scaffolder: add form decorator support to template editor

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-02-13 13:04:39 +01:00
parent 97f78162c6
commit 82300ea9d8
9 changed files with 102 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Added support for experimental form decorators when dry-running templates in the template editor.
@@ -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<string, JsonValue>,
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(
@@ -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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunResults />
</DryRunProvider>
@@ -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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunRemote />
<DryRunResults />
@@ -85,7 +97,7 @@ describe('DryRunResults', () => {
await act(async () => {
rerender(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunRemote execute />
<DryRunResults />
@@ -104,7 +116,7 @@ describe('DryRunResults', () => {
await act(async () => {
rerender(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunRemote remove />
<DryRunResults />
@@ -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(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunResultsList />
</DryRunProvider>
@@ -62,7 +74,7 @@ describe('DryRunResultsList', () => {
it('adds new result items and deletes them', async () => {
const { rerender } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunRemote execute={1} />
<DryRunResultsList />
@@ -75,7 +87,7 @@ describe('DryRunResultsList', () => {
await act(async () => {
rerender(
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
<TestApiProvider apis={mockApis}>
<DryRunProvider>
<DryRunRemote execute={2} />
<DryRunResultsList />
@@ -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 <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
jest.mock('react-virtualized-auto-sizer', () => ({
@@ -70,6 +71,12 @@ describe('DryRunResultsView', () => {
}),
},
],
[
formDecoratorsApiRef,
{
getFormDecorators: async () => [],
},
],
]}
>
<DryRunProvider>
@@ -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],
]}
>
<TemplateEditorPage />
@@ -53,6 +58,7 @@ describe('TemplateEditorPage', () => {
apis={[
[catalogApiRef, catalogApiMock],
[scaffolderApiRef, scaffolderApiMock],
[formDecoratorsApiRef, formDecoratorsApiMock],
]}
>
<TemplateEditorPage />
@@ -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({
@@ -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 }) => (
<TestApiProvider
apis={[
@@ -76,6 +76,7 @@ describe('useFormDecorators', () => {
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 }) => (
<TestApiProvider
apis={[
@@ -107,6 +108,7 @@ describe('useFormDecorators', () => {
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 }) => (
<TestApiProvider
apis={[
@@ -146,6 +148,7 @@ describe('useFormDecorators', () => {
const { secrets, formState } = await result.run({
formState: { test: 'formState' },
secrets: { test: 'hello' },
manifest,
});
expect(secrets).toEqual({ test: 'hello', new: 'hello' });
@@ -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<void>;
};
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<string, JsonValue>;
secrets: Record<string, string>;
manifest?: TemplateParameterSchema;
}) => {
let formState: Record<string, JsonValue> = { ...opts.formState };
let secrets: Record<string, string> = { ...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],
);
};