From 189201ef4b685f7d47f59de2e8b5662e6ee0d647 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Oct 2024 10:42:06 +0200 Subject: [PATCH] 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} /> }