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:
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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