diff --git a/.changeset/brave-lemons-hope.md b/.changeset/brave-lemons-hope.md
new file mode 100644
index 0000000000..64c0d59538
--- /dev/null
+++ b/.changeset/brave-lemons-hope.md
@@ -0,0 +1,32 @@
+---
+'@backstage/plugin-scaffolder': patch
+---
+
+Scaffolder Field Extensions are here! This means you'll now the ability to create custom field extensions and have the Scaffolder use the components when collecting information from the user in the wizard. By default we supply the `RepoUrlPicker` and the `OwnerPicker`, but if you want to provide some more extensions or override the built on ones you will have to change how the `ScaffolderPage` is wired up in your `app/src/App.tsx` to pass in the custom fields to the Scaffolder.
+
+You'll need to move this:
+
+```tsx
+} />
+```
+
+To this:
+
+```tsx
+import {
+ ScaffolderFieldExtensions,
+ RepoUrlPickerFieldExtension,
+ OwnerPickerFieldExtension,
+} from '@backstage/plugin-scaffolder';
+
+}>
+
+
+
+
+ {/*Any other extensions you want to provide*/}
+
+;
+```
+
+More documentation on how to write your own `FieldExtensions` to follow.
diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts
index b04c7b41c4..bbcca88e98 100644
--- a/packages/core-api/src/plugin/collectors.ts
+++ b/packages/core-api/src/plugin/collectors.ts
@@ -13,21 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
import { BackstagePlugin } from './types';
import { getComponentData } from '../extensions';
diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx
index 026771ce4f..07d030b2c1 100644
--- a/packages/create-app/templates/default-app/packages/app/src/App.tsx
+++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx
@@ -13,7 +13,10 @@ import {
catalogPlugin,
} from '@backstage/plugin-catalog';
import {CatalogImportPage, catalogImportPlugin} from '@backstage/plugin-catalog-import';
-import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder';
+import {
+ ScaffolderPage,
+ scaffolderPlugin
+} from '@backstage/plugin-scaffolder';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import { TechdocsPage } from '@backstage/plugin-techdocs';
diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json
index 6accd83112..f1ad6c50bc 100644
--- a/plugins/scaffolder/package.json
+++ b/plugins/scaffolder/package.json
@@ -44,6 +44,7 @@
"@material-ui/lab": "4.0.0-alpha.45",
"@rjsf/core": "^2.4.0",
"@rjsf/material-ui": "^2.4.0",
+ "@types/react": "^16.9",
"classnames": "^2.2.6",
"json-schema": "^0.3.0",
"git-url-parse": "^11.4.4",
diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts
index c7b1594071..e7e4bea688 100644
--- a/plugins/scaffolder/src/api.ts
+++ b/plugins/scaffolder/src/api.ts
@@ -15,7 +15,7 @@
*/
import { EntityName } from '@backstage/catalog-model';
-import { JsonObject } from '@backstage/config';
+import { JsonObject, JsonValue } from '@backstage/config';
import {
createApiRef,
DiscoveryApi,
@@ -24,6 +24,7 @@ import {
} from '@backstage/core';
import { ResponseError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
+import { Field, FieldValidation } from '@rjsf/core';
import ObservableImpl from 'zen-observable';
import { ListActionsResponse, ScaffolderTask, Status } from './types';
@@ -52,6 +53,12 @@ export type LogEvent = {
taskId: string;
};
+export type CustomField = {
+ name: string;
+ component: Field;
+ validation: (data: JsonValue, field: FieldValidation) => void;
+};
+
export interface ScaffolderApi {
getTemplateParameterSchema(
templateName: EntityName,
diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx
index 9a525c0be4..5ac663c7ea 100644
--- a/plugins/scaffolder/src/components/Router.tsx
+++ b/plugins/scaffolder/src/components/Router.tsx
@@ -14,18 +14,44 @@
* limitations under the License.
*/
-import React from 'react';
-import { Routes, Route } from 'react-router';
+import React, { useMemo } from 'react';
+import { Routes, Route, useOutlet } from 'react-router';
import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
import { ActionsPage } from './ActionsPage';
-export const Router = () => (
-
- } />
- } />
- } />
- } />
-
-);
+import {
+ FieldExtensionOptions,
+ FIELD_EXTENSION_WRAPPER_KEY,
+ FIELD_EXTENSION_KEY,
+ DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
+} from '../extensions';
+import { collectComponentData, collectChildren } from '../extensions/helpers';
+
+export const Router = () => {
+ const outlet = useOutlet();
+
+ const fieldExtensions = useMemo(() => {
+ const registeredExtensions = collectComponentData(
+ collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(),
+ FIELD_EXTENSION_KEY,
+ );
+
+ return registeredExtensions.length
+ ? registeredExtensions
+ : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
+ }, [outlet]);
+
+ return (
+
+ } />
+ }
+ />
+ } />
+ } />
+
+ );
+};
diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx
index 2a21e9da72..0a946862ef 100644
--- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx
+++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx
@@ -23,7 +23,7 @@ import { act } from 'react-dom/test-utils';
import { MemoryRouter, Route } from 'react-router';
import { ScaffolderApi, scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
-import { createValidator, TemplatePage } from './TemplatePage';
+import { TemplatePage } from './TemplatePage';
jest.mock('react-router-dom', () => {
return {
@@ -197,38 +197,3 @@ describe('TemplatePage', () => {
expect(await findByText('Reset')).toBeInTheDocument();
});
});
-
-describe('createValidator', () => {
- it('should validate deep schema', () => {
- const validator = createValidator({
- type: 'object',
- properties: {
- foo: {
- type: 'object',
- properties: {
- bar: {
- type: 'string',
- 'ui:field': 'RepoUrlPicker',
- },
- },
- },
- },
- });
-
- const errors = { foo: { bar: { addError: jest.fn() } } };
- validator({ foo: { bar: 'github.com?owner=a' } }, errors as any);
- expect(errors.foo.bar.addError).toHaveBeenCalledWith(
- 'Incomplete repository location provided',
- );
- jest.resetAllMocks();
-
- validator({ foo: { bar: 'github.com?repo=b' } }, errors as any);
- expect(errors.foo.bar.addError).toHaveBeenCalledWith(
- 'Incomplete repository location provided',
- );
- jest.resetAllMocks();
-
- validator({ foo: { bar: 'github.com?owner=a&repo=b' } }, errors as any);
- expect(errors.foo.bar.addError).not.toHaveBeenCalled();
- });
-});
diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx
index 12930db2cc..6f212d9d75 100644
--- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx
+++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx
@@ -24,7 +24,7 @@ import {
useRouteRef,
} from '@backstage/core';
import { LinearProgress } from '@material-ui/core';
-import { FormValidation, IChangeEvent } from '@rjsf/core';
+import { FieldValidation, FormValidation, IChangeEvent } from '@rjsf/core';
import parseGitUrl from 'git-url-parse';
import React, { useCallback, useState } from 'react';
import { generatePath, useNavigate, Navigate } from 'react-router';
@@ -33,8 +33,8 @@ import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { MultistepJsonForm } from '../MultistepJsonForm';
-import { RepoUrlPicker, OwnerPicker } from '../fields';
-import { JsonObject } from '@backstage/config';
+import { JsonObject, JsonValue } from '@backstage/config';
+import { FieldExtensionOptions } from '../../extensions';
const useTemplateParameterSchema = (templateName: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
@@ -54,7 +54,13 @@ function isObject(obj: unknown): obj is JsonObject {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
}
-export const createValidator = (rootSchema: JsonObject) => {
+export const createValidator = (
+ rootSchema: JsonObject,
+ validators: Record<
+ string,
+ undefined | ((value: JsonValue, validation: FieldValidation) => void)
+ >,
+) => {
function validate(
schema: JsonObject,
formData: JsonObject,
@@ -66,7 +72,7 @@ export const createValidator = (rootSchema: JsonObject) => {
}
for (const [key, propData] of Object.entries(formData)) {
- const propErrors = errors[key];
+ const propValidation = errors[key];
if (isObject(propData)) {
const propSchemaProps = schemaProps[key];
@@ -74,27 +80,15 @@ export const createValidator = (rootSchema: JsonObject) => {
validate(
propSchemaProps,
propData as JsonObject,
- propErrors as FormValidation,
+ propValidation as FormValidation,
);
}
} else {
const propSchema = schemaProps[key];
- if (
- isObject(propSchema) &&
- propSchema['ui:field'] === 'RepoUrlPicker'
- ) {
- try {
- const { host, searchParams } = new URL(`https://${propData}`);
- if (
- !host ||
- !searchParams.get('owner') ||
- !searchParams.get('repo')
- ) {
- propErrors.addError('Incomplete repository location provided');
- }
- } catch {
- propErrors.addError('Unable to parse the Repository URL');
- }
+ const fieldName =
+ isObject(propSchema) && (propSchema['ui:field'] as string);
+ if (fieldName && typeof validators[fieldName] === 'function') {
+ validators[fieldName]!(propData as JsonValue, propValidation);
}
}
}
@@ -139,7 +133,11 @@ const storePathValidator = (
return errors;
};
-export const TemplatePage = () => {
+export const TemplatePage = ({
+ customFieldExtensions = [],
+}: {
+ customFieldExtensions?: FieldExtensionOptions[];
+}) => {
const errorApi = useApi(errorApiRef);
const scaffolderApi = useApi(scaffolderApiRef);
const { templateName } = useParams();
@@ -148,7 +146,6 @@ export const TemplatePage = () => {
const { schema, loading, error } = useTemplateParameterSchema(templateName);
const [formState, setFormState] = useState({});
const handleFormReset = () => setFormState({});
-
const handleChange = useCallback(
(e: IChangeEvent) => setFormState(e.formData),
[setFormState],
@@ -173,6 +170,14 @@ export const TemplatePage = () => {
return ;
}
+ const customFieldComponents = Object.fromEntries(
+ customFieldExtensions.map(({ name, component }) => [name, component]),
+ );
+
+ const customFieldValidators = Object.fromEntries(
+ customFieldExtensions.map(({ name, validation }) => [name, validation]),
+ );
+
return (
{
>
{
return {
...step,
- validate: createValidator(step.schema),
+ validate: createValidator(step.schema, customFieldValidators),
};
})}
/>
diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx
index 0091a6741e..39183a856f 100644
--- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx
+++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
-import { Field } from '@rjsf/core';
+import { FieldProps } from '@rjsf/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { useApi } from '@backstage/core';
import { useAsync } from 'react-use';
@@ -39,14 +39,14 @@ const entityRef = (entity: Entity | undefined): string => {
return `${kindPart}${namespacePart}${name}`;
};
-export const OwnerPicker: Field = ({
+export const OwnerPicker = ({
onChange,
schema: { title = 'Owner', description = 'The owner of the component' },
required,
uiSchema,
rawErrors,
formData,
-}) => {
+}: FieldProps) => {
const allowedKinds = (uiSchema['ui:options']?.allowedKinds || [
'Group',
'User',
diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx
index 31b4c4fa9b..49d16169ac 100644
--- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx
+++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { useCallback, useEffect } from 'react';
-import { Field } from '@rjsf/core';
+import { FieldProps } from '@rjsf/core';
import { useApi, Progress } from '@backstage/core';
import { scaffolderApiRef } from '../../../api';
import { useAsync } from 'react-use';
@@ -69,12 +69,12 @@ function serializeFormData(data: {
return `${data.host}?${params.toString()}`;
}
-export const RepoUrlPicker: Field = ({
+export const RepoUrlPicker = ({
onChange,
uiSchema,
rawErrors,
formData,
-}) => {
+}: FieldProps) => {
const api = useApi(scaffolderApiRef);
const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[];
diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts
index b0f3df61d9..32bb6ee7f7 100644
--- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts
+++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { RepoUrlPicker } from './RepoUrlPicker';
+export { repoPickerValidation } from './validation';
diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts
new file mode 100644
index 0000000000..7e05ca7851
--- /dev/null
+++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { repoPickerValidation } from './validation';
+import { FieldValidation } from '@rjsf/core';
+
+describe('RepoPicker Validation', () => {
+ const fieldValidator = () =>
+ (({
+ addError: jest.fn(),
+ } as unknown) as FieldValidation);
+
+ it('validaties when no repo', () => {
+ const mockFieldValidation = fieldValidator();
+
+ repoPickerValidation('github.com?owner=a', mockFieldValidation);
+
+ expect(mockFieldValidation.addError).toHaveBeenCalledWith(
+ 'Incomplete repository location provided',
+ );
+ });
+
+ it('validates when no owner', () => {
+ const mockFieldValidation = fieldValidator();
+
+ repoPickerValidation('github.com?repo=a', mockFieldValidation);
+
+ expect(mockFieldValidation.addError).toHaveBeenCalledWith(
+ 'Incomplete repository location provided',
+ );
+ });
+
+ it('validates when not a real url', () => {
+ const mockFieldValidation = fieldValidator();
+
+ repoPickerValidation('', mockFieldValidation);
+
+ expect(mockFieldValidation.addError).toHaveBeenCalledWith(
+ 'Unable to parse the Repository URL',
+ );
+ });
+
+ it('validates properly with proper input', () => {
+ const mockFieldValidation = fieldValidator();
+
+ repoPickerValidation('github.com?owner=a&repo=b', mockFieldValidation);
+
+ expect(mockFieldValidation.addError).not.toHaveBeenCalled();
+ });
+});
diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts
new file mode 100644
index 0000000000..f573838081
--- /dev/null
+++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { FieldValidation } from '@rjsf/core';
+
+export const repoPickerValidation = (
+ value: string,
+ validation: FieldValidation,
+) => {
+ 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');
+ }
+};
diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts
new file mode 100644
index 0000000000..2da0dc57ba
--- /dev/null
+++ b/plugins/scaffolder/src/extensions/default.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { OwnerPicker } from '../components/fields/OwnerPicker';
+import {
+ RepoUrlPicker,
+ repoPickerValidation,
+} from '../components/fields/RepoUrlPicker';
+import { FieldExtensionOptions } from './types';
+
+export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS: FieldExtensionOptions[] = [
+ {
+ component: RepoUrlPicker,
+ name: 'RepoUrlPicker',
+ validation: repoPickerValidation,
+ },
+ {
+ component: OwnerPicker,
+ name: 'OwnerPicker',
+ },
+];
diff --git a/plugins/scaffolder/src/extensions/helpers.test.tsx b/plugins/scaffolder/src/extensions/helpers.test.tsx
new file mode 100644
index 0000000000..ed3b74a210
--- /dev/null
+++ b/plugins/scaffolder/src/extensions/helpers.test.tsx
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { collectComponentData, collectChildren } from './helpers';
+import { attachComponentData } from '@backstage/core';
+
+describe('Extension Helpers', () => {
+ const createElementWithComponentData = ({
+ type,
+ data,
+ }: {
+ type: string;
+ data: any;
+ }) => {
+ const element: React.ComponentType = () => null;
+ attachComponentData(element, type, data);
+ return element;
+ };
+
+ describe('collectChildren', () => {
+ it('should return the children of the component which has the correct componentData flag', () => {
+ const SearchElement = createElementWithComponentData({
+ type: 'find.me',
+ data: {},
+ });
+
+ const DontCareAboutme = createElementWithComponentData({
+ type: 'dont.find.me',
+ data: {},
+ });
+
+ const child1 = (
+
+ hello
+
+ );
+
+ const child2 = (
+
+ );
+
+ const testCase = (
+
+
+ {child1}
+ {child1}
+
+
{child2}
+
+ Hello!
+ {child1}
+
+
+ );
+
+ const children = collectChildren(testCase, 'find.me');
+
+ expect(children).toEqual([[child1, child1], child2, child1]);
+ });
+ });
+
+ describe('collectComponentData', () => {
+ it('should return the componentData for particular nodes', () => {
+ const componentData1 = { help: 'im something' };
+ const componentData2 = { help: 'im something else' };
+
+ const FirstElement = createElementWithComponentData({
+ type: 'find.me',
+ data: componentData1,
+ });
+
+ const SecondElement = createElementWithComponentData({
+ type: 'dont.find.me',
+ data: componentData2,
+ });
+
+ const testCase = [
+ ,
+ ,
+ ,
+ ,
+ ];
+ const returnedData = collectComponentData(testCase, 'find.me');
+
+ expect(returnedData).toEqual([
+ componentData1,
+ componentData1,
+ componentData1,
+ ]);
+ });
+ });
+});
diff --git a/plugins/scaffolder/src/extensions/helpers.ts b/plugins/scaffolder/src/extensions/helpers.ts
new file mode 100644
index 0000000000..2eac9780e5
--- /dev/null
+++ b/plugins/scaffolder/src/extensions/helpers.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { getComponentData } from '@backstage/core';
+
+export const collectComponentData = (
+ children: React.ReactNode,
+ componentDataKey: string,
+) => {
+ const stack = [children];
+ const found: T[] = [];
+
+ while (stack.length) {
+ const current: React.ReactNode = stack.pop()!;
+
+ React.Children.forEach(current, child => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ const data = getComponentData(child, componentDataKey);
+ if (data) {
+ found.push(data);
+ }
+
+ if (child.props.children) {
+ stack.push(child.props.children);
+ }
+ });
+ }
+
+ return found;
+};
+
+export const collectChildren = (
+ component: React.ReactNode,
+ componentDataKey: string,
+) => {
+ const stack = [component];
+ const found: React.ReactNode[] = [];
+
+ while (stack.length) {
+ const current: React.ReactNode = stack.pop()!;
+
+ React.Children.forEach(current, child => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ if (child.props.children) {
+ if (getComponentData(child, componentDataKey)) {
+ found.push(child.props.children);
+ }
+ stack.push(child.props.children);
+ }
+ });
+ }
+
+ return found;
+};
diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx
new file mode 100644
index 0000000000..bb53329342
--- /dev/null
+++ b/plugins/scaffolder/src/extensions/index.tsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Extension, attachComponentData } from '@backstage/core';
+import React from 'react';
+import { FieldExtensionOptions } from './types';
+
+export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1';
+export const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1';
+
+export function createScaffolderFieldExtension(
+ options: FieldExtensionOptions,
+): Extension<() => null> {
+ return {
+ expose() {
+ const FieldExtensionDataHolder: any = () => null;
+
+ attachComponentData(
+ FieldExtensionDataHolder,
+ FIELD_EXTENSION_KEY,
+ options,
+ );
+
+ return FieldExtensionDataHolder;
+ },
+ };
+}
+
+export const ScaffolderFieldExtensions: React.ComponentType = () => null;
+attachComponentData(
+ ScaffolderFieldExtensions,
+ FIELD_EXTENSION_WRAPPER_KEY,
+ true,
+);
+
+export type { FieldExtensionOptions };
+
+export { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from './default';
diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts
new file mode 100644
index 0000000000..5e7de9087f
--- /dev/null
+++ b/plugins/scaffolder/src/extensions/types.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { FieldValidation, FieldProps } from '@rjsf/core';
+
+export type FieldExtensionOptions = {
+ name: string;
+ component: (props: FieldProps) => JSX.Element | null;
+ validation?: (data: T, field: FieldValidation) => void;
+};
diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts
index e0b574e6c6..c22597f373 100644
--- a/plugins/scaffolder/src/index.ts
+++ b/plugins/scaffolder/src/index.ts
@@ -18,6 +18,9 @@ export {
scaffolderPlugin,
scaffolderPlugin as plugin,
ScaffolderPage,
+ OwnerPickerFieldExtension,
+ RepoUrlPickerFieldExtension,
} from './plugin';
+export { ScaffolderFieldExtensions } from './extensions';
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..5991ae4bc3 100644
--- a/plugins/scaffolder/src/plugin.ts
+++ b/plugins/scaffolder/src/plugin.ts
@@ -21,8 +21,14 @@ import {
discoveryApiRef,
identityApiRef,
} from '@backstage/core';
+import { OwnerPicker } from './components/fields/OwnerPicker';
+import {
+ RepoUrlPicker,
+ repoPickerValidation,
+} 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 +53,21 @@ export const scaffolderPlugin = createPlugin({
},
});
+export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide(
+ createScaffolderFieldExtension({
+ component: RepoUrlPicker,
+ name: 'RepoUrlPicker',
+ validation: repoPickerValidation,
+ }),
+);
+
+export const OwnerPickerFieldExtension = scaffolderPlugin.provide(
+ createScaffolderFieldExtension({
+ component: OwnerPicker,
+ name: 'OwnerPicker',
+ }),
+);
+
export const ScaffolderPage = scaffolderPlugin.provide(
createRoutableExtension({
component: () => import('./components/Router').then(m => m.Router),