diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c5e5033973..e01844e233 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -42,7 +42,12 @@ import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; import { GraphiQLPage } from '@backstage/plugin-graphiql'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; -import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { + ScaffolderPage, + scaffolderPlugin, + OwnerPicker, + RepoUrlPicker, +} from '@backstage/plugin-scaffolder'; import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; @@ -107,7 +112,15 @@ const routes = ( } /> } /> - } /> + + + + + } + /> } /> ( - - } /> - } /> - } /> - } /> - -); +export const Router = ({ children }: React.PropsWithChildren<{}>) => { + const [state, dispatch] = React.useReducer(extensionsReducer, { + fields: [], + }); + return ( + + {children} + + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 12930db2cc..fb9ae1fb3d 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -26,7 +26,7 @@ import { import { LinearProgress } from '@material-ui/core'; import { FormValidation, IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { generatePath, useNavigate, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; @@ -35,6 +35,7 @@ import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { RepoUrlPicker, OwnerPicker } from '../fields'; import { JsonObject } from '@backstage/config'; +import { ExtensionContext } from '../../extensions'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -148,6 +149,7 @@ export const TemplatePage = () => { const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); + const { state } = useContext(ExtensionContext)!; const handleChange = useCallback( (e: IChangeEvent) => setFormState(e.formData), @@ -173,6 +175,12 @@ export const TemplatePage = () => { return ; } + const customFields = state!.fields.reduce((acc, next) => { + acc[next.name] = next.component; + return acc; + }, {} as Record); + + console.log(customFields); return (
{ > = { +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, + createCollector, +} from '@backstage/core-api/src/extensions/traversal'; + +export type FieldExtensionOptions = { name: string; component: ComponentLoader; + validation: (data: JsonValue, field: FieldValidation) => void; }; -export function createScaffolderFormExtension< +export function createScaffolderFieldExtension< T extends (props: any) => JSX.Element | null ->(options: FormExtensionOptions): Extension {} +>(options: FieldExtensionOptions): Extension { + const componentInData = + 'lazy' in options.component + ? React.lazy(() => + options.component.lazy().then(component => ({ default: component })), + ) + : options.component.sync; + + return createReactExtension({ + data: { + 'scaffolder.extensions.field.v1': { + ...options, + component: componentInData, + }, + }, + component: options.component, + }); +} + +export type ExtensionState = { + fields: FieldExtensionOptions[]; +}; +export type RegisterFieldExtensionAction = { + type: 'fields'; + data: FieldExtensionOptions[]; +}; + +export type ExtensionAction = RegisterFieldExtensionAction; +export type ExtensionDispatch = (action: ExtensionAction) => void; + +export const ExtensionContext = React.createContext< + { state: ExtensionState; dispatch: ExtensionDispatch } | undefined +>(undefined); + +export const extensionsReducer = ( + state: ExtensionState, + action: ExtensionAction, +): ExtensionState => { + if (action.type === 'fields') { + return { + ...state, + fields: [...state.fields, ...action.data], + }; + } + return state; +}; + +export const ExtensionCollector = ({ + children, +}: React.PropsWithChildren<{}>) => { + const context = React.useContext(ExtensionContext); + + if (!context) { + throw new Error('ExtensionsCollector must be used in a ExtensionsContext'); + } + + useMount(() => { + const { fields } = traverseElementTree({ + root: children, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + fields: createCollector( + () => [] as FieldExtensionOptions[], + (acc, node) => { + const data = getComponentData>( + node, + 'scaffolder.extensions.field.v1', + ); + + if (data) { + acc.push(data); + } + }, + ), + }, + }); + context.dispatch({ data: fields, type: 'fields' }); + }); + + return null; +}; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index e0b574e6c6..66eff47e5d 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -18,6 +18,8 @@ export { scaffolderPlugin, scaffolderPlugin as plugin, ScaffolderPage, + OwnerPicker, + RepoUrlPicker, } from './plugin'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index be47daec46..460f3f0e60 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { JsonValue } from '@backstage/config'; import { createApiFactory, createPlugin, @@ -21,8 +22,11 @@ import { discoveryApiRef, identityApiRef, } from '@backstage/core'; +import { OwnerPicker as OwnerPickerComponent } from './components/fields/OwnerPicker'; +import { RepoUrlPicker as RepoUrlPickerComponent } from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; +import { createScaffolderFieldExtension } from './extensions'; import { rootRouteRef, registerComponentRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ @@ -47,6 +51,35 @@ export const scaffolderPlugin = createPlugin({ }, }); +export const RepoUrlPicker = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + // TODO: work out how to type this component part so we can enforce FieldComponent from RJSF + component: { + sync: RepoUrlPickerComponent, + }, + name: 'RepoUrlPicker', + validation: (value: JsonValue, validation) => { + try { + const { host, searchParams } = new URL(`https://${value}`); + if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { + validation.addError('Incomplete repository location provided'); + } + } catch { + validation.addError('Unable to parse the Repository URL'); + } + }, + }), +); + +export const OwnerPicker = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: { + sync: OwnerPickerComponent, + }, + name: 'OwnerPicker', + validation: () => {}, + }), +); export const ScaffolderPage = scaffolderPlugin.provide( createRoutableExtension({ component: () => import('./components/Router').then(m => m.Router),