scaffolder: add form decorator support to template editor
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
+24
-12
@@ -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 />
|
||||
|
||||
+23
-11
@@ -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 />
|
||||
|
||||
+7
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user