feat(scaffolder): promote formDecorators out of experimental (#34180)
* feat(scaffolder): promote formDecorators out of experimental Signed-off-by: benjdlambert <ben@blam.sh> * fix(scaffolder): parse form decorator input through the configured zod schema Signed-off-by: benjdlambert <ben@blam.sh> * refactor(scaffolder-backend): emit single formDecorators field on the parameter-schema response Signed-off-by: benjdlambert <ben@blam.sh> * feat(scaffolder): promote form decorator blueprints to public API Signed-off-by: benjdlambert <ben@blam.sh> --------- Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -630,7 +630,7 @@ const _default: OverridableFrontendPlugin<
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @public (undocumented)
|
||||
export class DefaultScaffolderFormDecoratorsApi
|
||||
implements ScaffolderFormDecoratorsApi
|
||||
{
|
||||
@@ -642,7 +642,7 @@ export class DefaultScaffolderFormDecoratorsApi
|
||||
getFormDecorators(): Promise<ScaffolderFormDecorator[]>;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @public (undocumented)
|
||||
export const formDecoratorsApi: OverridableExtensionDefinition<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
@@ -672,7 +672,7 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @public (undocumented)
|
||||
export const formDecoratorsApiRef: ApiRef<ScaffolderFormDecoratorsApi> & {
|
||||
readonly $$type: '@backstage/ApiRef';
|
||||
};
|
||||
@@ -692,7 +692,7 @@ export type ScaffolderCustomFieldExplorerClassKey =
|
||||
| 'fieldForm'
|
||||
| 'preview';
|
||||
|
||||
// @alpha (undocumented)
|
||||
// @public (undocumented)
|
||||
export interface ScaffolderFormDecoratorsApi {
|
||||
// (undocumented)
|
||||
getFormDecorators(): Promise<ScaffolderFormDecorator[]>;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alph
|
||||
import { formDecoratorsApiRef } from './ref';
|
||||
import { FormDecoratorBlueprint } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
/** @alpha */
|
||||
/** @public */
|
||||
export class DefaultScaffolderFormDecoratorsApi
|
||||
implements ScaffolderFormDecoratorsApi
|
||||
{
|
||||
@@ -46,7 +46,7 @@ export class DefaultScaffolderFormDecoratorsApi
|
||||
}
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
/** @public */
|
||||
export const formDecoratorsApi = ApiBlueprint.makeWithOverrides({
|
||||
name: 'form-decorators',
|
||||
inputs: {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ScaffolderFormDecoratorsApi } from './types';
|
||||
|
||||
/** @alpha */
|
||||
/** @public */
|
||||
export const formDecoratorsApiRef = createApiRef<ScaffolderFormDecoratorsApi>({
|
||||
id: 'plugin.scaffolder.form-decorators',
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
/** @alpha */
|
||||
/** @public */
|
||||
export interface ScaffolderFormDecoratorsApi {
|
||||
getFormDecorators(): Promise<ScaffolderFormDecorator[]>;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ describe('useFormDecorators', () => {
|
||||
});
|
||||
|
||||
const manifest: TemplateParameterSchema = {
|
||||
formDecorators: [{ id: 'test', input: { test: 'hello' } }],
|
||||
steps: [],
|
||||
title: 'test',
|
||||
};
|
||||
|
||||
const legacyManifest: TemplateParameterSchema = {
|
||||
EXPERIMENTAL_formDecorators: [{ id: 'test', input: { test: 'hello' } }],
|
||||
steps: [],
|
||||
title: 'test',
|
||||
@@ -82,6 +88,39 @@ describe('useFormDecorators', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should still run the form decorators when defined under the deprecated EXPERIMENTAL_formDecorators field', async () => {
|
||||
const renderedHook = renderHook(() => useFormDecorators(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[mockApiRef, mockApiImplementation],
|
||||
[
|
||||
formDecoratorsApiRef,
|
||||
DefaultScaffolderFormDecoratorsApi.create({
|
||||
decorators: [mockDecorator],
|
||||
}),
|
||||
],
|
||||
[errorApiRef, { post: () => {} }],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const result = renderedHook.result.current!;
|
||||
|
||||
await result.run({
|
||||
formState: {},
|
||||
secrets: {},
|
||||
manifest: legacyManifest,
|
||||
});
|
||||
|
||||
expect(mockApiImplementation.test).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return existing secrets and formstate', async () => {
|
||||
const renderedHook = renderHook(() => useFormDecorators(), {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -115,6 +154,112 @@ describe('useFormDecorators', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply zod schema defaults to the input when the template omits a field', async () => {
|
||||
const greeted = jest.fn();
|
||||
const greetDecorator = createScaffolderFormDecorator({
|
||||
id: 'greet',
|
||||
schema: {
|
||||
input: {
|
||||
name: z => z.string(),
|
||||
greeting: z => z.string().default('hello'),
|
||||
},
|
||||
},
|
||||
async decorator({ input }) {
|
||||
greeted(input);
|
||||
},
|
||||
});
|
||||
|
||||
const greetManifest: TemplateParameterSchema = {
|
||||
formDecorators: [{ id: 'greet', input: { name: 'world' } }],
|
||||
steps: [],
|
||||
title: 'test',
|
||||
};
|
||||
|
||||
const renderedHook = renderHook(() => useFormDecorators(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
formDecoratorsApiRef,
|
||||
DefaultScaffolderFormDecoratorsApi.create({
|
||||
decorators: [greetDecorator],
|
||||
}),
|
||||
],
|
||||
[errorApiRef, { post: () => {} }],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const result = renderedHook.result.current!;
|
||||
await result.run({
|
||||
formState: {},
|
||||
secrets: {},
|
||||
manifest: greetManifest,
|
||||
});
|
||||
|
||||
expect(greeted).toHaveBeenCalledWith({
|
||||
name: 'world',
|
||||
greeting: 'hello',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should post a validation error and skip the decorator when the input fails the schema', async () => {
|
||||
const decoratorFn = jest.fn();
|
||||
const strictDecorator = createScaffolderFormDecorator({
|
||||
id: 'strict',
|
||||
schema: {
|
||||
input: {
|
||||
count: z => z.number(),
|
||||
},
|
||||
},
|
||||
async decorator(ctx) {
|
||||
decoratorFn(ctx.input);
|
||||
},
|
||||
});
|
||||
|
||||
const post = jest.fn();
|
||||
const strictManifest: TemplateParameterSchema = {
|
||||
formDecorators: [{ id: 'strict', input: { count: 'nope' } }],
|
||||
steps: [],
|
||||
title: 'test',
|
||||
};
|
||||
|
||||
const renderedHook = renderHook(() => useFormDecorators(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
formDecoratorsApiRef,
|
||||
DefaultScaffolderFormDecoratorsApi.create({
|
||||
decorators: [strictDecorator],
|
||||
}),
|
||||
],
|
||||
[errorApiRef, { post }],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const result = renderedHook.result.current!;
|
||||
await result.run({
|
||||
formState: {},
|
||||
secrets: {},
|
||||
manifest: strictManifest,
|
||||
});
|
||||
|
||||
expect(decoratorFn).not.toHaveBeenCalled();
|
||||
expect(post).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow merging of existing secrets and formstate', async () => {
|
||||
const secretAndFormDataModifier = createScaffolderFormDecorator({
|
||||
id: 'test',
|
||||
|
||||
@@ -20,7 +20,8 @@ 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';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { z } from 'zod/v3';
|
||||
|
||||
/** @internal */
|
||||
type BoundFieldDecorator = {
|
||||
@@ -41,8 +42,11 @@ export const useFormDecorators = () => {
|
||||
|
||||
for (const decorator of decorators ?? []) {
|
||||
try {
|
||||
const { decorator: decoratorFn, deps } =
|
||||
OpaqueFormDecorator.toInternal(decorator);
|
||||
const {
|
||||
decorator: decoratorFn,
|
||||
deps,
|
||||
schema,
|
||||
} = OpaqueFormDecorator.toInternal(decorator);
|
||||
|
||||
const resolvedDeps = Object.entries(deps ?? {}).map(([key, value]) => {
|
||||
const api = apiHolder.get(value);
|
||||
@@ -54,8 +58,37 @@ export const useFormDecorators = () => {
|
||||
return [key, api];
|
||||
});
|
||||
|
||||
const inputSchema = schema?.input
|
||||
? z.object(
|
||||
Object.fromEntries(
|
||||
Object.entries(schema.input).map(([key, build]) => [
|
||||
key,
|
||||
build(z),
|
||||
]),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
decoratorsMap.set(decorator.id, {
|
||||
decorator: ctx => decoratorFn(ctx, Object.fromEntries(resolvedDeps)),
|
||||
decorator: async ctx => {
|
||||
let input: JsonObject = ctx.input;
|
||||
if (inputSchema) {
|
||||
const parsed = inputSchema.safeParse(ctx.input);
|
||||
if (!parsed.success) {
|
||||
errorApi.post(
|
||||
new Error(
|
||||
`Invalid input for form decorator ${decorator.id}: ${parsed.error.message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
input = parsed.data as JsonObject;
|
||||
}
|
||||
await decoratorFn(
|
||||
{ ...ctx, input },
|
||||
Object.fromEntries(resolvedDeps),
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (ex) {
|
||||
errorApi.post(ex);
|
||||
@@ -74,7 +107,9 @@ export const useFormDecorators = () => {
|
||||
let formState: Record<string, JsonValue> = { ...opts.formState };
|
||||
let secrets: Record<string, string> = { ...opts.secrets };
|
||||
|
||||
const formDecorators = opts.manifest?.EXPERIMENTAL_formDecorators;
|
||||
const formDecorators =
|
||||
opts.manifest?.formDecorators ??
|
||||
opts.manifest?.EXPERIMENTAL_formDecorators;
|
||||
if (formDecorators) {
|
||||
// for each of the form decorators, go and call the decorator with the context
|
||||
await Promise.all(
|
||||
|
||||
Reference in New Issue
Block a user