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),