From 189201ef4b685f7d47f59de2e8b5662e6ee0d647 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Oct 2024 10:42:06 +0200 Subject: [PATCH 01/16] wip: start to implement the hooks Signed-off-by: blam --- .../src/Template.v1beta3.schema.json | 14 +++ .../src/TemplateEntityV1beta3.ts | 5 ++ ...tsx => createScaffolderFieldExtension.tsx} | 0 .../scaffolder-react/src/extensions/index.ts | 16 ++++ .../src/next/components/Workflow/Workflow.tsx | 2 + .../createScaffolderFormHook.test.ts | 51 +++++++++++ .../extensions/createScaffolderFormHook.ts | 79 +++++++++++++++++ .../src/next/extensions/index.ts | 16 ++++ plugins/scaffolder-react/src/next/index.ts | 1 + .../src/alpha/api/FormHooksApi.test.ts | 85 +++++++++++++++++++ .../scaffolder/src/alpha/api/FormHooksApi.ts | 63 ++++++++++++++ plugins/scaffolder/src/alpha/api/ref.ts | 6 +- plugins/scaffolder/src/alpha/api/types.ts | 9 +- .../TemplateWizardPage/TemplateWizardPage.tsx | 7 +- .../src/components/Router/Router.tsx | 9 +- 15 files changed, 358 insertions(+), 5 deletions(-) rename plugins/scaffolder-react/src/extensions/{index.tsx => createScaffolderFieldExtension.tsx} (100%) create mode 100644 plugins/scaffolder-react/src/extensions/index.ts create mode 100644 plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts create mode 100644 plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts create mode 100644 plugins/scaffolder-react/src/next/extensions/index.ts create mode 100644 plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts create mode 100644 plugins/scaffolder/src/alpha/api/FormHooksApi.ts diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index 4e34545d6d..a0392f63d9 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -173,6 +173,20 @@ } } }, + "EXPERIMENTAL_formHooks": { + "type": "object", + "description": "A list of hooks and their inputs that the form should trigger before submitting the job", + "properties": { + "id": { + "type": "string", + "description": "The form hook ID" + }, + "input": { + "type": "object", + "description": "A object describing the inputs to the form hook." + } + } + }, "steps": { "type": "array", "description": "A list of steps to execute.", diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index 0e2fec4264..d445a0ac01 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -56,6 +56,11 @@ export interface TemplateEntityV1beta3 extends Entity { */ EXPERIMENTAL_recovery?: TemplateRecoveryV1beta3; + /** + * Form hooks to be run + */ + EXPERIMENTAL_formHooks?: { id: string; input: JsonObject }[]; + /** * This is a JSONSchema or an array of JSONSchema's which is used to render a form in the frontend * to collect user input and validate it against that schema. This can then be used in the `steps` part below to template diff --git a/plugins/scaffolder-react/src/extensions/index.tsx b/plugins/scaffolder-react/src/extensions/createScaffolderFieldExtension.tsx similarity index 100% rename from plugins/scaffolder-react/src/extensions/index.tsx rename to plugins/scaffolder-react/src/extensions/createScaffolderFieldExtension.tsx diff --git a/plugins/scaffolder-react/src/extensions/index.ts b/plugins/scaffolder-react/src/extensions/index.ts new file mode 100644 index 0000000000..874fa607fb --- /dev/null +++ b/plugins/scaffolder-react/src/extensions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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. + */ +export * from './createScaffolderFieldExtension'; diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 9afc18c438..da04e2720c 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -31,6 +31,7 @@ import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProper import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; import { JsonValue } from '@backstage/types'; +import { ScaffolderFormHook } from '../../extensions'; const useStyles = makeStyles({ markdown: { @@ -52,6 +53,7 @@ export type WorkflowProps = { description?: string; namespace: string; templateName: string; + formHooks?: ScaffolderFormHook[]; components?: { ReviewStepComponent?: React.ComponentType; }; diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts new file mode 100644 index 0000000000..f694092af3 --- /dev/null +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { createScaffolderFormHook } from './createScaffolderFormHook'; + +describe('createScaffolderFormHook', () => { + it('should return a hook', () => { + const hook = createScaffolderFormHook({ + id: 'test', + deps: {}, + fn: async () => {}, + }); + + expect(hook).toMatchInlineSnapshot(); + }); + + it('should allow passing schema and be typesafe', () => { + const hook = createScaffolderFormHook({ + id: 'test', + deps: {}, + schema: { + input: { + name: z => z.string(), + age: z => z.number(), + }, + }, + fn: async ctx => { + const name: string = ctx.input.name; + + // @ts-expect-error + const number: string = ctx.input.age; + + expect([name, number]).toBeDefined(); + }, + }); + + expect(hook).toMatchInlineSnapshot(); + }); +}); diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts new file mode 100644 index 0000000000..10cca94113 --- /dev/null +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { AnyApiRef } from '@backstage/core-plugin-api'; +import { JsonValue } from '@backstage/types'; +import { z } from 'zod'; + +export type ScaffolderFormHookContext = { + input: TInput; + setSecret: (key: string, value: JsonValue) => void; +}; + +export type ScaffolderInitialFormHook< + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, + TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, + TInput = { + [key in keyof TInputSchema]: z.infer>; + }, +> = { + version: 'v1'; + id: string; + schema?: { + input?: TInputSchema; + }; + deps?: TDeps; + fn: ( + ctx: ScaffolderFormHookContext, + deps: TDeps extends { [key in string]: AnyApiRef } + ? { [key in keyof TDeps]: TDeps[key]['T'] } + : never, + ) => Promise; +}; + +export type ScaffolderFormHook = { + id: string; + fn: (ctx: ScaffolderFormHookContext) => Promise; +}; + +/** + * Method for creating hooks which can be used to collect + * secrets from the user before submitting to the backend. + * @public + */ +export function createScaffolderFormHook< + TDeps extends { [key in string]: AnyApiRef }, + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, + TInput = { + [key in keyof TInputSchema]: z.infer>; + }, +>(options: { + id: string; + schema?: { + input?: TInputSchema; + }; + deps?: TDeps; + fn: ( + ctx: ScaffolderFormHookContext, + deps: TDeps extends { [key in string]: AnyApiRef } + ? { [key in keyof TDeps]: TDeps[key]['T'] } + : never, + ) => Promise; +}): ScaffolderInitialFormHook { + return { + ...options, + version: 'v1', + }; +} diff --git a/plugins/scaffolder-react/src/next/extensions/index.ts b/plugins/scaffolder-react/src/next/extensions/index.ts new file mode 100644 index 0000000000..fff3dfbf4a --- /dev/null +++ b/plugins/scaffolder-react/src/next/extensions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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. + */ +export * from './createScaffolderFormHook'; diff --git a/plugins/scaffolder-react/src/next/index.ts b/plugins/scaffolder-react/src/next/index.ts index 07f4565166..c4e94f36a3 100644 --- a/plugins/scaffolder-react/src/next/index.ts +++ b/plugins/scaffolder-react/src/next/index.ts @@ -18,3 +18,4 @@ export * from './lib'; export * from './hooks'; export * from './overridableComponents'; export * from './blueprints'; +export * from './extensions'; diff --git a/plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts b/plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts new file mode 100644 index 0000000000..32e9d84740 --- /dev/null +++ b/plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { DefaultScaffolderFormHooksApi } from './FormHooksApi'; +import { createScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; +import { createApiRef } from '@backstage/core-plugin-api'; + +import { TestApiRegistry, withLogCollector } from '@backstage/test-utils'; + +describe('FormHooksApi', () => { + const mockApiRef = createApiRef<{ + test: (input: string) => void; + }>({ id: 'test' }); + const mockApiImplementation = { test: jest.fn() }; + + it('should wrap up the form hooks', async () => { + const mockHook = createScaffolderFormHook({ + id: 'test', + deps: { mockApiRef }, + schema: { + input: { + test: z => z.string(), + }, + }, + async fn({ input: { test } }, { mockApiRef: mock }) { + mock.test(test); + }, + }); + + const hooks = new DefaultScaffolderFormHooksApi({ + formHooks: [mockHook], + apiHolder: TestApiRegistry.from([mockApiRef, mockApiImplementation]), + }); + + const formHooks = await hooks.getFormHooks(); + const [boundHook] = formHooks; + + expect(formHooks.length).toBe(1); + expect(boundHook.id).toBe('test'); + + await boundHook.fn({ setSecret: () => {}, input: { test: 'input value' } }); + expect(mockApiImplementation.test).toHaveBeenCalledWith('input value'); + }); + + it('should skip failing deps', async () => { + const mockHook = createScaffolderFormHook({ + id: 'test', + deps: { mockApiRef }, + schema: { + input: { + test: z => z.string(), + }, + }, + async fn({ input: { test } }, { mockApiRef: mock }) { + mock.test(test); + }, + }); + + const hooks = new DefaultScaffolderFormHooksApi({ + formHooks: [mockHook], + apiHolder: TestApiRegistry.from(), + }); + + const { error } = await withLogCollector(async () => { + const formHooks = await hooks.getFormHooks(); + expect(formHooks.length).toBe(0); + }); + + expect(error[0]).toMatchInlineSnapshot( + `[Error: Failed to resolve apiRef test for form hook test - it will be disabled]`, + ); + }); +}); diff --git a/plugins/scaffolder/src/alpha/api/FormHooksApi.ts b/plugins/scaffolder/src/alpha/api/FormHooksApi.ts new file mode 100644 index 0000000000..51b7af18e7 --- /dev/null +++ b/plugins/scaffolder/src/alpha/api/FormHooksApi.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { ApiHolder } from '@backstage/core-plugin-api'; +import { ScaffolderFormHooksApi } from './types'; +import { + ScaffolderFormHook, + ScaffolderInitialFormHook, +} from '@backstage/plugin-scaffolder-react/alpha'; + +export class DefaultScaffolderFormHooksApi implements ScaffolderFormHooksApi { + constructor( + private readonly options: { + formHooks: Array; + apiHolder: ApiHolder; + }, + ) {} + + async getFormHooks(): Promise { + return this.options.formHooks + .map(hook => { + try { + const resolvedDeps = Object.entries(hook.deps ?? {}).map( + ([key, value]) => { + const api = this.options.apiHolder.get(value); + if (!api) { + // eslint-disable-next-line no-console + throw new Error( + `Failed to resolve apiRef ${value.id} for form hook ${hook.id} - it will be disabled`, + ); + } + return [key, api]; + }, + ); + + return { + id: hook.id, + // todo(blam): should probably use zod to validate the input here + // or maybe move that to the `createScaffolderFormHook` method instead. + fn: input => hook.fn(input, Object.fromEntries(resolvedDeps)), + } as ScaffolderFormHook; + } catch (ex) { + // eslint-disable-next-line no-console + console.error(ex); + return undefined; + } + }) + .filter((h): h is ScaffolderFormHook => !!h); + } +} diff --git a/plugins/scaffolder/src/alpha/api/ref.ts b/plugins/scaffolder/src/alpha/api/ref.ts index 52bc44e674..d977f5a817 100644 --- a/plugins/scaffolder/src/alpha/api/ref.ts +++ b/plugins/scaffolder/src/alpha/api/ref.ts @@ -15,8 +15,12 @@ */ import { createApiRef } from '@backstage/frontend-plugin-api'; -import { ScaffolderFormFieldsApi } from './types'; +import { ScaffolderFormFieldsApi, ScaffolderFormHooksApi } from './types'; export const formFieldsApiRef = createApiRef({ id: 'plugin.scaffolder.form-fields', }); + +export const formHooksApiRef = createApiRef({ + id: 'plugin.scaffolder.form-hooks', +}); diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts index 81f08b18e2..98c47f72a9 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -14,8 +14,15 @@ * limitations under the License. */ -import { FormFieldExtensionData } from '@backstage/plugin-scaffolder-react/alpha'; +import { + FormFieldExtensionData, + ScaffolderFormHook, +} from '@backstage/plugin-scaffolder-react/alpha'; export interface ScaffolderFormFieldsApi { getFormFields(): Promise; } + +export interface ScaffolderFormHooksApi { + getFormHooks(): Promise; +} diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index b5e24a7d52..fb0815ff8e 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -36,7 +36,10 @@ import { } from '@backstage/plugin-scaffolder-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { Workflow } from '@backstage/plugin-scaffolder-react/alpha'; +import { + ScaffolderFormHook, + Workflow, +} from '@backstage/plugin-scaffolder-react/alpha'; import { JsonValue } from '@backstage/types'; import { Header, Page } from '@backstage/core-components'; @@ -65,6 +68,7 @@ export type TemplateWizardPageProps = { title?: string; subtitle?: string; }; + EXPERIMENTAL_formHooks?: ScaffolderFormHook[]; }; export const TemplateWizardPage = (props: TemplateWizardPageProps) => { @@ -122,6 +126,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { extensions={props.customFieldExtensions} formProps={props.formProps} layouts={props.layouts} + EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks} /> diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index 79684a694d..fd19102849 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -60,8 +60,11 @@ import { CustomFieldsPage, } from '../../alpha/components/TemplateEditorPage'; import { RequirePermission } from '@backstage/plugin-permission-react'; -import { taskReadPermission } from '@backstage/plugin-scaffolder-common/alpha'; -import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha'; +import { + taskReadPermission, + templateManagementPermission, +} from '@backstage/plugin-scaffolder-common/alpha'; +import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; /** * The Props for the Scaffolder Router @@ -81,6 +84,7 @@ export type RouterProps = { EXPERIMENTAL_TemplateListPageComponent?: React.ComponentType; EXPERIMENTAL_TemplateWizardPageComponent?: React.ComponentType; }; + EXPERIMENTAL_formHooks?: ScaffolderFormHook[]; groups?: TemplateGroupFilter[]; templateFilter?: (entity: TemplateEntityV1beta3) => boolean; headerOptions?: { @@ -160,6 +164,7 @@ export const Router = (props: PropsWithChildren) => { layouts={customLayouts} components={{ ReviewStepComponent }} formProps={props.formProps} + EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks} /> } From aee334c14ceff6c0e3f235846613a67e6a93fab4 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Oct 2024 18:00:26 +0200 Subject: [PATCH 02/16] feat: implemented useFormHooks hook for calling hooks on create Signed-off-by: blam --- .../scaffolder-backend/src/service/router.ts | 1 + .../src/TemplateEntityV1beta3.ts | 2 +- plugins/scaffolder-react/report.api.md | 4 + .../src/next/components/Workflow/Workflow.tsx | 45 ++++++-- .../extensions/createScaffolderFormHook.ts | 16 +-- .../next/hooks/useTemplateParameterSchema.ts | 9 +- plugins/scaffolder-react/src/types.ts | 1 + .../src/alpha/api/FormHooksApi.test.ts | 85 -------------- .../scaffolder/src/alpha/api/FormHooksApi.ts | 43 ++----- .../TemplateWizardPage/TemplateWizardPage.tsx | 1 - .../src/alpha/hooks/useFormHooks.test.tsx | 105 ++++++++++++++++++ .../src/alpha/hooks/useFormHooks.ts | 60 ++++++++++ .../src/components/Router/Router.tsx | 2 - 13 files changed, 226 insertions(+), 148 deletions(-) delete mode 100644 plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts create mode 100644 plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx create mode 100644 plugins/scaffolder/src/alpha/hooks/useFormHooks.ts diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 261beb1f56..c5457fe603 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -475,6 +475,7 @@ export async function createRouter( description: schema.description, schema, })), + EXPERIMENTAL_formHooks: template.spec.EXPERIMENTAL_formHooks, }); }, ) diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index d445a0ac01..942de02bad 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -59,7 +59,7 @@ export interface TemplateEntityV1beta3 extends Entity { /** * Form hooks to be run */ - EXPERIMENTAL_formHooks?: { id: string; input: JsonObject }[]; + EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[]; /** * This is a JSONSchema or an array of JSONSchema's which is used to render a form in the frontend diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index 5523b52ad9..1c19729ee4 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -538,6 +538,10 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + EXPERIMENTAL_formHooks?: { + id: string; + input?: JsonObject; + }[]; }; // @public diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index da04e2720c..0d271095e7 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -26,12 +26,16 @@ import { makeStyles } from '@material-ui/core/styles'; import { errorApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api'; import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema'; import { Stepper, type StepperProps } from '../Stepper/Stepper'; -import { SecretsContextProvider } from '../../../secrets/SecretsContext'; +import { + SecretsContextProvider, + useTemplateSecrets, +} from '../../../secrets/SecretsContext'; import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; import { JsonValue } from '@backstage/types'; -import { ScaffolderFormHook } from '../../extensions'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { useFormHooks } from '../../../../../scaffolder/src/alpha/hooks/useFormHooks'; const useStyles = makeStyles({ markdown: { @@ -53,7 +57,6 @@ export type WorkflowProps = { description?: string; namespace: string; templateName: string; - formHooks?: ScaffolderFormHook[]; components?: { ReviewStepComponent?: React.ComponentType; }; @@ -74,7 +77,6 @@ export type WorkflowProps = { export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const { title, description, namespace, templateName, onCreate, ...props } = workflowProps; - const analytics = useAnalytics(); const styles = useStyles(); const templateRef = stringifyEntityRef({ @@ -82,17 +84,34 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { namespace: namespace, name: templateName, }); - const errorApi = useApi(errorApiRef); - const { loading, manifest, error } = useTemplateParameterSchema(templateRef); - const sortedManifest = useFilteredSchemaProperties(manifest); - const minutesSaved = useTemplateTimeSavedMinutes(templateRef); + const { setSecrets } = useTemplateSecrets(); + const formHooks = useFormHooks(); const workflowOnCreate = useCallback( async (formState: Record) => { + if (manifest?.EXPERIMENTAL_formHooks && formHooks?.size) { + // for each of the form hooks, go and call the hook with the context + await Promise.all( + manifest.EXPERIMENTAL_formHooks.map(async hook => { + const formHook = formHooks.get(hook.id); + if (!formHook) { + // eslint-disable-next-line no-console + console.error('Failed to find form hook', hook.id); + return; + } + + await formHook.fn({ + setSecrets, + input: hook.input, + }); + }), + ); + } + onCreate(formState); const name = @@ -101,7 +120,15 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { value: minutesSaved, }); }, - [onCreate, analytics, templateName, minutesSaved], + [ + manifest?.EXPERIMENTAL_formHooks, + formHooks, + onCreate, + analytics, + templateName, + minutesSaved, + setSecrets, + ], ); useEffect(() => { diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts index 10cca94113..261a003745 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts @@ -14,18 +14,17 @@ * limitations under the License. */ import { AnyApiRef } from '@backstage/core-plugin-api'; -import { JsonValue } from '@backstage/types'; import { z } from 'zod'; export type ScaffolderFormHookContext = { input: TInput; - setSecret: (key: string, value: JsonValue) => void; + setSecrets: (input: Record) => void; }; -export type ScaffolderInitialFormHook< +export type ScaffolderFormHook< TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, - TInput = { + TInput extends {} = { [key in keyof TInputSchema]: z.infer>; }, > = { @@ -43,11 +42,6 @@ export type ScaffolderInitialFormHook< ) => Promise; }; -export type ScaffolderFormHook = { - id: string; - fn: (ctx: ScaffolderFormHookContext) => Promise; -}; - /** * Method for creating hooks which can be used to collect * secrets from the user before submitting to the backend. @@ -56,7 +50,7 @@ export type ScaffolderFormHook = { export function createScaffolderFormHook< TDeps extends { [key in string]: AnyApiRef }, TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TInput = { + TInput extends {} = { [key in keyof TInputSchema]: z.infer>; }, >(options: { @@ -71,7 +65,7 @@ export function createScaffolderFormHook< ? { [key in keyof TDeps]: TDeps[key]['T'] } : never, ) => Promise; -}): ScaffolderInitialFormHook { +}): ScaffolderFormHook { return { ...options, version: 'v1', diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts index 4b4f21ae71..4daba383b0 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts @@ -17,20 +17,23 @@ import useAsync from 'react-use/esm/useAsync'; import { scaffolderApiRef } from '../../api/ref'; import { useApi } from '@backstage/core-plugin-api'; -import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; /** * @alpha */ export const useTemplateParameterSchema = (templateRef: string) => { const scaffolderApi = useApi(scaffolderApiRef); - const { value, loading, error } = useAsync( + const { + value: manifest, + loading, + error, + } = useAsync( () => scaffolderApi.getTemplateParameterSchema(templateRef), [scaffolderApi, templateRef], ); return { - manifest: value as TemplateParameterSchema | undefined, + manifest, loading, error, }; diff --git a/plugins/scaffolder-react/src/types.ts b/plugins/scaffolder-react/src/types.ts index c1c26f32bb..c75d830369 100644 --- a/plugins/scaffolder-react/src/types.ts +++ b/plugins/scaffolder-react/src/types.ts @@ -33,4 +33,5 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[]; }; diff --git a/plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts b/plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts deleted file mode 100644 index 32e9d84740..0000000000 --- a/plugins/scaffolder/src/alpha/api/FormHooksApi.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * 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 { DefaultScaffolderFormHooksApi } from './FormHooksApi'; -import { createScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; -import { createApiRef } from '@backstage/core-plugin-api'; - -import { TestApiRegistry, withLogCollector } from '@backstage/test-utils'; - -describe('FormHooksApi', () => { - const mockApiRef = createApiRef<{ - test: (input: string) => void; - }>({ id: 'test' }); - const mockApiImplementation = { test: jest.fn() }; - - it('should wrap up the form hooks', async () => { - const mockHook = createScaffolderFormHook({ - id: 'test', - deps: { mockApiRef }, - schema: { - input: { - test: z => z.string(), - }, - }, - async fn({ input: { test } }, { mockApiRef: mock }) { - mock.test(test); - }, - }); - - const hooks = new DefaultScaffolderFormHooksApi({ - formHooks: [mockHook], - apiHolder: TestApiRegistry.from([mockApiRef, mockApiImplementation]), - }); - - const formHooks = await hooks.getFormHooks(); - const [boundHook] = formHooks; - - expect(formHooks.length).toBe(1); - expect(boundHook.id).toBe('test'); - - await boundHook.fn({ setSecret: () => {}, input: { test: 'input value' } }); - expect(mockApiImplementation.test).toHaveBeenCalledWith('input value'); - }); - - it('should skip failing deps', async () => { - const mockHook = createScaffolderFormHook({ - id: 'test', - deps: { mockApiRef }, - schema: { - input: { - test: z => z.string(), - }, - }, - async fn({ input: { test } }, { mockApiRef: mock }) { - mock.test(test); - }, - }); - - const hooks = new DefaultScaffolderFormHooksApi({ - formHooks: [mockHook], - apiHolder: TestApiRegistry.from(), - }); - - const { error } = await withLogCollector(async () => { - const formHooks = await hooks.getFormHooks(); - expect(formHooks.length).toBe(0); - }); - - expect(error[0]).toMatchInlineSnapshot( - `[Error: Failed to resolve apiRef test for form hook test - it will be disabled]`, - ); - }); -}); diff --git a/plugins/scaffolder/src/alpha/api/FormHooksApi.ts b/plugins/scaffolder/src/alpha/api/FormHooksApi.ts index 51b7af18e7..c8ea5d6cfa 100644 --- a/plugins/scaffolder/src/alpha/api/FormHooksApi.ts +++ b/plugins/scaffolder/src/alpha/api/FormHooksApi.ts @@ -14,50 +14,21 @@ * limitations under the License. */ -import { ApiHolder } from '@backstage/core-plugin-api'; import { ScaffolderFormHooksApi } from './types'; -import { - ScaffolderFormHook, - ScaffolderInitialFormHook, -} from '@backstage/plugin-scaffolder-react/alpha'; +import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; export class DefaultScaffolderFormHooksApi implements ScaffolderFormHooksApi { constructor( private readonly options: { - formHooks: Array; - apiHolder: ApiHolder; + hooks: Array; }, ) {} - async getFormHooks(): Promise { - return this.options.formHooks - .map(hook => { - try { - const resolvedDeps = Object.entries(hook.deps ?? {}).map( - ([key, value]) => { - const api = this.options.apiHolder.get(value); - if (!api) { - // eslint-disable-next-line no-console - throw new Error( - `Failed to resolve apiRef ${value.id} for form hook ${hook.id} - it will be disabled`, - ); - } - return [key, api]; - }, - ); + static create(options: { hooks: ScaffolderFormHook[] }) { + return new DefaultScaffolderFormHooksApi(options); + } - return { - id: hook.id, - // todo(blam): should probably use zod to validate the input here - // or maybe move that to the `createScaffolderFormHook` method instead. - fn: input => hook.fn(input, Object.fromEntries(resolvedDeps)), - } as ScaffolderFormHook; - } catch (ex) { - // eslint-disable-next-line no-console - console.error(ex); - return undefined; - } - }) - .filter((h): h is ScaffolderFormHook => !!h); + async getFormHooks(): Promise { + return this.options.hooks; } } diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index fb0815ff8e..70436bbd3e 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -126,7 +126,6 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { extensions={props.customFieldExtensions} formProps={props.formProps} layouts={props.layouts} - EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks} /> diff --git a/plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx new file mode 100644 index 0000000000..d6ede80831 --- /dev/null +++ b/plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { DefaultScaffolderFormHooksApi } from '../api/FormHooksApi'; +import { createScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; +import { createApiRef } from '@backstage/core-plugin-api'; + +import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; +import { renderHook } from '@testing-library/react'; +import { useFormHooks } from './useFormHooks'; +import React from 'react'; +import { formHooksApiRef } from '../api/ref'; + +describe('useFormHooks', () => { + const mockApiRef = createApiRef<{ + test: (input: string) => void; + }>({ id: 'test' }); + + const mockApiImplementation = { test: jest.fn() }; + + const mockHook = createScaffolderFormHook({ + id: 'test', + deps: { mockApiRef }, + schema: { + input: { + test: z => z.string(), + }, + }, + async fn({ input: { test } }, { mockApiRef: mock }) { + mock.test(test); + }, + }); + + it('should wrap up the form hooks', async () => { + const renderedHook = renderHook(() => useFormHooks(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const result = renderedHook.result.current!; + + expect(result.size).toBe(1); + + const testHook = result.get('test')!; + expect(testHook).toBeDefined(); + + await testHook.fn({ + setSecrets: () => {}, + input: { test: 'input value' }, + }); + + expect(mockApiImplementation.test).toHaveBeenCalledWith('input value'); + }); + + it('should skip failing deps', async () => { + const { error } = await withLogCollector(async () => { + const renderedHook = renderHook(() => useFormHooks(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const result = renderedHook.result.current!; + expect(result.size).toBe(0); + }); + + expect(error[0]).toMatchInlineSnapshot( + `[Error: Failed to resolve apiRef test for form hook test]`, + ); + }); +}); diff --git a/plugins/scaffolder/src/alpha/hooks/useFormHooks.ts b/plugins/scaffolder/src/alpha/hooks/useFormHooks.ts new file mode 100644 index 0000000000..85f25cfbd4 --- /dev/null +++ b/plugins/scaffolder/src/alpha/hooks/useFormHooks.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { useApi, useApiHolder } from '@backstage/core-plugin-api'; +import { formHooksApiRef } from '../api/ref'; +import useAsync from 'react-use/esm/useAsync'; +import { useMemo } from 'react'; +import { ScaffolderFormHookContext } from '@backstage/plugin-scaffolder-react/alpha'; + +/** @internal */ +type BoundFieldHook = { + fn: (ctx: ScaffolderFormHookContext) => Promise; +}; + +export const useFormHooks = () => { + const formHooksApi = useApi(formHooksApiRef); + const { value: hooks } = useAsync(() => formHooksApi.getFormHooks(), []); + const apiHolder = useApiHolder(); + + return useMemo(() => { + const hooksMap = new Map(); + + for (const hook of hooks ?? []) { + try { + const resolvedDeps = Object.entries(hook.deps ?? {}).map( + ([key, value]) => { + const api = apiHolder.get(value); + if (!api) { + throw new Error( + `Failed to resolve apiRef ${value.id} for form hook ${hook.id} it will be disabled`, + ); + } + return [key, api]; + }, + ); + + hooksMap.set(hook.id, { + fn: ctx => hook.fn(ctx, Object.fromEntries(resolvedDeps)), + }); + } catch (ex) { + // eslint-disable-next-line no-console + console.error(ex); + return undefined; + } + } + return hooksMap; + }, [apiHolder, hooks]); +}; diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index fd19102849..a02473b534 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -84,7 +84,6 @@ export type RouterProps = { EXPERIMENTAL_TemplateListPageComponent?: React.ComponentType; EXPERIMENTAL_TemplateWizardPageComponent?: React.ComponentType; }; - EXPERIMENTAL_formHooks?: ScaffolderFormHook[]; groups?: TemplateGroupFilter[]; templateFilter?: (entity: TemplateEntityV1beta3) => boolean; headerOptions?: { @@ -164,7 +163,6 @@ export const Router = (props: PropsWithChildren) => { layouts={customLayouts} components={{ ReviewStepComponent }} formProps={props.formProps} - EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks} /> } From 88746203aa6c7b171d381896bc07d77b11d44b1e Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 14 Oct 2024 09:06:16 +0200 Subject: [PATCH 03/16] chore: hooks -> decorators Signed-off-by: blam --- .../scaffolder-backend/src/service/router.ts | 3 +- .../src/Template.v1beta3.schema.json | 4 +- .../src/TemplateEntityV1beta3.ts | 2 +- plugins/scaffolder-react/report.api.md | 2 +- .../src/next/components/Workflow/Workflow.tsx | 24 +++++------ ... => createScaffolderFormDecorator.test.ts} | 14 +++---- ...ok.ts => createScaffolderFormDecorator.ts} | 14 +++---- .../src/next/extensions/index.ts | 2 +- plugins/scaffolder-react/src/types.ts | 2 +- .../{FormHooksApi.ts => FormDecoratorsApi.ts} | 18 +++++---- plugins/scaffolder/src/alpha/api/ref.ts | 6 +-- plugins/scaffolder/src/alpha/api/types.ts | 6 +-- .../TemplateWizardPage/TemplateWizardPage.tsx | 6 +-- ...ks.test.tsx => useFormDecorators.test.tsx} | 40 ++++++++++--------- .../{useFormHooks.ts => useFormDecorators.ts} | 33 ++++++++------- 15 files changed, 91 insertions(+), 85 deletions(-) rename plugins/scaffolder-react/src/next/extensions/{createScaffolderFormHook.test.ts => createScaffolderFormDecorator.test.ts} (74%) rename plugins/scaffolder-react/src/next/extensions/{createScaffolderFormHook.ts => createScaffolderFormDecorator.ts} (83%) rename plugins/scaffolder/src/alpha/api/{FormHooksApi.ts => FormDecoratorsApi.ts} (57%) rename plugins/scaffolder/src/alpha/hooks/{useFormHooks.test.tsx => useFormDecorators.test.tsx} (68%) rename plugins/scaffolder/src/alpha/hooks/{useFormHooks.ts => useFormDecorators.ts} (57%) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index c5457fe603..3bde8d19ae 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -475,7 +475,8 @@ export async function createRouter( description: schema.description, schema, })), - EXPERIMENTAL_formHooks: template.spec.EXPERIMENTAL_formHooks, + EXPERIMENTAL_formDecorators: + template.spec.EXPERIMENTAL_formDecorators, }); }, ) diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index a0392f63d9..9ab9c0ab5a 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -173,9 +173,9 @@ } } }, - "EXPERIMENTAL_formHooks": { + "EXPERIMENTAL_formDecorators": { "type": "object", - "description": "A list of hooks and their inputs that the form should trigger before submitting the job", + "description": "A list of decorators and their inputs that the form should trigger before submitting the job", "properties": { "id": { "type": "string", diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index 942de02bad..c5b576b5c2 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -59,7 +59,7 @@ export interface TemplateEntityV1beta3 extends Entity { /** * Form hooks to be run */ - EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[]; + EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[]; /** * This is a JSONSchema or an array of JSONSchema's which is used to render a form in the frontend diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index 1c19729ee4..1ac2fd279b 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -538,7 +538,7 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; - EXPERIMENTAL_formHooks?: { + EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject; }[]; diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 0d271095e7..785696436c 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -35,7 +35,7 @@ import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; import { JsonValue } from '@backstage/types'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { useFormHooks } from '../../../../../scaffolder/src/alpha/hooks/useFormHooks'; +import { useFormDecorators } from '../../../../../scaffolder/src/alpha/hooks/useFormDecorators'; const useStyles = makeStyles({ markdown: { @@ -89,24 +89,24 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const sortedManifest = useFilteredSchemaProperties(manifest); const minutesSaved = useTemplateTimeSavedMinutes(templateRef); const { setSecrets } = useTemplateSecrets(); - const formHooks = useFormHooks(); + const formDecorators = useFormDecorators(); const workflowOnCreate = useCallback( async (formState: Record) => { - if (manifest?.EXPERIMENTAL_formHooks && formHooks?.size) { - // for each of the form hooks, go and call the hook with the context + if (manifest?.EXPERIMENTAL_formDecorators && formDecorators?.size) { + // for each of the form decorators, go and call the decorator with the context await Promise.all( - manifest.EXPERIMENTAL_formHooks.map(async hook => { - const formHook = formHooks.get(hook.id); - if (!formHook) { + manifest.EXPERIMENTAL_formDecorators.map(async decorator => { + const formDecorator = formDecorators.get(decorator.id); + if (!formDecorator) { // eslint-disable-next-line no-console - console.error('Failed to find form hook', hook.id); + console.error('Failed to find form decorator', decorator.id); return; } - await formHook.fn({ + await formDecorator.fn({ setSecrets, - input: hook.input, + input: decorator.input, }); }), ); @@ -121,8 +121,8 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { }); }, [ - manifest?.EXPERIMENTAL_formHooks, - formHooks, + manifest?.EXPERIMENTAL_formDecorators, + formDecorators, onCreate, analytics, templateName, diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts similarity index 74% rename from plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts rename to plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts index f694092af3..55d59cc249 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.test.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createScaffolderFormHook } from './createScaffolderFormHook'; +import { createScaffolderFormDecorator } from './createScaffolderFormDecorator'; -describe('createScaffolderFormHook', () => { - it('should return a hook', () => { - const hook = createScaffolderFormHook({ +describe('createScaffolderFormDecorator', () => { + it('should return a decorator', () => { + const decorator = createScaffolderFormDecorator({ id: 'test', deps: {}, fn: async () => {}, }); - expect(hook).toMatchInlineSnapshot(); + expect(decorator).toMatchInlineSnapshot(); }); it('should allow passing schema and be typesafe', () => { - const hook = createScaffolderFormHook({ + const decorator = createScaffolderFormDecorator({ id: 'test', deps: {}, schema: { @@ -46,6 +46,6 @@ describe('createScaffolderFormHook', () => { }, }); - expect(hook).toMatchInlineSnapshot(); + expect(decorator).toMatchInlineSnapshot(); }); }); diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts similarity index 83% rename from plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts rename to plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 261a003745..fc70fc4d52 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormHook.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -16,12 +16,12 @@ import { AnyApiRef } from '@backstage/core-plugin-api'; import { z } from 'zod'; -export type ScaffolderFormHookContext = { +export type ScaffolderFormDecoratorContext = { input: TInput; setSecrets: (input: Record) => void; }; -export type ScaffolderFormHook< +export type ScaffolderFormDecorator< TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, TInput extends {} = { @@ -35,7 +35,7 @@ export type ScaffolderFormHook< }; deps?: TDeps; fn: ( - ctx: ScaffolderFormHookContext, + ctx: ScaffolderFormDecoratorContext, deps: TDeps extends { [key in string]: AnyApiRef } ? { [key in keyof TDeps]: TDeps[key]['T'] } : never, @@ -43,11 +43,11 @@ export type ScaffolderFormHook< }; /** - * Method for creating hooks which can be used to collect + * Method for creating decorators which can be used to collect * secrets from the user before submitting to the backend. * @public */ -export function createScaffolderFormHook< +export function createScaffolderFormDecorator< TDeps extends { [key in string]: AnyApiRef }, TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, TInput extends {} = { @@ -60,12 +60,12 @@ export function createScaffolderFormHook< }; deps?: TDeps; fn: ( - ctx: ScaffolderFormHookContext, + ctx: ScaffolderFormDecoratorContext, deps: TDeps extends { [key in string]: AnyApiRef } ? { [key in keyof TDeps]: TDeps[key]['T'] } : never, ) => Promise; -}): ScaffolderFormHook { +}): ScaffolderFormDecorator { return { ...options, version: 'v1', diff --git a/plugins/scaffolder-react/src/next/extensions/index.ts b/plugins/scaffolder-react/src/next/extensions/index.ts index fff3dfbf4a..73bfa749f6 100644 --- a/plugins/scaffolder-react/src/next/extensions/index.ts +++ b/plugins/scaffolder-react/src/next/extensions/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './createScaffolderFormHook'; +export * from './createScaffolderFormDecorator'; diff --git a/plugins/scaffolder-react/src/types.ts b/plugins/scaffolder-react/src/types.ts index c75d830369..5f557198c6 100644 --- a/plugins/scaffolder-react/src/types.ts +++ b/plugins/scaffolder-react/src/types.ts @@ -33,5 +33,5 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; - EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[]; + EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[]; }; diff --git a/plugins/scaffolder/src/alpha/api/FormHooksApi.ts b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts similarity index 57% rename from plugins/scaffolder/src/alpha/api/FormHooksApi.ts rename to plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts index c8ea5d6cfa..38170f899a 100644 --- a/plugins/scaffolder/src/alpha/api/FormHooksApi.ts +++ b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts @@ -14,21 +14,23 @@ * limitations under the License. */ -import { ScaffolderFormHooksApi } from './types'; -import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; +import { ScaffolderFormDecoratorsApi } from './types'; +import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; -export class DefaultScaffolderFormHooksApi implements ScaffolderFormHooksApi { +export class DefaultScaffolderFormDecoratorsApi + implements ScaffolderFormDecoratorsApi +{ constructor( private readonly options: { - hooks: Array; + decorators: Array; }, ) {} - static create(options: { hooks: ScaffolderFormHook[] }) { - return new DefaultScaffolderFormHooksApi(options); + static create(options: { decorators: ScaffolderFormDecorator[] }) { + return new DefaultScaffolderFormDecoratorsApi(options); } - async getFormHooks(): Promise { - return this.options.hooks; + async getFormDecorators(): Promise { + return this.options.decorators; } } diff --git a/plugins/scaffolder/src/alpha/api/ref.ts b/plugins/scaffolder/src/alpha/api/ref.ts index d977f5a817..121f394dce 100644 --- a/plugins/scaffolder/src/alpha/api/ref.ts +++ b/plugins/scaffolder/src/alpha/api/ref.ts @@ -15,12 +15,12 @@ */ import { createApiRef } from '@backstage/frontend-plugin-api'; -import { ScaffolderFormFieldsApi, ScaffolderFormHooksApi } from './types'; +import { ScaffolderFormFieldsApi, ScaffolderFormDecoratorsApi } from './types'; export const formFieldsApiRef = createApiRef({ id: 'plugin.scaffolder.form-fields', }); -export const formHooksApiRef = createApiRef({ - id: 'plugin.scaffolder.form-hooks', +export const formDecoratorsApiRef = createApiRef({ + id: 'plugin.scaffolder.form-decorators', }); diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts index 98c47f72a9..28ce8dab3d 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -16,13 +16,13 @@ import { FormFieldExtensionData, - ScaffolderFormHook, + ScaffolderFormDecorator, } from '@backstage/plugin-scaffolder-react/alpha'; export interface ScaffolderFormFieldsApi { getFormFields(): Promise; } -export interface ScaffolderFormHooksApi { - getFormHooks(): Promise; +export interface ScaffolderFormDecoratorsApi { + getFormDecorators(): Promise; } diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index 70436bbd3e..b5e24a7d52 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -36,10 +36,7 @@ import { } from '@backstage/plugin-scaffolder-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { - ScaffolderFormHook, - Workflow, -} from '@backstage/plugin-scaffolder-react/alpha'; +import { Workflow } from '@backstage/plugin-scaffolder-react/alpha'; import { JsonValue } from '@backstage/types'; import { Header, Page } from '@backstage/core-components'; @@ -68,7 +65,6 @@ export type TemplateWizardPageProps = { title?: string; subtitle?: string; }; - EXPERIMENTAL_formHooks?: ScaffolderFormHook[]; }; export const TemplateWizardPage = (props: TemplateWizardPageProps) => { diff --git a/plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx similarity index 68% rename from plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx rename to plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index d6ede80831..cdc3eef918 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormHooks.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -13,24 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DefaultScaffolderFormHooksApi } from '../api/FormHooksApi'; -import { createScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; +import { DefaultScaffolderFormDecoratorsApi } from '../api/FormDecoratorsApi'; +import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; import { createApiRef } from '@backstage/core-plugin-api'; import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react'; -import { useFormHooks } from './useFormHooks'; +import { useFormDecorators } from './useFormDecorators'; import React from 'react'; -import { formHooksApiRef } from '../api/ref'; +import { formDecoratorsApiRef } from '../api/ref'; -describe('useFormHooks', () => { +describe('useFormDecorators', () => { const mockApiRef = createApiRef<{ test: (input: string) => void; }>({ id: 'test' }); const mockApiImplementation = { test: jest.fn() }; - const mockHook = createScaffolderFormHook({ + const mockDecorator = createScaffolderFormDecorator({ id: 'test', deps: { mockApiRef }, schema: { @@ -43,16 +43,18 @@ describe('useFormHooks', () => { }, }); - it('should wrap up the form hooks', async () => { - const renderedHook = renderHook(() => useFormHooks(), { + it('should wrap up the form decorators', async () => { + const renderedHook = renderHook(() => useFormDecorators(), { wrapper: ({ children }) => ( @@ -65,10 +67,10 @@ describe('useFormHooks', () => { expect(result.size).toBe(1); - const testHook = result.get('test')!; - expect(testHook).toBeDefined(); + const testDecorator = result.get('test')!; + expect(testDecorator).toBeDefined(); - await testHook.fn({ + await testDecorator.fn({ setSecrets: () => {}, input: { test: 'input value' }, }); @@ -78,14 +80,16 @@ describe('useFormHooks', () => { it('should skip failing deps', async () => { const { error } = await withLogCollector(async () => { - const renderedHook = renderHook(() => useFormHooks(), { + const renderedHook = renderHook(() => useFormDecorators(), { wrapper: ({ children }) => ( diff --git a/plugins/scaffolder/src/alpha/hooks/useFormHooks.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts similarity index 57% rename from plugins/scaffolder/src/alpha/hooks/useFormHooks.ts rename to plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts index 85f25cfbd4..293c81996d 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormHooks.ts +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -14,40 +14,43 @@ * limitations under the License. */ import { useApi, useApiHolder } from '@backstage/core-plugin-api'; -import { formHooksApiRef } from '../api/ref'; +import { formDecoratorsApiRef } from '../api/ref'; import useAsync from 'react-use/esm/useAsync'; import { useMemo } from 'react'; -import { ScaffolderFormHookContext } from '@backstage/plugin-scaffolder-react/alpha'; +import { ScaffolderFormDecoratorContext } from '@backstage/plugin-scaffolder-react/alpha'; /** @internal */ -type BoundFieldHook = { - fn: (ctx: ScaffolderFormHookContext) => Promise; +type BoundFieldDecorator = { + fn: (ctx: ScaffolderFormDecoratorContext) => Promise; }; -export const useFormHooks = () => { - const formHooksApi = useApi(formHooksApiRef); - const { value: hooks } = useAsync(() => formHooksApi.getFormHooks(), []); +export const useFormDecorators = () => { + const formDecoratorsApi = useApi(formDecoratorsApiRef); + const { value: decorators } = useAsync( + () => formDecoratorsApi.getFormDecorators(), + [], + ); const apiHolder = useApiHolder(); return useMemo(() => { - const hooksMap = new Map(); + const decoratorsMap = new Map(); - for (const hook of hooks ?? []) { + for (const decorator of decorators ?? []) { try { - const resolvedDeps = Object.entries(hook.deps ?? {}).map( + const resolvedDeps = Object.entries(decorator.deps ?? {}).map( ([key, value]) => { const api = apiHolder.get(value); if (!api) { throw new Error( - `Failed to resolve apiRef ${value.id} for form hook ${hook.id} it will be disabled`, + `Failed to resolve apiRef ${value.id} for form decorator ${decorator.id} it will be disabled`, ); } return [key, api]; }, ); - hooksMap.set(hook.id, { - fn: ctx => hook.fn(ctx, Object.fromEntries(resolvedDeps)), + decoratorsMap.set(decorator.id, { + fn: ctx => decorator.fn(ctx, Object.fromEntries(resolvedDeps)), }); } catch (ex) { // eslint-disable-next-line no-console @@ -55,6 +58,6 @@ export const useFormHooks = () => { return undefined; } } - return hooksMap; - }, [apiHolder, hooks]); + return decoratorsMap; + }, [apiHolder, decorators]); }; From f6874fbfa8329893880ed4d03caf0b8f3c94022a Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 14 Oct 2024 09:31:12 +0200 Subject: [PATCH 04/16] chore: adding some more useful state to the context Signed-off-by: blam --- plugins/scaffolder-react/report.api.md | 4 ---- .../src/next/components/Workflow/Workflow.tsx | 14 ++++++++++---- .../extensions/createScaffolderFormDecorator.ts | 7 ++++++- .../src/secrets/SecretsContext.tsx | 12 ++++++++++++ .../scaffolder/src/alpha/api/FormDecoratorsApi.ts | 8 +++++--- .../src/alpha/hooks/useFormDecorators.test.tsx | 2 ++ plugins/scaffolder/src/plugin.tsx | 7 +++++++ 7 files changed, 42 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index 1ac2fd279b..5523b52ad9 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -538,10 +538,6 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; - EXPERIMENTAL_formDecorators?: { - id: string; - input?: JsonObject; - }[]; }; // @public diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 785696436c..83249c0304 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Content, InfoCard, @@ -28,7 +28,7 @@ import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSche import { Stepper, type StepperProps } from '../Stepper/Stepper'; import { SecretsContextProvider, - useTemplateSecrets, + useInternalTemplateSecrets, } from '../../../secrets/SecretsContext'; import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; @@ -88,11 +88,14 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const { loading, manifest, error } = useTemplateParameterSchema(templateRef); const sortedManifest = useFilteredSchemaProperties(manifest); const minutesSaved = useTemplateTimeSavedMinutes(templateRef); - const { setSecrets } = useTemplateSecrets(); + const { setSecrets } = useInternalTemplateSecrets(); const formDecorators = useFormDecorators(); + const [formState, setFormState] = useState>({}); const workflowOnCreate = useCallback( - async (formState: Record) => { + async (originalFormState: Record) => { + setFormState(originalFormState); + if (manifest?.EXPERIMENTAL_formDecorators && formDecorators?.size) { // for each of the form decorators, go and call the decorator with the context await Promise.all( @@ -106,6 +109,8 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { await formDecorator.fn({ setSecrets, + setFormState, + formState, input: decorator.input, }); }), @@ -124,6 +129,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { manifest?.EXPERIMENTAL_formDecorators, formDecorators, onCreate, + formState, analytics, templateName, minutesSaved, diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index fc70fc4d52..867435e4c1 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -14,11 +14,16 @@ * limitations under the License. */ import { AnyApiRef } from '@backstage/core-plugin-api'; +import { JsonValue } from '@backstage/types'; +import { Dispatch, SetStateAction } from 'react'; import { z } from 'zod'; export type ScaffolderFormDecoratorContext = { input: TInput; - setSecrets: (input: Record) => void; + formState: Record; + + setFormState: Dispatch>>; + setSecrets: Dispatch>>; }; export type ScaffolderFormDecorator< diff --git a/plugins/scaffolder-react/src/secrets/SecretsContext.tsx b/plugins/scaffolder-react/src/secrets/SecretsContext.tsx index 64b25d0e8f..a12722a1fd 100644 --- a/plugins/scaffolder-react/src/secrets/SecretsContext.tsx +++ b/plugins/scaffolder-react/src/secrets/SecretsContext.tsx @@ -94,3 +94,15 @@ export const useTemplateSecrets = (): ScaffolderUseTemplateSecrets => { return { setSecrets, secrets }; }; + +export const useInternalTemplateSecrets = () => { + const value = useContext(SecretsContext)?.atVersion(1); + + if (!value) { + throw new Error( + 'useTemplateSecrets must be used within a SecretsContextProvider', + ); + } + + return value; +}; diff --git a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts index 38170f899a..7638986eab 100644 --- a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts +++ b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts @@ -20,14 +20,16 @@ import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alph export class DefaultScaffolderFormDecoratorsApi implements ScaffolderFormDecoratorsApi { - constructor( + private constructor( private readonly options: { decorators: Array; }, ) {} - static create(options: { decorators: ScaffolderFormDecorator[] }) { - return new DefaultScaffolderFormDecoratorsApi(options); + static create(options?: { decorators: ScaffolderFormDecorator[] }) { + return new DefaultScaffolderFormDecoratorsApi( + options ?? { decorators: [] }, + ); } async getFormDecorators(): Promise { diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index cdc3eef918..0198dc3fee 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -71,6 +71,8 @@ describe('useFormDecorators', () => { expect(testDecorator).toBeDefined(); await testDecorator.fn({ + formState: {}, + setFormState: () => {}, setSecrets: () => {}, input: { test: 'input value' }, }); diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index 8b24e86069..a6ccdef32c 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -78,6 +78,8 @@ import { } from './components/fields/MyGroupsPicker/MyGroupsPicker'; import { RepoBranchPicker } from './components/fields/RepoBranchPicker/RepoBranchPicker'; import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/schema'; +import { formDecoratorsApiRef } from './alpha/api/ref'; +import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi'; /** * The main plugin export for the scaffolder. @@ -102,6 +104,11 @@ export const scaffolderPlugin = createPlugin({ identityApi, }), }), + createApiFactory({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => DefaultScaffolderFormDecoratorsApi.create(), + }), ], routes: { root: rootRouteRef, From bcaba24892e38070f79287f674230186fd3373a2 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 14 Oct 2024 15:09:43 +0200 Subject: [PATCH 05/16] chore: test fixes and simplify the types a little bit Signed-off-by: blam --- .../components/Workflow/Workflow.test.tsx | 57 +++++++++------ .../src/next/components/Workflow/Workflow.tsx | 14 ++-- .../createScaffolderFormDecorator.test.ts | 24 ++++++- .../createScaffolderFormDecorator.ts | 9 ++- .../TemplateWizardPage.test.tsx | 7 ++ .../alpha/hooks/useFormDecorators.test.tsx | 70 +++++++++---------- 6 files changed, 112 insertions(+), 69 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index 243b9b7e79..b942e5487a 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -27,6 +27,12 @@ import { analyticsApiRef } from '@backstage/core-plugin-api'; import { ScaffolderApi, scaffolderApiRef } from '../../../api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { SecretsContextProvider } from '../../../secrets'; + +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ScaffolderFormDecoratorsApi } from '../../../../../scaffolder/src/alpha/api/types'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { formDecoratorsApiRef } from '../../../../../scaffolder/src/alpha/api/ref'; const scaffolderApiMock: jest.Mocked = { cancelTask: jest.fn(), @@ -39,14 +45,19 @@ const scaffolderApiMock: jest.Mocked = { listTasks: jest.fn(), autocomplete: jest.fn(), }; +const scaffolderDecoratorsMock: jest.Mocked = { + getFormDecorators: jest.fn().mockResolvedValue([]), +}; const catalogApi = catalogApiMock.mock(); const analyticsMock = mockApis.analytics(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], + [formDecoratorsApiRef, scaffolderDecoratorsMock], [catalogApiRef, catalogApi], [analyticsApiRef, analyticsMock], + [catalogApiRef, catalogApi], ); describe('', () => { @@ -82,31 +93,33 @@ describe('', () => { }); const { getByRole, getAllByRole, getByText } = await renderInTestApp( - - + + ( -

This is a different wrapper for the review page

- ), - reviewButtonText: Onwards, - createButtonText: Make, - }} - extensions={[]} - /> -
, + onCreate={onCreate} + onError={onError} + namespace="default" + templateName="docs-template" + initialState={{ + name: 'prefilled-name', + age: '53', + }} + components={{ + ReviewStateComponent: () => ( +

This is a different wrapper for the review page

+ ), + reviewButtonText: Onwards, + createButtonText: Make, + }} + extensions={[]} + /> +
+ , ); // Test template title is overriden diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 83249c0304..1b0a10c780 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Content, InfoCard, @@ -90,11 +90,10 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const minutesSaved = useTemplateTimeSavedMinutes(templateRef); const { setSecrets } = useInternalTemplateSecrets(); const formDecorators = useFormDecorators(); - const [formState, setFormState] = useState>({}); const workflowOnCreate = useCallback( async (originalFormState: Record) => { - setFormState(originalFormState); + let formState: Record = { ...originalFormState }; if (manifest?.EXPERIMENTAL_formDecorators && formDecorators?.size) { // for each of the form decorators, go and call the decorator with the context @@ -109,7 +108,13 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { await formDecorator.fn({ setSecrets, - setFormState, + setFormState: ( + handler: ( + oldState: Record, + ) => Record, + ) => { + formState = { ...handler(formState) }; + }, formState, input: decorator.input, }); @@ -129,7 +134,6 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { manifest?.EXPERIMENTAL_formDecorators, formDecorators, onCreate, - formState, analytics, templateName, minutesSaved, diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts index 55d59cc249..cef533d283 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts @@ -23,7 +23,14 @@ describe('createScaffolderFormDecorator', () => { fn: async () => {}, }); - expect(decorator).toMatchInlineSnapshot(); + expect(decorator).toMatchInlineSnapshot(` + { + "deps": {}, + "fn": [Function], + "id": "test", + "version": "v1", + } + `); }); it('should allow passing schema and be typesafe', () => { @@ -46,6 +53,19 @@ describe('createScaffolderFormDecorator', () => { }, }); - expect(decorator).toMatchInlineSnapshot(); + expect(decorator).toMatchInlineSnapshot(` + { + "deps": {}, + "fn": [Function], + "id": "test", + "schema": { + "input": { + "age": [Function], + "name": [Function], + }, + }, + "version": "v1", + } + `); }); }); diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 867435e4c1..59c144249a 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -15,15 +15,18 @@ */ import { AnyApiRef } from '@backstage/core-plugin-api'; import { JsonValue } from '@backstage/types'; -import { Dispatch, SetStateAction } from 'react'; import { z } from 'zod'; export type ScaffolderFormDecoratorContext = { input: TInput; formState: Record; - setFormState: Dispatch>>; - setSecrets: Dispatch>>; + setFormState: ( + fn: (currentState: Record) => Record, + ) => void; + setSecrets: ( + fn: (currentState: Record) => Record, + ) => void; }; export type ScaffolderFormDecorator< diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx index 18211d185a..7d978e5c38 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -33,6 +33,8 @@ import { rootRouteRef } from '../../../routes'; import { ANNOTATION_EDIT_URL } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { ScaffolderFormDecoratorsApi } from '../../api/types'; +import { formDecoratorsApiRef } from '../../api/ref'; jest.mock('react-router-dom', () => { return { @@ -55,11 +57,16 @@ const scaffolderApiMock: jest.Mocked = { autocomplete: jest.fn(), }; +const scaffolderDecoratorsMock: jest.Mocked = { + getFormDecorators: jest.fn().mockResolvedValue([]), +}; + const catalogApi = catalogApiMock.mock(); const analyticsApi = mockApis.analytics(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], + [formDecoratorsApiRef, scaffolderDecoratorsMock], [catalogApiRef, catalogApi], [analyticsApiRef, analyticsApi], [catalogApiRef, catalogApi], diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index 0198dc3fee..d070894206 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -17,8 +17,8 @@ import { DefaultScaffolderFormDecoratorsApi } from '../api/FormDecoratorsApi'; import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; import { createApiRef } from '@backstage/core-plugin-api'; -import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; -import { renderHook } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook, waitFor } from '@testing-library/react'; import { useFormDecorators } from './useFormDecorators'; import React from 'react'; import { formDecoratorsApiRef } from '../api/ref'; @@ -63,49 +63,45 @@ describe('useFormDecorators', () => { ), }); - const result = renderedHook.result.current!; + await waitFor(async () => { + const result = renderedHook.result.current!; - expect(result.size).toBe(1); + expect(result.size).toBe(1); - const testDecorator = result.get('test')!; - expect(testDecorator).toBeDefined(); + const testDecorator = result.get('test')!; + expect(testDecorator).toBeDefined(); - await testDecorator.fn({ - formState: {}, - setFormState: () => {}, - setSecrets: () => {}, - input: { test: 'input value' }, + await testDecorator.fn({ + formState: {}, + setFormState: () => {}, + setSecrets: () => {}, + input: { test: 'input value' }, + }); + + expect(mockApiImplementation.test).toHaveBeenCalledWith('input value'); }); - - expect(mockApiImplementation.test).toHaveBeenCalledWith('input value'); }); it('should skip failing deps', async () => { - const { error } = await withLogCollector(async () => { - const renderedHook = renderHook(() => useFormDecorators(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - const result = renderedHook.result.current!; - expect(result.size).toBe(0); + const renderedHook = renderHook(() => useFormDecorators(), { + wrapper: ({ children }) => ( + + {children} + + ), }); - expect(error[0]).toMatchInlineSnapshot( - `[Error: Failed to resolve apiRef test for form hook test]`, - ); + const result = renderedHook.result.current!; + expect(result.size).toBe(0); }); }); From 3c62a508470a6bbed079740b1367b99b5bf9ba1c Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 14 Oct 2024 16:35:08 +0200 Subject: [PATCH 06/16] chore: add changeset Signed-off-by: blam Signed-off-by: blam --- .changeset/fluffy-zebras-cheer.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/fluffy-zebras-cheer.md diff --git a/.changeset/fluffy-zebras-cheer.md b/.changeset/fluffy-zebras-cheer.md new file mode 100644 index 0000000000..308f12d10a --- /dev/null +++ b/.changeset/fluffy-zebras-cheer.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-common': patch +'@backstage/plugin-scaffolder-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Experimental support for `formDecorators` to enable secret collection and mutations to the parameters for scaffolder tasks From 8d732a8d7543aba18230c9f083d47de3e8791c84 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 28 Oct 2024 15:15:30 +0100 Subject: [PATCH 07/16] chore: fixing api reports for a cleanup Signed-off-by: blam --- plugins/scaffolder-common/report.api.md | 4 + plugins/scaffolder-react/report-alpha.api.md | 78 ++++++++++++++++++- plugins/scaffolder-react/report.api.md | 4 + .../createScaffolderFormDecorator.ts | 4 +- .../next/hooks/useTemplateParameterSchema.ts | 5 +- 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder-common/report.api.md b/plugins/scaffolder-common/report.api.md index 8d7a61910d..3aa7e08121 100644 --- a/plugins/scaffolder-common/report.api.md +++ b/plugins/scaffolder-common/report.api.md @@ -77,6 +77,10 @@ export interface TemplateEntityV1beta3 extends Entity { type: string; presentation?: TemplatePresentationV1beta3; EXPERIMENTAL_recovery?: TemplateRecoveryV1beta3; + EXPERIMENTAL_formDecorators?: { + id: string; + input?: JsonObject; + }[]; parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[]; steps: Array; output?: { diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 7084625ad4..092217abe8 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts /// +import { AnyApiRef } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; @@ -75,6 +76,35 @@ export function createFormField< TUiOptions extends z.ZodType, >(opts: FormFieldExtensionData): FormField; +// @alpha +export function createScaffolderFormDecorator< + TDeps extends { + [key in string]: AnyApiRef; + }, + TInputSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + TInput extends {} = { + [key in keyof TInputSchema]: z.infer>; + }, +>(options: { + id: string; + schema?: { + input?: TInputSchema; + }; + deps?: TDeps; + fn: ( + ctx: ScaffolderFormDecoratorContext, + deps: TDeps extends { + [key in string]: AnyApiRef; + } + ? { + [key in keyof TDeps]: TDeps[key]['T']; + } + : never, + ) => Promise; +}): ScaffolderFormDecorator; + // @alpha export const DefaultTemplateOutputs: (props: { output?: ScaffolderTaskOutput; @@ -190,6 +220,50 @@ export interface ScaffolderFieldProps { required?: boolean; } +// @alpha (undocumented) +export type ScaffolderFormDecorator< + TInputSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + } = {}, + TDeps extends { + [key in string]: AnyApiRef; + } = { + [key in string]: AnyApiRef; + }, + TInput extends {} = { + [key in keyof TInputSchema]: z.infer>; + }, +> = { + version: 'v1'; + id: string; + schema?: { + input?: TInputSchema; + }; + deps?: TDeps; + fn: ( + ctx: ScaffolderFormDecoratorContext, + deps: TDeps extends { + [key in string]: AnyApiRef; + } + ? { + [key in keyof TDeps]: TDeps[key]['T']; + } + : never, + ) => Promise; +}; + +// @alpha (undocumented) +export type ScaffolderFormDecoratorContext = { + input: TInput; + formState: Record; + setFormState: ( + fn: (currentState: Record) => Record, + ) => void; + setSecrets: ( + fn: (currentState: Record) => Record, + ) => void; +}; + // @alpha (undocumented) export function ScaffolderPageContextMenu( props: ScaffolderPageContextMenuProps, @@ -346,9 +420,9 @@ export const useFormDataFromQuery: ( // @alpha (undocumented) export const useTemplateParameterSchema: (templateRef: string) => { - manifest: TemplateParameterSchema | undefined; + manifest?: TemplateParameterSchema | undefined; loading: boolean; - error: Error | undefined; + error?: Error | undefined; }; // @alpha diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index 5523b52ad9..1ac2fd279b 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -538,6 +538,10 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + EXPERIMENTAL_formDecorators?: { + id: string; + input?: JsonObject; + }[]; }; // @public diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 59c144249a..965cfa6107 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -17,6 +17,7 @@ import { AnyApiRef } from '@backstage/core-plugin-api'; import { JsonValue } from '@backstage/types'; import { z } from 'zod'; +/** @alpha */ export type ScaffolderFormDecoratorContext = { input: TInput; formState: Record; @@ -29,6 +30,7 @@ export type ScaffolderFormDecoratorContext = { ) => void; }; +/** @alpha */ export type ScaffolderFormDecorator< TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, @@ -53,7 +55,7 @@ export type ScaffolderFormDecorator< /** * Method for creating decorators which can be used to collect * secrets from the user before submitting to the backend. - * @public + * @alpha */ export function createScaffolderFormDecorator< TDeps extends { [key in string]: AnyApiRef }, diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts index 4daba383b0..452e3a67a8 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts @@ -17,11 +17,14 @@ import useAsync from 'react-use/esm/useAsync'; import { scaffolderApiRef } from '../../api/ref'; import { useApi } from '@backstage/core-plugin-api'; +import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; /** * @alpha */ -export const useTemplateParameterSchema = (templateRef: string) => { +export const useTemplateParameterSchema = ( + templateRef: string, +): { manifest?: TemplateParameterSchema; loading: boolean; error?: Error } => { const scaffolderApi = useApi(scaffolderApiRef); const { value: manifest, From 7e3ca8be717d91c30c083c908dda1cc1aefa393e Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 28 Oct 2024 16:22:34 +0100 Subject: [PATCH 08/16] feat: more work on form decorators Signed-off-by: blam --- packages/app/src/apis.ts | 12 +++++ .../src/components/scaffolder/decorators.ts | 33 +++++++++++++ .../src/Template.v1beta3.schema.json | 21 +++++---- .../src/next/components/Workflow/Workflow.tsx | 4 +- .../createScaffolderFormDecorator.ts | 6 +++ plugins/scaffolder/src/alpha/api.tsx | 46 ------------------- .../src/alpha/api/FormDecoratorsApi.ts | 9 ++-- plugins/scaffolder/src/alpha/api/index.ts | 8 +++- plugins/scaffolder/src/alpha/api/types.ts | 4 +- plugins/scaffolder/src/alpha/extensions.tsx | 29 ++++++++++++ .../alpha/hooks/useFormDecorators.test.tsx | 2 - plugins/scaffolder/src/alpha/index.ts | 1 + plugins/scaffolder/src/alpha/plugin.tsx | 2 +- 13 files changed, 109 insertions(+), 68 deletions(-) create mode 100644 packages/app/src/components/scaffolder/decorators.ts delete mode 100644 plugins/scaffolder/src/alpha/api.tsx diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 0c2ccbad22..e39122337f 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,6 +26,9 @@ import { discoveryApiRef, } from '@backstage/core-plugin-api'; import { AuthProxyDiscoveryApi } from './AuthProxyDiscoveryApi'; +import { formDecoratorsApiRef } from '@backstage/plugin-scaffolder/alpha'; +import { DefaultScaffolderFormDecoratorsApi } from '@backstage/plugin-scaffolder/alpha'; +import { mockDecorator } from './components/scaffolder/decorators'; export const apis: AnyApiFactory[] = [ createApiFactory({ @@ -39,5 +42,14 @@ export const apis: AnyApiFactory[] = [ factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), + createApiFactory({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => + DefaultScaffolderFormDecoratorsApi.create({ + decorators: [mockDecorator], + }), + }), + ScmAuth.createDefaultApiFactory(), ]; diff --git a/packages/app/src/components/scaffolder/decorators.ts b/packages/app/src/components/scaffolder/decorators.ts new file mode 100644 index 0000000000..6cce3a3fe0 --- /dev/null +++ b/packages/app/src/components/scaffolder/decorators.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { githubAuthApiRef } from '@backstage/core-plugin-api'; +import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; + +export const mockDecorator = createScaffolderFormDecorator({ + id: 'githubOauth', + schema: { + input: { + test: z => z.string(), + }, + }, + deps: { + githubApi: githubAuthApiRef, + }, + fn: async ({ setSecrets }, { githubApi }) => { + const token = await githubApi.getAccessToken(); + setSecrets(state => ({ ...state, GITHUB_TOKEN: token })); + }, +}); diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index 9ab9c0ab5a..58f8ef240a 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -174,16 +174,19 @@ } }, "EXPERIMENTAL_formDecorators": { - "type": "object", + "type": "array", "description": "A list of decorators and their inputs that the form should trigger before submitting the job", - "properties": { - "id": { - "type": "string", - "description": "The form hook ID" - }, - "input": { - "type": "object", - "description": "A object describing the inputs to the form hook." + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The form hook ID" + }, + "input": { + "type": "object", + "description": "A object describing the inputs to the form hook." + } } } }, diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 1b0a10c780..91bc98dad3 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -95,11 +95,11 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { async (originalFormState: Record) => { let formState: Record = { ...originalFormState }; - if (manifest?.EXPERIMENTAL_formDecorators && formDecorators?.size) { + if (manifest?.EXPERIMENTAL_formDecorators) { // for each of the form decorators, go and call the decorator with the context await Promise.all( manifest.EXPERIMENTAL_formDecorators.map(async decorator => { - const formDecorator = formDecorators.get(decorator.id); + const formDecorator = formDecorators?.get(decorator.id); if (!formDecorator) { // eslint-disable-next-line no-console console.error('Failed to find form decorator', decorator.id); diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 965cfa6107..20da932c05 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -52,6 +52,12 @@ export type ScaffolderFormDecorator< ) => Promise; }; +/** @alpha */ +export type AnyScaffolderFormDecorator = ScaffolderFormDecorator< + { [key in string]: (zImpl: typeof z) => z.ZodType }, + { [key in string]: AnyApiRef }, + any +>; /** * Method for creating decorators which can be used to collect * secrets from the user before submitting to the backend. diff --git a/plugins/scaffolder/src/alpha/api.tsx b/plugins/scaffolder/src/alpha/api.tsx deleted file mode 100644 index 06364cd6e9..0000000000 --- a/plugins/scaffolder/src/alpha/api.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * 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 { - ApiBlueprint, - createApiFactory, - discoveryApiRef, - fetchApiRef, - identityApiRef, -} from '@backstage/frontend-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import { ScaffolderClient } from '../api'; - -export const scaffolderApi = ApiBlueprint.make({ - params: { - factory: createApiFactory({ - api: scaffolderApiRef, - deps: { - discoveryApi: discoveryApiRef, - scmIntegrationsApi: scmIntegrationsApiRef, - fetchApi: fetchApiRef, - identityApi: identityApiRef, - }, - factory: ({ discoveryApi, scmIntegrationsApi, fetchApi, identityApi }) => - new ScaffolderClient({ - discoveryApi, - scmIntegrationsApi, - fetchApi, - identityApi, - }), - }), - }, -}); diff --git a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts index 7638986eab..79ec618ce2 100644 --- a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts +++ b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts @@ -15,24 +15,25 @@ */ import { ScaffolderFormDecoratorsApi } from './types'; -import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; +import { AnyScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; +/** @alpha */ export class DefaultScaffolderFormDecoratorsApi implements ScaffolderFormDecoratorsApi { private constructor( private readonly options: { - decorators: Array; + decorators: Array; }, ) {} - static create(options?: { decorators: ScaffolderFormDecorator[] }) { + static create(options?: { decorators: AnyScaffolderFormDecorator[] }) { return new DefaultScaffolderFormDecoratorsApi( options ?? { decorators: [] }, ); } - async getFormDecorators(): Promise { + async getFormDecorators(): Promise { return this.options.decorators; } } diff --git a/plugins/scaffolder/src/alpha/api/index.ts b/plugins/scaffolder/src/alpha/api/index.ts index bbc30ef50b..0afefe17a5 100644 --- a/plugins/scaffolder/src/alpha/api/index.ts +++ b/plugins/scaffolder/src/alpha/api/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { formFieldsApiRef } from './ref'; -export type { ScaffolderFormFieldsApi } from './types'; +export { formFieldsApiRef, formDecoratorsApiRef } from './ref'; +export type { + ScaffolderFormFieldsApi, + ScaffolderFormDecoratorsApi, +} from './types'; +export { DefaultScaffolderFormDecoratorsApi } from './FormDecoratorsApi'; diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts index 28ce8dab3d..d52d102d6d 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -16,7 +16,7 @@ import { FormFieldExtensionData, - ScaffolderFormDecorator, + AnyScaffolderFormDecorator, } from '@backstage/plugin-scaffolder-react/alpha'; export interface ScaffolderFormFieldsApi { @@ -24,5 +24,5 @@ export interface ScaffolderFormFieldsApi { } export interface ScaffolderFormDecoratorsApi { - getFormDecorators(): Promise; + getFormDecorators(): Promise; } diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 191dcf407c..493b0d7114 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -21,11 +21,19 @@ import { import { NavItemBlueprint, PageBlueprint, + ApiBlueprint, + createApiFactory, + discoveryApiRef, + fetchApiRef, + identityApiRef, } from '@backstage/frontend-plugin-api'; import React from 'react'; import { rootRouteRef } from '../routes'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { ScaffolderClient } from '../api'; export const scaffolderPage = PageBlueprint.make({ params: { @@ -50,3 +58,24 @@ export const repoUrlPickerFormField = FormFieldBlueprint.make({ field: () => import('./fields/RepoUrlPicker').then(m => m.RepoUrlPicker), }, }); + +export const scaffolderApi = ApiBlueprint.make({ + params: { + factory: createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, + fetchApi: fetchApiRef, + identityApi: identityApiRef, + }, + factory: ({ discoveryApi, scmIntegrationsApi, fetchApi, identityApi }) => + new ScaffolderClient({ + discoveryApi, + scmIntegrationsApi, + fetchApi, + identityApi, + }), + }), + }, +}); diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index d070894206..091e2fbc42 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -52,7 +52,6 @@ describe('useFormDecorators', () => { [ formDecoratorsApiRef, DefaultScaffolderFormDecoratorsApi.create({ - // @ts-expect-error - todo decorators: [mockDecorator], }), ], @@ -90,7 +89,6 @@ describe('useFormDecorators', () => { [ formDecoratorsApiRef, DefaultScaffolderFormDecoratorsApi.create({ - // @ts-expect-error - todo decorators: [mockDecorator], }), ], diff --git a/plugins/scaffolder/src/alpha/index.ts b/plugins/scaffolder/src/alpha/index.ts index bd3104a274..53e2b8f0bf 100644 --- a/plugins/scaffolder/src/alpha/index.ts +++ b/plugins/scaffolder/src/alpha/index.ts @@ -23,5 +23,6 @@ export { } from './components'; export { scaffolderTranslationRef } from '../translation'; +export * from './api'; export { default } from './plugin'; diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 54e375e875..f3f0e9ed9e 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -26,11 +26,11 @@ import { selectedTemplateRouteRef, viewTechDocRouteRef, } from '../routes'; -import { scaffolderApi } from './api'; import { repoUrlPickerFormField, scaffolderNavItem, scaffolderPage, + scaffolderApi, } from './extensions'; import { formFieldsApi } from './api/FormFieldsApi'; From aac1e45bc586467fbda1ed0f50cc19b3f32968ce Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 09:51:15 +0100 Subject: [PATCH 09/16] chore: small cleanup Signed-off-by: blam --- .../createScaffolderFormDecorator.ts | 28 ++++++++----------- .../src/alpha/hooks/useFormDecorators.ts | 2 +- .../src/components/Router/Router.tsx | 1 - 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 20da932c05..2c0a43b24d 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -32,11 +32,8 @@ export type ScaffolderFormDecoratorContext = { /** @alpha */ export type ScaffolderFormDecorator< - TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {}, - TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, - TInput extends {} = { - [key in keyof TInputSchema]: z.infer>; - }, + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, + TDeps extends { [key in string]: AnyApiRef }, > = { version: 'v1'; id: string; @@ -45,7 +42,9 @@ export type ScaffolderFormDecorator< }; deps?: TDeps; fn: ( - ctx: ScaffolderFormDecoratorContext, + ctx: ScaffolderFormDecoratorContext<{ + [key in keyof TInputSchema]: z.infer>; + }>, deps: TDeps extends { [key in string]: AnyApiRef } ? { [key in keyof TDeps]: TDeps[key]['T'] } : never, @@ -53,22 +52,15 @@ export type ScaffolderFormDecorator< }; /** @alpha */ -export type AnyScaffolderFormDecorator = ScaffolderFormDecorator< - { [key in string]: (zImpl: typeof z) => z.ZodType }, - { [key in string]: AnyApiRef }, - any ->; +export type AnyScaffolderFormDecorator = ScaffolderFormDecorator; /** * Method for creating decorators which can be used to collect * secrets from the user before submitting to the backend. * @alpha */ export function createScaffolderFormDecorator< - TDeps extends { [key in string]: AnyApiRef }, TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TInput extends {} = { - [key in keyof TInputSchema]: z.infer>; - }, + TDeps extends { [key in string]: AnyApiRef }, >(options: { id: string; schema?: { @@ -76,12 +68,14 @@ export function createScaffolderFormDecorator< }; deps?: TDeps; fn: ( - ctx: ScaffolderFormDecoratorContext, + ctx: ScaffolderFormDecoratorContext<{ + [key in keyof TInputSchema]: z.infer>; + }>, deps: TDeps extends { [key in string]: AnyApiRef } ? { [key in keyof TDeps]: TDeps[key]['T'] } : never, ) => Promise; -}): ScaffolderFormDecorator { +}): ScaffolderFormDecorator { return { ...options, version: 'v1', diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts index 293c81996d..50fc6bfde0 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useApi, useApiHolder } from '@backstage/core-plugin-api'; +import { AnyApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; import { formDecoratorsApiRef } from '../api/ref'; import useAsync from 'react-use/esm/useAsync'; import { useMemo } from 'react'; diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index a02473b534..6c06322867 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -64,7 +64,6 @@ import { taskReadPermission, templateManagementPermission, } from '@backstage/plugin-scaffolder-common/alpha'; -import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha'; /** * The Props for the Scaffolder Router From f217918b14d981b95d69131bddb743bf19cbd123 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 12:01:44 +0100 Subject: [PATCH 10/16] chore: refactor to use opaque type helper instead Signed-off-by: blam --- .../src/components/scaffolder/decorators.ts | 2 +- packages/scaffolder-internal/package.json | 1 + .../src/wiring/InternalFormDecorator.ts | 42 ++++++++++++ .../scaffolder-internal/src/wiring/index.ts | 1 + .../src/next/components/Workflow/Workflow.tsx | 4 +- .../createScaffolderFormDecorator.test.ts | 4 +- .../createScaffolderFormDecorator.ts | 65 ++++++++++--------- .../src/alpha/api/FormDecoratorsApi.ts | 8 +-- plugins/scaffolder/src/alpha/api/types.ts | 4 +- .../alpha/hooks/useFormDecorators.test.tsx | 4 +- .../src/alpha/hooks/useFormDecorators.ts | 36 +++++----- yarn.lock | 1 + 12 files changed, 110 insertions(+), 62 deletions(-) create mode 100644 packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts diff --git a/packages/app/src/components/scaffolder/decorators.ts b/packages/app/src/components/scaffolder/decorators.ts index 6cce3a3fe0..7516a861b2 100644 --- a/packages/app/src/components/scaffolder/decorators.ts +++ b/packages/app/src/components/scaffolder/decorators.ts @@ -26,7 +26,7 @@ export const mockDecorator = createScaffolderFormDecorator({ deps: { githubApi: githubAuthApiRef, }, - fn: async ({ setSecrets }, { githubApi }) => { + decorator: async ({ setSecrets }, { githubApi }) => { const token = await githubApi.getAccessToken(); setSecrets(state => ({ ...state, GITHUB_TOKEN: token })); }, diff --git a/packages/scaffolder-internal/package.json b/packages/scaffolder-internal/package.json index d07614f127..9350fa7ac1 100644 --- a/packages/scaffolder-internal/package.json +++ b/packages/scaffolder-internal/package.json @@ -23,6 +23,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-scaffolder-react": "workspace:^", "zod": "^3.22.4" }, diff --git a/packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts b/packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts new file mode 100644 index 0000000000..7942544acb --- /dev/null +++ b/packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { OpaqueType } from '@internal/opaque'; +import { z } from 'zod'; + +import { + ScaffolderFormDecorator, + ScaffolderFormDecoratorContext, +} from '@backstage/plugin-scaffolder-react/alpha'; +import { AnyApiRef } from '@backstage/frontend-plugin-api'; + +/** @alpha */ +export const OpaqueFormDecorator = OpaqueType.create<{ + public: ScaffolderFormDecorator; + versions: { + readonly version: 'v1'; + readonly id: string; + readonly schema?: { + input?: { + [key in string]: (zImpl: typeof z) => z.ZodType; + }; + }; + readonly deps?: { [key in string]: AnyApiRef }; + readonly decorator: ( + ctx: ScaffolderFormDecoratorContext, + deps: { [depName in string]: AnyApiRef['T'] }, + ) => Promise; + }; +}>({ type: '@backstage/scaffolder/FormDecorator', versions: ['v1'] }); diff --git a/packages/scaffolder-internal/src/wiring/index.ts b/packages/scaffolder-internal/src/wiring/index.ts index c85755bccf..37c8bd09cb 100644 --- a/packages/scaffolder-internal/src/wiring/index.ts +++ b/packages/scaffolder-internal/src/wiring/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { OpaqueFormField, type FormField } from './InternalFormField'; +export { OpaqueFormDecorator } from './InternalFormDecorator'; diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 91bc98dad3..f984f5ff7a 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -106,7 +106,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { return; } - await formDecorator.fn({ + await formDecorator.decorator({ setSecrets, setFormState: ( handler: ( @@ -116,7 +116,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { formState = { ...handler(formState) }; }, formState, - input: decorator.input, + input: decorator.input ?? {}, }); }), ); diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts index cef533d283..e8157c9d9e 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts @@ -20,7 +20,7 @@ describe('createScaffolderFormDecorator', () => { const decorator = createScaffolderFormDecorator({ id: 'test', deps: {}, - fn: async () => {}, + decorator: async () => {}, }); expect(decorator).toMatchInlineSnapshot(` @@ -43,7 +43,7 @@ describe('createScaffolderFormDecorator', () => { age: z => z.number(), }, }, - fn: async ctx => { + decorator: async ctx => { const name: string = ctx.input.name; // @ts-expect-error diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 2c0a43b24d..0064ae0397 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -14,11 +14,14 @@ * limitations under the License. */ import { AnyApiRef } from '@backstage/core-plugin-api'; -import { JsonValue } from '@backstage/types'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { OpaqueFormDecorator } from '@internal/scaffolder'; import { z } from 'zod'; /** @alpha */ -export type ScaffolderFormDecoratorContext = { +export type ScaffolderFormDecoratorContext< + TInput extends JsonObject = JsonObject, +> = { input: TInput; formState: Record; @@ -31,53 +34,51 @@ export type ScaffolderFormDecoratorContext = { }; /** @alpha */ -export type ScaffolderFormDecorator< - TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TDeps extends { [key in string]: AnyApiRef }, -> = { - version: 'v1'; - id: string; - schema?: { - input?: TInputSchema; - }; - deps?: TDeps; - fn: ( - ctx: ScaffolderFormDecoratorContext<{ - [key in keyof TInputSchema]: z.infer>; - }>, - deps: TDeps extends { [key in string]: AnyApiRef } - ? { [key in keyof TDeps]: TDeps[key]['T'] } - : never, - ) => Promise; +export type ScaffolderFormDecorator = { + readonly $$type: '@backstage/scaffolder/FormDecorator'; + readonly id: string; + readonly TInput: TInput; }; -/** @alpha */ -export type AnyScaffolderFormDecorator = ScaffolderFormDecorator; /** * Method for creating decorators which can be used to collect * secrets from the user before submitting to the backend. * @alpha */ export function createScaffolderFormDecorator< - TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, - TDeps extends { [key in string]: AnyApiRef }, + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, + TInput extends JsonObject = { + [key in keyof TInputSchema]: z.infer>; + }, >(options: { id: string; schema?: { input?: TInputSchema; }; deps?: TDeps; - fn: ( - ctx: ScaffolderFormDecoratorContext<{ - [key in keyof TInputSchema]: z.infer>; - }>, + decorator: ( + ctx: ScaffolderFormDecoratorContext, deps: TDeps extends { [key in string]: AnyApiRef } ? { [key in keyof TDeps]: TDeps[key]['T'] } : never, ) => Promise; -}): ScaffolderFormDecorator { - return { +}): ScaffolderFormDecorator { + return OpaqueFormDecorator.createInstance('v1', { ...options, - version: 'v1', - }; + TInput: null as unknown as TInput, + } as { + id: string; + schema?: { + input?: TInputSchema; + }; + TInput: TInput; + deps?: TDeps; + decorator: ( + ctx: ScaffolderFormDecoratorContext, + deps: { [key in string]: AnyApiRef['T'] }, + ) => Promise; + }); } diff --git a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts index 79ec618ce2..13c4b6d1a2 100644 --- a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts +++ b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts @@ -15,7 +15,7 @@ */ import { ScaffolderFormDecoratorsApi } from './types'; -import { AnyScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; +import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; /** @alpha */ export class DefaultScaffolderFormDecoratorsApi @@ -23,17 +23,17 @@ export class DefaultScaffolderFormDecoratorsApi { private constructor( private readonly options: { - decorators: Array; + decorators: Array; }, ) {} - static create(options?: { decorators: AnyScaffolderFormDecorator[] }) { + static create(options?: { decorators: ScaffolderFormDecorator[] }) { return new DefaultScaffolderFormDecoratorsApi( options ?? { decorators: [] }, ); } - async getFormDecorators(): Promise { + async getFormDecorators(): Promise { return this.options.decorators; } } diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts index d52d102d6d..28ce8dab3d 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -16,7 +16,7 @@ import { FormFieldExtensionData, - AnyScaffolderFormDecorator, + ScaffolderFormDecorator, } from '@backstage/plugin-scaffolder-react/alpha'; export interface ScaffolderFormFieldsApi { @@ -24,5 +24,5 @@ export interface ScaffolderFormFieldsApi { } export interface ScaffolderFormDecoratorsApi { - getFormDecorators(): Promise; + getFormDecorators(): Promise; } diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index 091e2fbc42..66e2389a45 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -38,7 +38,7 @@ describe('useFormDecorators', () => { test: z => z.string(), }, }, - async fn({ input: { test } }, { mockApiRef: mock }) { + async decorator({ input: { test } }, { mockApiRef: mock }) { mock.test(test); }, }); @@ -70,7 +70,7 @@ describe('useFormDecorators', () => { const testDecorator = result.get('test')!; expect(testDecorator).toBeDefined(); - await testDecorator.fn({ + await testDecorator.decorator({ formState: {}, setFormState: () => {}, setSecrets: () => {}, diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts index 50fc6bfde0..c0dbe4b354 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -13,19 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AnyApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; +import { errorApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; import { formDecoratorsApiRef } from '../api/ref'; import useAsync from 'react-use/esm/useAsync'; import { useMemo } from 'react'; import { ScaffolderFormDecoratorContext } from '@backstage/plugin-scaffolder-react/alpha'; +import { OpaqueFormDecorator } from '@internal/scaffolder'; /** @internal */ type BoundFieldDecorator = { - fn: (ctx: ScaffolderFormDecoratorContext) => Promise; + decorator: (ctx: ScaffolderFormDecoratorContext) => Promise; }; export const useFormDecorators = () => { const formDecoratorsApi = useApi(formDecoratorsApiRef); + const errorApi = useApi(errorApiRef); const { value: decorators } = useAsync( () => formDecoratorsApi.getFormDecorators(), [], @@ -37,27 +39,27 @@ export const useFormDecorators = () => { for (const decorator of decorators ?? []) { try { - const resolvedDeps = Object.entries(decorator.deps ?? {}).map( - ([key, value]) => { - const api = apiHolder.get(value); - if (!api) { - throw new Error( - `Failed to resolve apiRef ${value.id} for form decorator ${decorator.id} it will be disabled`, - ); - } - return [key, api]; - }, - ); + const { decorator: decoratorFn, deps } = + OpaqueFormDecorator.toInternal(decorator); + + const resolvedDeps = Object.entries(deps ?? {}).map(([key, value]) => { + const api = apiHolder.get(value); + if (!api) { + throw new Error( + `Failed to resolve apiRef ${value.id} for form decorator ${decorator.id} it will be disabled`, + ); + } + return [key, api]; + }); decoratorsMap.set(decorator.id, { - fn: ctx => decorator.fn(ctx, Object.fromEntries(resolvedDeps)), + decorator: ctx => decoratorFn(ctx, Object.fromEntries(resolvedDeps)), }); } catch (ex) { - // eslint-disable-next-line no-console - console.error(ex); + errorApi.post(ex); return undefined; } } return decoratorsMap; - }, [apiHolder, decorators]); + }, [apiHolder, decorators, errorApi]); }; diff --git a/yarn.lock b/yarn.lock index 4ed6c78663..d0f61dedd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10339,6 +10339,7 @@ __metadata: resolution: "@internal/scaffolder@workspace:packages/scaffolder-internal" dependencies: "@backstage/cli": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-scaffolder-react": "workspace:^" zod: ^3.22.4 languageName: unknown From a8140e10aa16c258c1b127394b7c884fe8b0372f Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 12:44:47 +0100 Subject: [PATCH 11/16] chore: refactor, and move the execution Signed-off-by: blam --- .../src/components/scaffolder/decorators.ts | 11 ++-- .../src/next/components/Stepper/Stepper.tsx | 2 +- .../src/next/components/Workflow/Workflow.tsx | 48 +--------------- .../src/secrets/SecretsContext.tsx | 12 ---- .../TemplateWizardPage/TemplateWizardPage.tsx | 30 ++++++++-- plugins/scaffolder/src/alpha/hooks/index.ts | 16 ++++++ .../src/alpha/hooks/useFormDecorators.ts | 57 ++++++++++++++++++- plugins/scaffolder/src/alpha/index.ts | 2 + 8 files changed, 109 insertions(+), 69 deletions(-) create mode 100644 plugins/scaffolder/src/alpha/hooks/index.ts diff --git a/packages/app/src/components/scaffolder/decorators.ts b/packages/app/src/components/scaffolder/decorators.ts index 7516a861b2..ffb78562f8 100644 --- a/packages/app/src/components/scaffolder/decorators.ts +++ b/packages/app/src/components/scaffolder/decorators.ts @@ -17,7 +17,7 @@ import { githubAuthApiRef } from '@backstage/core-plugin-api'; import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; export const mockDecorator = createScaffolderFormDecorator({ - id: 'githubOauth', + id: 'mock-decorator', schema: { input: { test: z => z.string(), @@ -26,8 +26,11 @@ export const mockDecorator = createScaffolderFormDecorator({ deps: { githubApi: githubAuthApiRef, }, - decorator: async ({ setSecrets }, { githubApi }) => { - const token = await githubApi.getAccessToken(); - setSecrets(state => ({ ...state, GITHUB_TOKEN: token })); + decorator: async ( + { setSecrets, setFormState, input: { test } }, + { githubApi: _githubApi }, + ) => { + setFormState(state => ({ ...state, test, mock: 'MOCK' })); + setSecrets(state => ({ ...state, GITHUB_TOKEN: 'MOCK_TOKEN' })); }, }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 0b4cbbc94a..35bae80ec5 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -120,11 +120,11 @@ export const Stepper = (stepperProps: StepperProps) => { reviewButtonText = 'Review', } = components; const analytics = useAnalytics(); - const { presentation, steps } = useTemplateSchema(props.manifest); const apiHolder = useApiHolder(); const [activeStep, setActiveStep] = useState(0); const [isValidating, setIsValidating] = useState(false); const [initialState] = useFormDataFromQuery(props.initialState); + const { presentation, steps } = useTemplateSchema(props.manifest); const [stepsState, setStepsState] = useState>(initialState); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index f984f5ff7a..204df49f99 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -26,10 +26,7 @@ import { makeStyles } from '@material-ui/core/styles'; import { errorApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api'; import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema'; import { Stepper, type StepperProps } from '../Stepper/Stepper'; -import { - SecretsContextProvider, - useInternalTemplateSecrets, -} from '../../../secrets/SecretsContext'; +import { SecretsContextProvider } from '../../../secrets/SecretsContext'; import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; @@ -88,40 +85,9 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const { loading, manifest, error } = useTemplateParameterSchema(templateRef); const sortedManifest = useFilteredSchemaProperties(manifest); const minutesSaved = useTemplateTimeSavedMinutes(templateRef); - const { setSecrets } = useInternalTemplateSecrets(); - const formDecorators = useFormDecorators(); const workflowOnCreate = useCallback( - async (originalFormState: Record) => { - let formState: Record = { ...originalFormState }; - - if (manifest?.EXPERIMENTAL_formDecorators) { - // for each of the form decorators, go and call the decorator with the context - await Promise.all( - manifest.EXPERIMENTAL_formDecorators.map(async decorator => { - const formDecorator = formDecorators?.get(decorator.id); - if (!formDecorator) { - // eslint-disable-next-line no-console - console.error('Failed to find form decorator', decorator.id); - return; - } - - await formDecorator.decorator({ - setSecrets, - setFormState: ( - handler: ( - oldState: Record, - ) => Record, - ) => { - formState = { ...handler(formState) }; - }, - formState, - input: decorator.input ?? {}, - }); - }), - ); - } - + async (formState: Record) => { onCreate(formState); const name = @@ -130,15 +96,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { value: minutesSaved, }); }, - [ - manifest?.EXPERIMENTAL_formDecorators, - formDecorators, - onCreate, - analytics, - templateName, - minutesSaved, - setSecrets, - ], + [onCreate, analytics, templateName, minutesSaved], ); useEffect(() => { diff --git a/plugins/scaffolder-react/src/secrets/SecretsContext.tsx b/plugins/scaffolder-react/src/secrets/SecretsContext.tsx index a12722a1fd..64b25d0e8f 100644 --- a/plugins/scaffolder-react/src/secrets/SecretsContext.tsx +++ b/plugins/scaffolder-react/src/secrets/SecretsContext.tsx @@ -94,15 +94,3 @@ export const useTemplateSecrets = (): ScaffolderUseTemplateSecrets => { return { setSecrets, secrets }; }; - -export const useInternalTemplateSecrets = () => { - const value = useContext(SecretsContext)?.atVersion(1); - - if (!value) { - throw new Error( - 'useTemplateSecrets must be used within a SecretsContextProvider', - ); - } - - return value; -}; diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index b5e24a7d52..2ed6de4479 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import useAsync from 'react-use/esm/useAsync'; import { @@ -36,9 +36,12 @@ import { } from '@backstage/plugin-scaffolder-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { Workflow } from '@backstage/plugin-scaffolder-react/alpha'; +import { + Workflow, + useTemplateParameterSchema, +} from '@backstage/plugin-scaffolder-react/alpha'; import { JsonValue } from '@backstage/types'; -import { Header, Page } from '@backstage/core-components'; +import { Header, Page, Progress } from '@backstage/core-components'; import { rootRouteRef, @@ -49,6 +52,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; import { TemplateWizardPageContextMenu } from './TemplateWizardPageContextMenu'; +import { useFormDecorators } from '../../hooks'; /** * @alpha @@ -70,9 +74,10 @@ export type TemplateWizardPageProps = { export const TemplateWizardPage = (props: TemplateWizardPageProps) => { const rootRef = useRouteRef(rootRouteRef); const taskRoute = useRouteRef(scaffolderTaskRouteRef); - const { secrets } = useTemplateSecrets(); + const { secrets: contextSecrets } = useTemplateSecrets(); const scaffolderApi = useApi(scaffolderApiRef); const catalogApi = useApi(catalogApiRef); + const [isCreating, setIsCreating] = useState(false); const navigate = useNavigate(); const { templateName, namespace } = useRouteRefParams( selectedTemplateRouteRef, @@ -85,12 +90,26 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { name: templateName, }); + const { manifest } = useTemplateParameterSchema(templateRef); + const decorators = useFormDecorators({ manifest }); + const { value: editUrl } = useAsync(async () => { const data = await catalogApi.getEntityByRef(templateRef); return data?.metadata.annotations?.[ANNOTATION_EDIT_URL]; }, [templateRef, catalogApi]); - const onCreate = async (values: Record) => { + const onCreate = async (initialValues: Record) => { + if (isCreating) { + return; + } + + setIsCreating(true); + + const { formState: values, secrets } = await decorators.run({ + formState: initialValues, + secrets: contextSecrets, + }); + const { taskId } = await scaffolderApi.scaffold({ templateRef, values, @@ -113,6 +132,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { > + {isCreating && } Promise; }; -export const useFormDecorators = () => { +/** @alpha */ +export const useFormDecorators = ({ + manifest, +}: { + manifest?: TemplateParameterSchema; +}) => { const formDecoratorsApi = useApi(formDecoratorsApiRef); const errorApi = useApi(errorApiRef); const { value: decorators } = useAsync( @@ -34,7 +41,7 @@ export const useFormDecorators = () => { ); const apiHolder = useApiHolder(); - return useMemo(() => { + const boundDecorators = useMemo(() => { const decoratorsMap = new Map(); for (const decorator of decorators ?? []) { @@ -62,4 +69,50 @@ export const useFormDecorators = () => { } return decoratorsMap; }, [apiHolder, decorators, errorApi]); + + return { + run: async (opts: { + formState: Record; + secrets: Record; + }) => { + let formState: Record = { ...opts.formState }; + let secrets: Record = {}; + + if (manifest?.EXPERIMENTAL_formDecorators) { + // for each of the form decorators, go and call the decorator with the context + await Promise.all( + manifest.EXPERIMENTAL_formDecorators.map(async decorator => { + const formDecorator = boundDecorators?.get(decorator.id); + if (!formDecorator) { + errorApi.post( + new Error(`Failed to find form decorator ${decorator.id}`), + ); + return; + } + + await formDecorator.decorator({ + setSecrets: ( + handler: ( + oldState: Record, + ) => Record, + ) => { + secrets = { ...handler(secrets) }; + }, + setFormState: ( + handler: ( + oldState: Record, + ) => Record, + ) => { + formState = { ...handler(formState) }; + }, + formState, + input: decorator.input ?? {}, + }); + }), + ); + } + + return { formState, secrets }; + }, + }; }; diff --git a/plugins/scaffolder/src/alpha/index.ts b/plugins/scaffolder/src/alpha/index.ts index 53e2b8f0bf..17c9c82fef 100644 --- a/plugins/scaffolder/src/alpha/index.ts +++ b/plugins/scaffolder/src/alpha/index.ts @@ -22,6 +22,8 @@ export { type ScaffolderTemplateFormPreviewerClassKey, } from './components'; +export * from './hooks'; + export { scaffolderTranslationRef } from '../translation'; export * from './api'; From 066d0f00b82d60a5b6cb520499f2ed66685e2366 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 12:50:03 +0100 Subject: [PATCH 12/16] chore: updated tests Signed-off-by: blam --- .../src/next/components/Workflow/Workflow.tsx | 2 - .../alpha/hooks/useFormDecorators.test.tsx | 49 ++++++------------- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 204df49f99..8c202eb3b3 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -31,8 +31,6 @@ import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProper import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved'; import { JsonValue } from '@backstage/types'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { useFormDecorators } from '../../../../../scaffolder/src/alpha/hooks/useFormDecorators'; const useStyles = makeStyles({ markdown: { diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index 66e2389a45..92475c3eea 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -15,13 +15,14 @@ */ import { DefaultScaffolderFormDecoratorsApi } from '../api/FormDecoratorsApi'; import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; -import { createApiRef } from '@backstage/core-plugin-api'; +import { createApiRef, errorApiRef } from '@backstage/core-plugin-api'; import { TestApiProvider } from '@backstage/test-utils'; import { renderHook, waitFor } from '@testing-library/react'; import { useFormDecorators } from './useFormDecorators'; import React from 'react'; import { formDecoratorsApiRef } from '../api/ref'; +import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; describe('useFormDecorators', () => { const mockApiRef = createApiRef<{ @@ -43,8 +44,14 @@ describe('useFormDecorators', () => { }, }); - it('should wrap up the form decorators', async () => { - const renderedHook = renderHook(() => useFormDecorators(), { + const manifest: TemplateParameterSchema = { + EXPERIMENTAL_formDecorators: [{ id: 'test', input: { test: 'hello' } }], + steps: [], + title: 'test', + }; + + it('should run the form decorators for a given manifest with the correct input', async () => { + const renderedHook = renderHook(() => useFormDecorators({ manifest }), { wrapper: ({ children }) => ( { decorators: [mockDecorator], }), ], + [errorApiRef, { post: () => {} }], ]} > {children} @@ -65,41 +73,12 @@ describe('useFormDecorators', () => { await waitFor(async () => { const result = renderedHook.result.current!; - expect(result.size).toBe(1); - - const testDecorator = result.get('test')!; - expect(testDecorator).toBeDefined(); - - await testDecorator.decorator({ + await result.run({ formState: {}, - setFormState: () => {}, - setSecrets: () => {}, - input: { test: 'input value' }, + secrets: {}, }); - expect(mockApiImplementation.test).toHaveBeenCalledWith('input value'); + expect(mockApiImplementation.test).toHaveBeenCalledWith('hello'); }); }); - - it('should skip failing deps', async () => { - const renderedHook = renderHook(() => useFormDecorators(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - const result = renderedHook.result.current!; - expect(result.size).toBe(0); - }); }); From 881f4a6d4fd289738de98e43e43af9bf86ff4e02 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 13:05:37 +0100 Subject: [PATCH 13/16] chore: api-reports Signed-off-by: blam --- plugins/catalog-import/report-alpha.api.md | 30 +++---- plugins/catalog/report-alpha.api.md | 30 +++---- plugins/devtools/report-alpha.api.md | 30 +++---- plugins/kubernetes/report-alpha.api.md | 30 +++---- plugins/scaffolder-react/report-alpha.api.md | 53 ++++-------- .../src/next/components/Stepper/Stepper.tsx | 2 +- .../components/Workflow/Workflow.test.tsx | 57 +++++-------- .../src/next/components/Workflow/Workflow.tsx | 5 ++ plugins/scaffolder/report-alpha.api.md | 80 +++++++++++++++---- plugins/scaffolder/src/alpha/api/ref.ts | 2 + plugins/scaffolder/src/alpha/api/types.ts | 2 + plugins/search/report-alpha.api.md | 30 +++---- plugins/techdocs/report-alpha.api.md | 30 +++---- 13 files changed, 204 insertions(+), 177 deletions(-) diff --git a/plugins/catalog-import/report-alpha.api.md b/plugins/catalog-import/report-alpha.api.md index 1706404b94..c13f7a1a4a 100644 --- a/plugins/catalog-import/report-alpha.api.md +++ b/plugins/catalog-import/report-alpha.api.md @@ -18,21 +18,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:catalog-import': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:catalog-import': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -63,6 +48,21 @@ const _default: FrontendPlugin< routeRef?: RouteRef | undefined; }; }>; + 'api:catalog-import': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; } >; export default _default; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 1b1ce54d92..3a9f0c6c33 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -146,21 +146,6 @@ const _default: FrontendPlugin< unregisterRedirect: ExternalRouteRef; }, { - 'api:catalog': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'nav-item:catalog': ExtensionDefinition<{ kind: 'nav-item'; name: undefined; @@ -182,6 +167,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:catalog': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'api:catalog/starred-entities': ExtensionDefinition<{ kind: 'api'; name: 'starred-entities'; diff --git a/plugins/devtools/report-alpha.api.md b/plugins/devtools/report-alpha.api.md index 1b026d1f78..e3261e0f05 100644 --- a/plugins/devtools/report-alpha.api.md +++ b/plugins/devtools/report-alpha.api.md @@ -19,21 +19,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:devtools': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:devtools': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -85,6 +70,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:devtools': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; } >; export default _default; diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 9ba748577f..4f779df258 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -21,21 +21,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:kubernetes': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:kubernetes': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -62,6 +47,21 @@ const _default: FrontendPlugin< routeRef?: RouteRef | undefined; }; }>; + 'api:kubernetes': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'entity-content:kubernetes/kubernetes': ExtensionDefinition<{ kind: 'entity-content'; name: 'kubernetes'; diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 092217abe8..2cc8416b3c 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -78,13 +78,17 @@ export function createFormField< // @alpha export function createScaffolderFormDecorator< - TDeps extends { - [key in string]: AnyApiRef; - }, TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; + } = { + [key in string]: (zImpl: typeof z) => z.ZodType; }, - TInput extends {} = { + TDeps extends { + [key in string]: AnyApiRef; + } = { + [key in string]: AnyApiRef; + }, + TInput extends JsonObject = { [key in keyof TInputSchema]: z.infer>; }, >(options: { @@ -93,7 +97,7 @@ export function createScaffolderFormDecorator< input?: TInputSchema; }; deps?: TDeps; - fn: ( + decorator: ( ctx: ScaffolderFormDecoratorContext, deps: TDeps extends { [key in string]: AnyApiRef; @@ -103,7 +107,7 @@ export function createScaffolderFormDecorator< } : never, ) => Promise; -}): ScaffolderFormDecorator; +}): ScaffolderFormDecorator; // @alpha export const DefaultTemplateOutputs: (props: { @@ -221,39 +225,16 @@ export interface ScaffolderFieldProps { } // @alpha (undocumented) -export type ScaffolderFormDecorator< - TInputSchema extends { - [key in string]: (zImpl: typeof z) => z.ZodType; - } = {}, - TDeps extends { - [key in string]: AnyApiRef; - } = { - [key in string]: AnyApiRef; - }, - TInput extends {} = { - [key in keyof TInputSchema]: z.infer>; - }, -> = { - version: 'v1'; - id: string; - schema?: { - input?: TInputSchema; - }; - deps?: TDeps; - fn: ( - ctx: ScaffolderFormDecoratorContext, - deps: TDeps extends { - [key in string]: AnyApiRef; - } - ? { - [key in keyof TDeps]: TDeps[key]['T']; - } - : never, - ) => Promise; +export type ScaffolderFormDecorator = { + readonly $$type: '@backstage/scaffolder/FormDecorator'; + readonly id: string; + readonly TInput: TInput; }; // @alpha (undocumented) -export type ScaffolderFormDecoratorContext = { +export type ScaffolderFormDecoratorContext< + TInput extends JsonObject = JsonObject, +> = { input: TInput; formState: Record; setFormState: ( diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 35bae80ec5..0b4cbbc94a 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -120,11 +120,11 @@ export const Stepper = (stepperProps: StepperProps) => { reviewButtonText = 'Review', } = components; const analytics = useAnalytics(); + const { presentation, steps } = useTemplateSchema(props.manifest); const apiHolder = useApiHolder(); const [activeStep, setActiveStep] = useState(0); const [isValidating, setIsValidating] = useState(false); const [initialState] = useFormDataFromQuery(props.initialState); - const { presentation, steps } = useTemplateSchema(props.manifest); const [stepsState, setStepsState] = useState>(initialState); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index b942e5487a..243b9b7e79 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -27,12 +27,6 @@ import { analyticsApiRef } from '@backstage/core-plugin-api'; import { ScaffolderApi, scaffolderApiRef } from '../../../api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; -import { SecretsContextProvider } from '../../../secrets'; - -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { ScaffolderFormDecoratorsApi } from '../../../../../scaffolder/src/alpha/api/types'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { formDecoratorsApiRef } from '../../../../../scaffolder/src/alpha/api/ref'; const scaffolderApiMock: jest.Mocked = { cancelTask: jest.fn(), @@ -45,19 +39,14 @@ const scaffolderApiMock: jest.Mocked = { listTasks: jest.fn(), autocomplete: jest.fn(), }; -const scaffolderDecoratorsMock: jest.Mocked = { - getFormDecorators: jest.fn().mockResolvedValue([]), -}; const catalogApi = catalogApiMock.mock(); const analyticsMock = mockApis.analytics(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], - [formDecoratorsApiRef, scaffolderDecoratorsMock], [catalogApiRef, catalogApi], [analyticsApiRef, analyticsMock], - [catalogApiRef, catalogApi], ); describe('', () => { @@ -93,33 +82,31 @@ describe('', () => { }); const { getByRole, getAllByRole, getByText } = await renderInTestApp( - - - + ( -

This is a different wrapper for the review page

- ), - reviewButtonText: Onwards, - createButtonText: Make, - }} - extensions={[]} - /> -
-
, + onCreate={onCreate} + onError={onError} + namespace="default" + templateName="docs-template" + initialState={{ + name: 'prefilled-name', + age: '53', + }} + components={{ + ReviewStateComponent: () => ( +

This is a different wrapper for the review page

+ ), + reviewButtonText: Onwards, + createButtonText: Make, + }} + extensions={[]} + /> + , ); // Test template title is overriden diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 8c202eb3b3..9afc18c438 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -72,6 +72,7 @@ export type WorkflowProps = { export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const { title, description, namespace, templateName, onCreate, ...props } = workflowProps; + const analytics = useAnalytics(); const styles = useStyles(); const templateRef = stringifyEntityRef({ @@ -79,9 +80,13 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { namespace: namespace, name: templateName, }); + const errorApi = useApi(errorApiRef); + const { loading, manifest, error } = useTemplateParameterSchema(templateRef); + const sortedManifest = useFilteredSchemaProperties(manifest); + const minutesSaved = useTemplateTimeSavedMinutes(templateRef); const workflowOnCreate = useCallback( diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 4253b2b914..13f7189051 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -7,25 +7,30 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; +import { ApiRef } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { FormField } from '@internal/scaffolder'; +import { FormFieldExtensionData } from '@backstage/plugin-scaffolder-react/alpha'; import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; +import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { LayoutOptions } from '@backstage/plugin-scaffolder-react'; import { PathParams } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { RouteRef } from '@backstage/frontend-plugin-api'; +import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; import { SubRouteRef } from '@backstage/frontend-plugin-api'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; +import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) @@ -49,21 +54,6 @@ const _default: FrontendPlugin< }>; }, { - 'api:scaffolder': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:scaffolder': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -126,6 +116,21 @@ const _default: FrontendPlugin< field: () => Promise; }; }>; + 'api:scaffolder': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'api:scaffolder/form-fields': ExtensionDefinition<{ config: {}; configInput: {}; @@ -157,6 +162,24 @@ const _default: FrontendPlugin< >; export default _default; +// @alpha (undocumented) +export class DefaultScaffolderFormDecoratorsApi + implements ScaffolderFormDecoratorsApi +{ + // (undocumented) + static create(options?: { + decorators: ScaffolderFormDecorator[]; + }): DefaultScaffolderFormDecoratorsApi; + // (undocumented) + getFormDecorators(): Promise; +} + +// @alpha (undocumented) +export const formDecoratorsApiRef: ApiRef; + +// @alpha (undocumented) +export const formFieldsApiRef: ApiRef; + // @alpha @deprecated export type FormProps = Pick< FormProps_2, @@ -170,6 +193,18 @@ export type ScaffolderCustomFieldExplorerClassKey = | 'fieldForm' | 'preview'; +// @alpha (undocumented) +export interface ScaffolderFormDecoratorsApi { + // (undocumented) + getFormDecorators(): Promise; +} + +// @alpha (undocumented) +export interface ScaffolderFormFieldsApi { + // (undocumented) + getFormFields(): Promise; +} + // @public (undocumented) export type ScaffolderTemplateEditorClassKey = | 'root' @@ -379,5 +414,20 @@ export type TemplateWizardPageProps = { }; }; +// @alpha (undocumented) +export const useFormDecorators: ({ + manifest, +}: { + manifest?: TemplateParameterSchema | undefined; +}) => { + run: (opts: { + formState: Record; + secrets: Record; + }) => Promise<{ + formState: Record; + secrets: Record; + }>; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/scaffolder/src/alpha/api/ref.ts b/plugins/scaffolder/src/alpha/api/ref.ts index 121f394dce..9890a9a9b2 100644 --- a/plugins/scaffolder/src/alpha/api/ref.ts +++ b/plugins/scaffolder/src/alpha/api/ref.ts @@ -17,10 +17,12 @@ import { createApiRef } from '@backstage/frontend-plugin-api'; import { ScaffolderFormFieldsApi, ScaffolderFormDecoratorsApi } from './types'; +/** @alpha */ export const formFieldsApiRef = createApiRef({ id: 'plugin.scaffolder.form-fields', }); +/** @alpha */ export const formDecoratorsApiRef = createApiRef({ id: 'plugin.scaffolder.form-decorators', }); diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts index 28ce8dab3d..10f97a6a10 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -19,10 +19,12 @@ import { ScaffolderFormDecorator, } from '@backstage/plugin-scaffolder-react/alpha'; +/** @alpha */ export interface ScaffolderFormFieldsApi { getFormFields(): Promise; } +/** @alpha */ export interface ScaffolderFormDecoratorsApi { getFormDecorators(): Promise; } diff --git a/plugins/search/report-alpha.api.md b/plugins/search/report-alpha.api.md index 9b8f21fa43..d4a016bbe2 100644 --- a/plugins/search/report-alpha.api.md +++ b/plugins/search/report-alpha.api.md @@ -22,21 +22,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:search': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'nav-item:search': ExtensionDefinition<{ kind: 'nav-item'; name: undefined; @@ -58,6 +43,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:search': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'page:search': ExtensionDefinition<{ config: { noTrack: boolean; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 63e078dd43..d39c9d31ed 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -31,21 +31,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:techdocs': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:techdocs': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -97,6 +82,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:techdocs': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'api:techdocs/storage': ExtensionDefinition<{ kind: 'api'; name: 'storage'; From 75027d905478044f26f3e0b1447bc65cbb3bb891 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 13:13:45 +0100 Subject: [PATCH 14/16] chore: issue with snap Signed-off-by: blam Signed-off-by: blam --- .../next/extensions/createScaffolderFormDecorator.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts index e8157c9d9e..d9bba3e34c 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts @@ -25,8 +25,10 @@ describe('createScaffolderFormDecorator', () => { expect(decorator).toMatchInlineSnapshot(` { + "$$type": "@backstage/scaffolder/FormDecorator", + "TInput": null, + "decorator": [Function], "deps": {}, - "fn": [Function], "id": "test", "version": "v1", } @@ -55,8 +57,10 @@ describe('createScaffolderFormDecorator', () => { expect(decorator).toMatchInlineSnapshot(` { + "$$type": "@backstage/scaffolder/FormDecorator", + "TInput": null, + "decorator": [Function], "deps": {}, - "fn": [Function], "id": "test", "schema": { "input": { From 2bbf0a56656f8bfa9441fefe4b91f09a6380a803 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 13:57:44 +0100 Subject: [PATCH 15/16] chore: Fixing api -reports Signed-off-by: blam --- plugins/scaffolder/report-alpha.api.md | 17 ----------------- .../src/alpha/hooks/useFormDecorators.ts | 1 - plugins/scaffolder/src/alpha/index.ts | 2 -- 3 files changed, 20 deletions(-) diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 13f7189051..15ec32fa49 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -19,7 +19,6 @@ import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; -import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { LayoutOptions } from '@backstage/plugin-scaffolder-react'; import { PathParams } from '@backstage/core-plugin-api'; @@ -30,7 +29,6 @@ import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alph import { SubRouteRef } from '@backstage/frontend-plugin-api'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; -import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; import { TranslationRef } from '@backstage/core-plugin-api/alpha'; // @alpha (undocumented) @@ -414,20 +412,5 @@ export type TemplateWizardPageProps = { }; }; -// @alpha (undocumented) -export const useFormDecorators: ({ - manifest, -}: { - manifest?: TemplateParameterSchema | undefined; -}) => { - run: (opts: { - formState: Record; - secrets: Record; - }) => Promise<{ - formState: Record; - secrets: Record; - }>; -}; - // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts index 8d90d76a80..8469a89bec 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -27,7 +27,6 @@ type BoundFieldDecorator = { decorator: (ctx: ScaffolderFormDecoratorContext) => Promise; }; -/** @alpha */ export const useFormDecorators = ({ manifest, }: { diff --git a/plugins/scaffolder/src/alpha/index.ts b/plugins/scaffolder/src/alpha/index.ts index 17c9c82fef..53e2b8f0bf 100644 --- a/plugins/scaffolder/src/alpha/index.ts +++ b/plugins/scaffolder/src/alpha/index.ts @@ -22,8 +22,6 @@ export { type ScaffolderTemplateFormPreviewerClassKey, } from './components'; -export * from './hooks'; - export { scaffolderTranslationRef } from '../translation'; export * from './api'; From e37287d2ea51fc60e07ce3edbe223d2634dd12fc Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 19 Nov 2024 14:10:02 +0100 Subject: [PATCH 16/16] chore: some quick docs Signed-off-by: blam --- .../software-templates/experimental.md | 87 +++++++++++++++++++ microsite/sidebars.js | 1 + 2 files changed, 88 insertions(+) create mode 100644 docs/features/software-templates/experimental.md diff --git a/docs/features/software-templates/experimental.md b/docs/features/software-templates/experimental.md new file mode 100644 index 0000000000..e37971ad82 --- /dev/null +++ b/docs/features/software-templates/experimental.md @@ -0,0 +1,87 @@ +--- +id: experimental +title: Experimental Features +# prettier-ignore +description: Information on Experimental Features that are currently available in the Scaffolder +--- + +## Introduction + +This section contains information and guides on the experimental features that are currently available in the Scaffolder. Be advised that these features are still in development and may not be fully stable or complete, and are subject to change at any time. + +Please leave feedback on these features in the [Backstage Discord](https://discord.com/invite/MUpMjP2) or by [creating an issue](https://github.com/backstage/backstage/issues/new/choose) on the Backstage GitHub repository. + +## Retries and Recovery + +### TODO + +## Form Decorators + +Form decorators provide the ability to run arbitrary code before the form is submitted along with secrets to the `scaffolder-backend` plugin. They are provided to the `app` using a Utility API. + +#### Installation + +To install the Form Decorators, add the following to your `packages/app/src/apis.ts`: + +```ts + createApiFactory({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => + DefaultScaffolderFormDecoratorsApi.create({ + decorators: [ + // add decorators here + ], + }), + }), +``` + +And then you'll also need to define which decorators run in each template using the `EXPERIMENTAL_formDecorators` key in the template's `spec`: + +```yaml +kind: Template +metadata: + name: my-template +spec: + EXPERIMENTAL_formDecorators: + - id: my-decorator + input: + test: something funky + + parameters: ... + steps: ... +``` + +#### Creating a Decorator + +You can create a decorator using the simple helper method `createScaffolderFormDecorator`: + +```ts +export const mockDecorator = createScaffolderFormDecorator({ + // give the decorator a name + id: 'mock-decorator', + + // define the schema for the input that can be proided in `template.yaml` + schema: { + input: { + test: z => z.string(), + }, + }, + deps: { + // define dependencies here + githubApi: githubAuthApiRef, + }, + decorator: async ( + // Context has all the things needed to write simple decorators + { setSecrets, setFormState, input: { test } }, + // Depepdencies injected here + { githubApi }, + ) => { + // mutate the form state + setFormState(state => ({ ...state, test, mock: 'MOCK' })); + + // mutate the form secrets + setSecrets(state => ({ ...state, GITHUB_TOKEN: 'MOCK_TOKEN' })); + }, +}); +``` diff --git a/microsite/sidebars.js b/microsite/sidebars.js index 61329f4985..2647143065 100644 --- a/microsite/sidebars.js +++ b/microsite/sidebars.js @@ -101,6 +101,7 @@ module.exports = { 'features/software-templates/migrating-to-rjsf-v5', 'features/software-templates/migrating-from-v1beta2-to-v1beta3', 'features/software-templates/dry-run-testing', + 'features/software-templates/experimental', ], }, {