feat(scaffolder/fields): try a simpler way to register
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user