From aee334c14ceff6c0e3f235846613a67e6a93fab4 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Oct 2024 18:00:26 +0200 Subject: [PATCH] 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} /> }