Merge pull request #5677 from backstage/feat/create-scaffolder-extension

Scaffolder Field Extensions
This commit is contained in:
Ben Lambert
2021-05-31 21:02:40 +02:00
committed by GitHub
20 changed files with 522 additions and 95 deletions
+32
View File
@@ -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
<Route path="/create" element={<ScaffolderPage />} />
```
To this:
```tsx
import {
ScaffolderFieldExtensions,
RepoUrlPickerFieldExtension,
OwnerPickerFieldExtension,
} from '@backstage/plugin-scaffolder';
<Route path="/create" element={<ScaffolderPage />}>
<ScaffolderFieldExtensions>
<RepoUrlPickerFieldExtension />
<OwnerPickerFieldExtension />
{/*Any other extensions you want to provide*/}
</ScaffolderFieldExtensions>
</Route>;
```
More documentation on how to write your own `FieldExtensions` to follow.
@@ -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';
@@ -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';
+1
View File
@@ -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",
+8 -1
View File
@@ -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,
+36 -10
View File
@@ -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 = () => (
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route path="/templates/:templateName" element={<TemplatePage />} />
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
);
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<FieldExtensionOptions>(
collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(),
FIELD_EXTENSION_KEY,
);
return registeredExtensions.length
? registeredExtensions
: DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS;
}, [outlet]);
return (
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route
path="/templates/:templateName"
element={<TemplatePage customFieldExtensions={fieldExtensions} />}
/>
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
);
};
@@ -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();
});
});
@@ -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 <Navigate to={rootLink()} />;
}
const customFieldComponents = Object.fromEntries(
customFieldExtensions.map(({ name, component }) => [name, component]),
);
const customFieldValidators = Object.fromEntries(
customFieldExtensions.map(({ name, validation }) => [name, validation]),
);
return (
<Page themeId="home">
<Header
@@ -194,7 +199,7 @@ export const TemplatePage = () => {
>
<MultistepJsonForm
formData={formState}
fields={{ RepoUrlPicker, OwnerPicker }}
fields={customFieldComponents}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
@@ -210,7 +215,7 @@ export const TemplatePage = () => {
return {
...step,
validate: createValidator(step.schema),
validate: createValidator(step.schema, customFieldValidators),
};
})}
/>
@@ -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<string>) => {
const allowedKinds = (uiSchema['ui:options']?.allowedKinds || [
'Group',
'User',
@@ -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<string>) => {
const api = useApi(scaffolderApiRef);
const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[];
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { RepoUrlPicker } from './RepoUrlPicker';
export { repoPickerValidation } from './validation';
@@ -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();
});
});
@@ -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');
}
};
@@ -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',
},
];
@@ -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 = (
<div>
<b>hello</b>
</div>
);
const child2 = (
<div>
<p>Hello2</p>
</div>
);
const testCase = (
<div>
<SearchElement>
{child1}
{child1}
</SearchElement>
<SearchElement>{child2}</SearchElement>
<DontCareAboutme>
<p>Hello!</p>
<SearchElement>{child1}</SearchElement>
</DontCareAboutme>
</div>
);
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 = [
<FirstElement />,
<FirstElement />,
<SecondElement />,
<FirstElement />,
];
const returnedData = collectComponentData(testCase, 'find.me');
expect(returnedData).toEqual([
componentData1,
componentData1,
componentData1,
]);
});
});
});
@@ -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 = <T>(
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<T>(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;
};
@@ -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<T = any>(
options: FieldExtensionOptions<T>,
): 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';
@@ -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<T = any> = {
name: string;
component: (props: FieldProps<T>) => JSX.Element | null;
validation?: (data: T, field: FieldValidation) => void;
};
+3
View File
@@ -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';
+21
View File
@@ -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),