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:
Ben Lambert
2026-05-11 11:29:36 +02:00
committed by GitHub
parent 044ba27248
commit 8006acf89a
19 changed files with 283 additions and 22 deletions
@@ -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.
@@ -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.
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder': minor
---
Promoted `formDecoratorsApiRef`, `ScaffolderFormDecoratorsApi`,
`DefaultScaffolderFormDecoratorsApi`, and `formDecoratorsApi` from `@alpha`
to `@public`.
@@ -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.
+7
View File
@@ -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.
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-react': minor
---
Promoted `FormDecoratorBlueprint` and `ScaffolderFormDecorator` from `@alpha`
to `@public`.
@@ -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) {
+8
View File
@@ -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;
@@ -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."
}
}
}
@@ -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 }[];
};
+2 -2
View File
@@ -128,7 +128,7 @@ export const Form: (
props: PropsWithChildren<ScaffolderRJSFFormProps>,
) => 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<TInput extends JsonObject = JsonObject> = {
readonly $$type: '@backstage/scaffolder/FormDecorator';
readonly id: string;
@@ -25,7 +25,7 @@ const formDecoratorExtensionDataRef =
});
/**
* @alpha
* @public
* Creates extensions that are Field Extensions for the Scaffolder
* */
export const FormDecoratorBlueprint = createExtensionBlueprint({
@@ -33,7 +33,7 @@ export type ScaffolderFormDecoratorContext<
) => void;
};
/** @alpha */
/** @public */
export type ScaffolderFormDecorator<TInput extends JsonObject = JsonObject> = {
readonly $$type: '@backstage/scaffolder/FormDecorator';
readonly id: string;
+4 -4
View File
@@ -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: {
+1 -1
View File
@@ -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',
});
+1 -1
View File
@@ -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(