feat(scaffolder/fields): try a simpler way to register

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2021-05-16 16:45:05 +02:00
parent 6bba66c0d6
commit feb0cc5f54
4 changed files with 55 additions and 127 deletions
+34 -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,
@@ -75,6 +82,13 @@ export interface ScaffolderApi {
// Returns a list of all installed actions.
listActions(): Promise<ListActionsResponse>;
// Register Custom Fields
registerCustomField(field: CustomField): void;
getCustomFields(): {
components: Map<string, Field>;
validators: Map<string, (data: JsonValue, field: FieldValidation) => void>;
};
streamLogs({
taskId,
after,
@@ -88,6 +102,7 @@ export class ScaffolderClient implements ScaffolderApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
private readonly scmIntegrationsApi: ScmIntegrationRegistry;
private readonly customFields: Map<string, CustomField> = new Map();
constructor(options: {
discoveryApi: DiscoveryApi;
@@ -136,6 +151,24 @@ export class ScaffolderClient implements ScaffolderApi {
return schema;
}
registerCustomField(field: CustomField) {
this.customFields.set(field.name, field);
}
getCustomFields() {
return [...this.customFields.entries()].reduce(
(previous, [name, field]) => {
previous.components.set(name, field.component);
previous.validators.set(name, field.validation);
return previous;
},
{
components: new Map(),
validators: new Map(),
},
);
}
/**
* Executes the scaffolding of a component, given a template and its
* parameter values.
+3 -11
View File
@@ -20,25 +20,17 @@ import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
import { ActionsPage } from './ActionsPage';
import {
ExtensionContext,
ExtensionCollector,
extensionsReducer,
} from '../extensions';
export const Router = ({ children }: React.PropsWithChildren<{}>) => {
const [state, dispatch] = React.useReducer(extensionsReducer, {
fields: [],
});
return (
<ExtensionContext.Provider value={{ state, dispatch }}>
<ExtensionCollector>{children}</ExtensionCollector>
<>
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route path="/templates/:templateName" element={<TemplatePage />} />
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
</ExtensionContext.Provider>
{children}
</>
);
};
@@ -24,23 +24,16 @@ import {
useRouteRef,
} from '@backstage/core';
import { LinearProgress } from '@material-ui/core';
import {
Field,
FieldValidation,
FormValidation,
IChangeEvent,
} from '@rjsf/core';
import { FieldValidation, FormValidation, IChangeEvent } from '@rjsf/core';
import parseGitUrl from 'git-url-parse';
import React, { useCallback, useContext, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { generatePath, useNavigate, Navigate } from 'react-router';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { RepoUrlPicker, OwnerPicker } from '../fields';
import { JsonObject, JsonValue } from '@backstage/config';
import { ExtensionContext } from '../../extensions';
const useTemplateParameterSchema = (templateName: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
@@ -62,7 +55,10 @@ function isObject(obj: unknown): obj is JsonObject {
export const createValidator = (
rootSchema: JsonObject,
validators: Record<string, (value: any, validation: FieldValidation) => void>,
validators: Map<
string,
(value: JsonValue, validation: FieldValidation) => void
>,
) => {
function validate(
schema: JsonObject,
@@ -90,9 +86,8 @@ export const createValidator = (
const propSchema = schemaProps[key];
const fieldName =
isObject(propSchema) && (propSchema['ui:field'] as string);
const validator = fieldName && validators[fieldName];
if (validator) {
validator(propData, propValidation);
if (fieldName && validators.has(fieldName)) {
validators.get(fieldName)!(propData as JsonValue, propValidation);
}
}
}
@@ -146,7 +141,6 @@ 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),
@@ -172,18 +166,7 @@ export const TemplatePage = () => {
return <Navigate to={rootLink()} />;
}
const { components, validation } = state!.fields.reduce(
(acc, next) => {
acc.components[next.name] = next.component;
acc.validation[next.name] = next.validation;
return acc;
},
{ components: {}, validation: {} } as {
components: Record<string, Field>;
validation: Record<string, (data: JsonValue, f: FieldValidation) => void>;
},
);
const { components, validators } = scaffolderApi.getCustomFields();
return (
<Page themeId="home">
<Header
@@ -205,7 +188,7 @@ export const TemplatePage = () => {
>
<MultistepJsonForm
formData={formState}
fields={components}
fields={Object.fromEntries(components.entries())}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
@@ -221,7 +204,7 @@ export const TemplatePage = () => {
return {
...step,
validate: createValidator(step.schema, validation),
validate: createValidator(step.schema, validators),
};
})}
/>
+7 -87
View File
@@ -14,23 +14,9 @@
* limitations under the License.
*/
import { JsonValue } from '@backstage/config';
import {
Extension,
ComponentLoader,
createReactExtension,
getComponentData,
attachComponentData,
} from '@backstage/core';
import { Extension, useApi } from '@backstage/core';
import { FieldValidation, Field } from '@rjsf/core';
import React from 'react';
import { useMount } from 'react-use';
import {
childDiscoverer,
routeElementDiscoverer,
traverseElementTree,
createCollector,
} from '@backstage/core-api/src/extensions/traversal';
import { scaffolderApiRef } from '../api';
export type FieldExtensionOptions = {
name: string;
@@ -41,81 +27,15 @@ export type FieldExtensionOptions = {
export function createScaffolderFieldExtension(
options: FieldExtensionOptions,
): Extension<Field> {
const extensionData = {
'scaffolder.extensions.field.v1': options,
const WrappingRegister = () => {
const scaffolderApi = useApi(scaffolderApiRef);
scaffolderApi.registerCustomField(options);
return null;
};
const Result = (props: any) => <options.component {...props} />;
for (const [key, value] of Object.entries(extensionData)) {
attachComponentData(Result, key, value);
}
return {
expose() {
return Result;
return WrappingRegister;
},
};
}
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<FieldExtensionOptions>(
node,
'scaffolder.extensions.field.v1',
);
if (data) {
acc.push(data);
}
},
),
},
});
context.dispatch({ data: fields, type: 'fields' });
});
return null;
};