From 82300ea9d86cf65b6cd093f6a6d65500e8dd69d4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 13 Feb 2025 13:04:39 +0100 Subject: [PATCH] scaffolder: add form decorator support to template editor Signed-off-by: Patrik Oldsberg --- .changeset/eighty-ways-train.md | 5 +++ .../TemplateEditorPage/DryRunContext.tsx | 16 ++++++--- .../DryRunResults/DryRunResults.test.tsx | 36 ++++++++++++------- .../DryRunResults/DryRunResultsList.test.tsx | 34 ++++++++++++------ .../DryRunResults/DryRunResultsView.test.tsx | 7 ++++ .../TemplateEditorPage.test.tsx | 6 ++++ .../TemplateWizardPage/TemplateWizardPage.tsx | 3 +- .../alpha/hooks/useFormDecorators.test.tsx | 9 +++-- .../src/alpha/hooks/useFormDecorators.ts | 28 +++++++++------ 9 files changed, 102 insertions(+), 42 deletions(-) create mode 100644 .changeset/eighty-ways-train.md diff --git a/.changeset/eighty-ways-train.md b/.changeset/eighty-ways-train.md new file mode 100644 index 0000000000..2b7950e567 --- /dev/null +++ b/.changeset/eighty-ways-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added support for experimental form decorators when dry-running templates in the template editor. diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunContext.tsx index 1765e8cb94..ced6f0f909 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunContext.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunContext.tsx @@ -16,7 +16,7 @@ import yaml from 'yaml'; import { useApi } from '@backstage/core-plugin-api'; -import { JsonObject } from '@backstage/types'; +import { JsonObject, JsonValue } from '@backstage/types'; import React, { createContext, ReactNode, @@ -30,6 +30,7 @@ import { scaffolderApiRef, ScaffolderDryRunResponse, } from '@backstage/plugin-scaffolder-react'; +import { useFormDecorators } from '../../hooks/useFormDecorators'; const MAX_CONTENT_SIZE = 64 * 1024; const CHUNK_SIZE = 32 * 1024; @@ -81,6 +82,7 @@ export function base64EncodeContent(content: string): string { } export function DryRunProvider(props: DryRunProviderProps) { + const decorators = useFormDecorators(); const scaffolderApi = useApi(scaffolderApiRef); const [state, setState] = useState< @@ -130,10 +132,16 @@ export function DryRunProvider(props: DryRunProviderProps) { const parsed = yaml.parse(options.templateContent); + const { formState: values, secrets } = await decorators.run({ + formState: options.values as Record, + secrets: {}, + manifest: parsed?.spec, + }); + const response = await scaffolderApi.dryRun({ template: parsed, - values: options.values, - secrets: {}, + values, + secrets, directoryContents: options.files.map(file => ({ path: file.path, base64Content: base64EncodeContent(file.content), @@ -150,7 +158,7 @@ export function DryRunProvider(props: DryRunProviderProps) { selectedResult: prevState.selectedResult ?? result, })); }, - [scaffolderApi], + [scaffolderApi, decorators], ); const dryRun = useMemo( diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx index 0267093ee6..277de1cf32 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx @@ -21,6 +21,7 @@ import React, { useEffect } from 'react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { DryRunProvider, useDryRun } from '../DryRunContext'; import { DryRunResults } from './DryRunResults'; +import { formDecoratorsApiRef } from '../../../api'; function DryRunRemote({ execute, @@ -50,19 +51,30 @@ function DryRunRemote({ return null; } -const mockScaffolderApi = { - dryRun: async () => ({ - directoryContents: [], - log: [], - output: {}, - steps: [], - }), -}; +const mockApis = [ + [ + scaffolderApiRef, + { + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), + }, + ], + [ + formDecoratorsApiRef, + { + getFormDecorators: async () => [], + }, + ], +] as const; describe('DryRunResults', () => { it('renders without exploding', async () => { await renderInTestApp( - + @@ -73,7 +85,7 @@ describe('DryRunResults', () => { it('expands when dry-run result is added and toggles on click, and disappears when results are gone', async () => { const { rerender } = await renderInTestApp( - + @@ -85,7 +97,7 @@ describe('DryRunResults', () => { await act(async () => { rerender( - + @@ -104,7 +116,7 @@ describe('DryRunResults', () => { await act(async () => { rerender( - + diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx index 706f284db1..6fbd5b3a60 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx @@ -21,6 +21,7 @@ import React, { useEffect } from 'react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { DryRunProvider, useDryRun } from '../DryRunContext'; import { DryRunResultsList } from './DryRunResultsList'; +import { formDecoratorsApiRef } from '../../../api'; function DryRunRemote({ execute }: { execute?: number }) { const dryRun = useDryRun(); @@ -39,19 +40,30 @@ function DryRunRemote({ execute }: { execute?: number }) { return null; } -const mockScaffolderApi = { - dryRun: async () => ({ - directoryContents: [], - log: [], - output: {}, - steps: [], - }), -}; +const mockApis = [ + [ + scaffolderApiRef, + { + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), + }, + ], + [ + formDecoratorsApiRef, + { + getFormDecorators: async () => [], + }, + ], +] as const; describe('DryRunResultsList', () => { it('renders without exploding', async () => { const rendered = await renderInTestApp( - + @@ -62,7 +74,7 @@ describe('DryRunResultsList', () => { it('adds new result items and deletes them', async () => { const { rerender } = await renderInTestApp( - + @@ -75,7 +87,7 @@ describe('DryRunResultsList', () => { await act(async () => { rerender( - + diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx index c5c391907e..e1d5bc874e 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx @@ -22,6 +22,7 @@ import React, { ReactNode, useEffect } from 'react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { DryRunProvider, useDryRun } from '../DryRunContext'; import { DryRunResultsView } from './DryRunResultsView'; +import { formDecoratorsApiRef } from '../../../api'; // The inside needs mocking to render in jsdom jest.mock('react-virtualized-auto-sizer', () => ({ @@ -70,6 +71,12 @@ describe('DryRunResultsView', () => { }), }, ], + [ + formDecoratorsApiRef, + { + getFormDecorators: async () => [], + }, + ], ]} > diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx index 7f7c66bc5d..e034ece21a 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx @@ -21,10 +21,14 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { TemplateEditorPage } from './TemplateEditorPage'; import { rootRouteRef } from '../../../routes'; +import { formDecoratorsApiRef } from '../../api'; describe('TemplateEditorPage', () => { const catalogApiMock = { getEntities: jest.fn().mockResolvedValue([]) }; const scaffolderApiMock = {}; + const formDecoratorsApiMock = { + getFormDecorators: jest.fn().mockResolvedValue([]), + }; it('Should render without exploding', async () => { await renderInTestApp( @@ -32,6 +36,7 @@ describe('TemplateEditorPage', () => { apis={[ [catalogApiRef, catalogApiMock], [scaffolderApiRef, scaffolderApiMock], + [formDecoratorsApiRef, formDecoratorsApiMock], ]} > @@ -53,6 +58,7 @@ describe('TemplateEditorPage', () => { apis={[ [catalogApiRef, catalogApiMock], [scaffolderApiRef, scaffolderApiMock], + [formDecoratorsApiRef, formDecoratorsApiMock], ]} > diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index 2ed6de4479..0c2356de54 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -91,7 +91,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { }); const { manifest } = useTemplateParameterSchema(templateRef); - const decorators = useFormDecorators({ manifest }); + const decorators = useFormDecorators(); const { value: editUrl } = useAsync(async () => { const data = await catalogApi.getEntityByRef(templateRef); @@ -108,6 +108,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { const { formState: values, secrets } = await decorators.run({ formState: initialValues, secrets: contextSecrets, + manifest, }); const { taskId } = await scaffolderApi.scaffold({ diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index 8f486bbba1..4da8b5e424 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -51,7 +51,7 @@ describe('useFormDecorators', () => { }; it('should run the form decorators for a given manifest with the correct input', async () => { - const renderedHook = renderHook(() => useFormDecorators({ manifest }), { + const renderedHook = renderHook(() => useFormDecorators(), { wrapper: ({ children }) => ( { await result.run({ formState: {}, secrets: {}, + manifest, }); expect(mockApiImplementation.test).toHaveBeenCalledWith('hello'); @@ -83,7 +84,7 @@ describe('useFormDecorators', () => { }); it('should return existing secrets and formstate', async () => { - const renderedHook = renderHook(() => useFormDecorators({ manifest }), { + const renderedHook = renderHook(() => useFormDecorators(), { wrapper: ({ children }) => ( { const { secrets, formState } = await result.run({ formState: { test: 'formState' }, secrets: { test: 'hello' }, + manifest, }); expect(secrets).toEqual({ test: 'hello' }); @@ -122,7 +124,7 @@ describe('useFormDecorators', () => { setSecrets(state => ({ ...state, new: 'hello' })); }, }); - const renderedHook = renderHook(() => useFormDecorators({ manifest }), { + const renderedHook = renderHook(() => useFormDecorators(), { wrapper: ({ children }) => ( { const { secrets, formState } = await result.run({ formState: { test: 'formState' }, secrets: { test: 'hello' }, + manifest, }); expect(secrets).toEqual({ test: 'hello', new: 'hello' }); diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts index d74c4a9823..a27d47fb59 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -16,7 +16,7 @@ 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 { useCallback, useMemo } from 'react'; import { ScaffolderFormDecoratorContext } from '@backstage/plugin-scaffolder-react/alpha'; import { OpaqueFormDecorator } from '@internal/scaffolder'; import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; @@ -27,11 +27,7 @@ type BoundFieldDecorator = { decorator: (ctx: ScaffolderFormDecoratorContext) => Promise; }; -export const useFormDecorators = ({ - manifest, -}: { - manifest?: TemplateParameterSchema; -}) => { +export const useFormDecorators = () => { const formDecoratorsApi = useApi(formDecoratorsApiRef); const errorApi = useApi(errorApiRef); const { value: decorators } = useAsync( @@ -69,18 +65,20 @@ export const useFormDecorators = ({ return decoratorsMap; }, [apiHolder, decorators, errorApi]); - return { - run: async (opts: { + const run = useCallback( + async (opts: { formState: Record; secrets: Record; + manifest?: TemplateParameterSchema; }) => { let formState: Record = { ...opts.formState }; let secrets: Record = { ...opts.secrets }; - if (manifest?.EXPERIMENTAL_formDecorators) { + const formDecorators = opts.manifest?.EXPERIMENTAL_formDecorators; + if (formDecorators) { // for each of the form decorators, go and call the decorator with the context await Promise.all( - manifest.EXPERIMENTAL_formDecorators.map(async decorator => { + formDecorators.map(async decorator => { const formDecorator = boundDecorators?.get(decorator.id); if (!formDecorator) { errorApi.post( @@ -113,5 +111,13 @@ export const useFormDecorators = ({ return { formState, secrets }; }, - }; + [boundDecorators, errorApi], + ); + + return useMemo( + () => ({ + run, + }), + [run], + ); };