From 8006acf89a7d892b7eb9724aae2a002c9cafd93f Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Mon, 11 May 2026 11:29:36 +0200 Subject: [PATCH] feat(scaffolder): promote `formDecorators` out of experimental (#34180) * feat(scaffolder): promote formDecorators out of experimental Signed-off-by: benjdlambert * fix(scaffolder): parse form decorator input through the configured zod schema Signed-off-by: benjdlambert * refactor(scaffolder-backend): emit single formDecorators field on the parameter-schema response Signed-off-by: benjdlambert * feat(scaffolder): promote form decorator blueprints to public API Signed-off-by: benjdlambert --------- Signed-off-by: benjdlambert --- .../scaffolder-backend-form-decorators.md | 8 + .../scaffolder-common-form-decorators.md | 7 + .../scaffolder-form-decorator-api-public.md | 7 + .../scaffolder-form-decorator-input-schema.md | 8 + .changeset/scaffolder-form-decorators.md | 7 + .../scaffolder-react-form-decorator-public.md | 6 + .../scaffolder-backend/src/service/router.ts | 3 +- plugins/scaffolder-common/report.api.md | 8 + .../src/Template.v1beta3.schema.json | 24 ++- .../src/TemplateEntityV1beta3.ts | 13 +- plugins/scaffolder-react/report-alpha.api.md | 4 +- .../blueprints/FormDecoratorBlueprint.tsx | 2 +- .../createScaffolderFormDecorator.ts | 2 +- plugins/scaffolder/report-alpha.api.md | 8 +- .../src/alpha/api/FormDecoratorsApi.ts | 4 +- plugins/scaffolder/src/alpha/api/ref.ts | 2 +- plugins/scaffolder/src/alpha/api/types.ts | 2 +- .../alpha/hooks/useFormDecorators.test.tsx | 145 ++++++++++++++++++ .../src/alpha/hooks/useFormDecorators.ts | 45 +++++- 19 files changed, 283 insertions(+), 22 deletions(-) create mode 100644 .changeset/scaffolder-backend-form-decorators.md create mode 100644 .changeset/scaffolder-common-form-decorators.md create mode 100644 .changeset/scaffolder-form-decorator-api-public.md create mode 100644 .changeset/scaffolder-form-decorator-input-schema.md create mode 100644 .changeset/scaffolder-form-decorators.md create mode 100644 .changeset/scaffolder-react-form-decorator-public.md diff --git a/.changeset/scaffolder-backend-form-decorators.md b/.changeset/scaffolder-backend-form-decorators.md new file mode 100644 index 0000000000..218c470fba --- /dev/null +++ b/.changeset/scaffolder-backend-form-decorators.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +The template parameter schema response now exposes a `formDecorators` field +instead of `EXPERIMENTAL_formDecorators`. Templates that still declare +`spec.EXPERIMENTAL_formDecorators` are read transparently and surfaced under +the new field. diff --git a/.changeset/scaffolder-common-form-decorators.md b/.changeset/scaffolder-common-form-decorators.md new file mode 100644 index 0000000000..1f52da0950 --- /dev/null +++ b/.changeset/scaffolder-common-form-decorators.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder-common': minor +--- + +Promote the `formDecorators` field on the `Template` spec out of experimental. +The previous `EXPERIMENTAL_formDecorators` field continues to work and is +kept as a deprecated alias. diff --git a/.changeset/scaffolder-form-decorator-api-public.md b/.changeset/scaffolder-form-decorator-api-public.md new file mode 100644 index 0000000000..6e9135998e --- /dev/null +++ b/.changeset/scaffolder-form-decorator-api-public.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Promoted `formDecoratorsApiRef`, `ScaffolderFormDecoratorsApi`, +`DefaultScaffolderFormDecoratorsApi`, and `formDecoratorsApi` from `@alpha` +to `@public`. diff --git a/.changeset/scaffolder-form-decorator-input-schema.md b/.changeset/scaffolder-form-decorator-input-schema.md new file mode 100644 index 0000000000..ac48e2e3e1 --- /dev/null +++ b/.changeset/scaffolder-form-decorator-input-schema.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Form decorator input is now parsed against the zod schema configured on the +decorator before the decorator runs, so defaults declared via `.default()` +are applied and invalid input is reported through the error API instead of +silently passing through. diff --git a/.changeset/scaffolder-form-decorators.md b/.changeset/scaffolder-form-decorators.md new file mode 100644 index 0000000000..c9f2695a88 --- /dev/null +++ b/.changeset/scaffolder-form-decorators.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +The template wizard now reads form decorators from the new +`spec.formDecorators` field on a template, falling back to the deprecated +`spec.EXPERIMENTAL_formDecorators` for templates that have not been migrated. diff --git a/.changeset/scaffolder-react-form-decorator-public.md b/.changeset/scaffolder-react-form-decorator-public.md new file mode 100644 index 0000000000..2661482613 --- /dev/null +++ b/.changeset/scaffolder-react-form-decorator-public.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +Promoted `FormDecoratorBlueprint` and `ScaffolderFormDecorator` from `@alpha` +to `@public`. diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 623067eddc..1ec36989c5 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -481,7 +481,8 @@ export async function createRouter( description: schema.description as string, schema, })), - EXPERIMENTAL_formDecorators: + formDecorators: + template.spec.formDecorators ?? template.spec.EXPERIMENTAL_formDecorators, }); } catch (err) { diff --git a/plugins/scaffolder-common/report.api.md b/plugins/scaffolder-common/report.api.md index 2c6c928920..0f8060deb6 100644 --- a/plugins/scaffolder-common/report.api.md +++ b/plugins/scaffolder-common/report.api.md @@ -415,6 +415,10 @@ export interface TemplateEntityV1beta3 extends Entity { type: string; presentation?: TemplatePresentationV1beta3; EXPERIMENTAL_recovery?: TemplateRecoveryV1beta3; + formDecorators?: { + id: string; + input?: JsonObject; + }[]; EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject; @@ -481,6 +485,10 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + formDecorators?: { + id: string; + input?: JsonObject; + }[]; EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject; diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index 683115bf78..718ab0a621 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -190,7 +190,7 @@ } } }, - "EXPERIMENTAL_formDecorators": { + "formDecorators": { "type": "array", "description": "A list of decorators and their inputs that the form should trigger before submitting the job", "items": { @@ -198,11 +198,29 @@ "properties": { "id": { "type": "string", - "description": "The form hook ID" + "description": "The form decorator ID" }, "input": { "type": "object", - "description": "A object describing the inputs to the form hook." + "description": "An object describing the inputs to the form decorator." + } + } + } + }, + "EXPERIMENTAL_formDecorators": { + "type": "array", + "deprecated": true, + "description": "Deprecated, use formDecorators instead.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The form decorator ID" + }, + "input": { + "type": "object", + "description": "An object describing the inputs to the form decorator." } } } diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index d48049946c..feba4e0796 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -57,7 +57,14 @@ export interface TemplateEntityV1beta3 extends Entity { EXPERIMENTAL_recovery?: TemplateRecoveryV1beta3; /** - * Form hooks to be run + * Form decorators to be run before submission. + */ + formDecorators?: { id: string; input?: JsonObject }[]; + + /** + * Form decorators to be run before submission. + * + * @deprecated Use `spec.formDecorators` instead. */ EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[]; @@ -164,6 +171,10 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + formDecorators?: { id: string; input?: JsonObject }[]; + /** + * @deprecated Use {@link TemplateParameterSchema.formDecorators} instead. + */ EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[]; }; diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 80b9d9bde3..23b978ead8 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -128,7 +128,7 @@ export const Form: ( props: PropsWithChildren, ) => JSX_2.Element; -// @alpha +// @public export const FormDecoratorBlueprint: ExtensionBlueprint<{ kind: 'scaffolder-form-decorator'; params: { @@ -257,7 +257,7 @@ export interface ScaffolderFieldProps { required?: boolean; } -// @alpha (undocumented) +// @public (undocumented) export type ScaffolderFormDecorator = { readonly $$type: '@backstage/scaffolder/FormDecorator'; readonly id: string; diff --git a/plugins/scaffolder-react/src/next/blueprints/FormDecoratorBlueprint.tsx b/plugins/scaffolder-react/src/next/blueprints/FormDecoratorBlueprint.tsx index 436b1df85c..66b3ffb5b5 100644 --- a/plugins/scaffolder-react/src/next/blueprints/FormDecoratorBlueprint.tsx +++ b/plugins/scaffolder-react/src/next/blueprints/FormDecoratorBlueprint.tsx @@ -25,7 +25,7 @@ const formDecoratorExtensionDataRef = }); /** - * @alpha + * @public * Creates extensions that are Field Extensions for the Scaffolder * */ export const FormDecoratorBlueprint = createExtensionBlueprint({ diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts index 51963072b4..ae5fb859a6 100644 --- a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -33,7 +33,7 @@ export type ScaffolderFormDecoratorContext< ) => void; }; -/** @alpha */ +/** @public */ export type ScaffolderFormDecorator = { readonly $$type: '@backstage/scaffolder/FormDecorator'; readonly id: string; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 10066f3fd3..f6ca89a215 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -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; } -// @alpha (undocumented) +// @public (undocumented) export const formDecoratorsApi: OverridableExtensionDefinition<{ config: {}; configInput: {}; @@ -672,7 +672,7 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{ ) => ExtensionBlueprintParams; }>; -// @alpha (undocumented) +// @public (undocumented) export const formDecoratorsApiRef: ApiRef & { readonly $$type: '@backstage/ApiRef'; }; @@ -692,7 +692,7 @@ export type ScaffolderCustomFieldExplorerClassKey = | 'fieldForm' | 'preview'; -// @alpha (undocumented) +// @public (undocumented) export interface ScaffolderFormDecoratorsApi { // (undocumented) getFormDecorators(): Promise; diff --git a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts index fc15078055..1fd7d639aa 100644 --- a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts +++ b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts @@ -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: { diff --git a/plugins/scaffolder/src/alpha/api/ref.ts b/plugins/scaffolder/src/alpha/api/ref.ts index 532669d0ad..f096cf8c5b 100644 --- a/plugins/scaffolder/src/alpha/api/ref.ts +++ b/plugins/scaffolder/src/alpha/api/ref.ts @@ -17,7 +17,7 @@ import { createApiRef } from '@backstage/frontend-plugin-api'; import { ScaffolderFormDecoratorsApi } from './types'; -/** @alpha */ +/** @public */ export const formDecoratorsApiRef = createApiRef({ id: 'plugin.scaffolder.form-decorators', }); diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts index 1d2d331adf..11268580c0 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -16,7 +16,7 @@ import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; -/** @alpha */ +/** @public */ export interface ScaffolderFormDecoratorsApi { getFormDecorators(): Promise; } diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx index 444f53985a..08bfd34002 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -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 }) => ( + {} }], + ]} + > + {children} + + ), + }); + + 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 }) => ( + {} }], + ]} + > + {children} + + ), + }); + + 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 }) => ( + + {children} + + ), + }); + + 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', diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts index a27d47fb59..537c0c2783 100644 --- a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -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 = { ...opts.formState }; let secrets: Record = { ...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(