diff --git a/.changeset/fluffy-zebras-cheer.md b/.changeset/fluffy-zebras-cheer.md new file mode 100644 index 0000000000..308f12d10a --- /dev/null +++ b/.changeset/fluffy-zebras-cheer.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-common': patch +'@backstage/plugin-scaffolder-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Experimental support for `formDecorators` to enable secret collection and mutations to the parameters for scaffolder tasks diff --git a/docs/features/software-templates/experimental.md b/docs/features/software-templates/experimental.md new file mode 100644 index 0000000000..e37971ad82 --- /dev/null +++ b/docs/features/software-templates/experimental.md @@ -0,0 +1,87 @@ +--- +id: experimental +title: Experimental Features +# prettier-ignore +description: Information on Experimental Features that are currently available in the Scaffolder +--- + +## Introduction + +This section contains information and guides on the experimental features that are currently available in the Scaffolder. Be advised that these features are still in development and may not be fully stable or complete, and are subject to change at any time. + +Please leave feedback on these features in the [Backstage Discord](https://discord.com/invite/MUpMjP2) or by [creating an issue](https://github.com/backstage/backstage/issues/new/choose) on the Backstage GitHub repository. + +## Retries and Recovery + +### TODO + +## Form Decorators + +Form decorators provide the ability to run arbitrary code before the form is submitted along with secrets to the `scaffolder-backend` plugin. They are provided to the `app` using a Utility API. + +#### Installation + +To install the Form Decorators, add the following to your `packages/app/src/apis.ts`: + +```ts + createApiFactory({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => + DefaultScaffolderFormDecoratorsApi.create({ + decorators: [ + // add decorators here + ], + }), + }), +``` + +And then you'll also need to define which decorators run in each template using the `EXPERIMENTAL_formDecorators` key in the template's `spec`: + +```yaml +kind: Template +metadata: + name: my-template +spec: + EXPERIMENTAL_formDecorators: + - id: my-decorator + input: + test: something funky + + parameters: ... + steps: ... +``` + +#### Creating a Decorator + +You can create a decorator using the simple helper method `createScaffolderFormDecorator`: + +```ts +export const mockDecorator = createScaffolderFormDecorator({ + // give the decorator a name + id: 'mock-decorator', + + // define the schema for the input that can be proided in `template.yaml` + schema: { + input: { + test: z => z.string(), + }, + }, + deps: { + // define dependencies here + githubApi: githubAuthApiRef, + }, + decorator: async ( + // Context has all the things needed to write simple decorators + { setSecrets, setFormState, input: { test } }, + // Depepdencies injected here + { githubApi }, + ) => { + // mutate the form state + setFormState(state => ({ ...state, test, mock: 'MOCK' })); + + // mutate the form secrets + setSecrets(state => ({ ...state, GITHUB_TOKEN: 'MOCK_TOKEN' })); + }, +}); +``` diff --git a/microsite/sidebars.js b/microsite/sidebars.js index 61329f4985..2647143065 100644 --- a/microsite/sidebars.js +++ b/microsite/sidebars.js @@ -101,6 +101,7 @@ module.exports = { 'features/software-templates/migrating-to-rjsf-v5', 'features/software-templates/migrating-from-v1beta2-to-v1beta3', 'features/software-templates/dry-run-testing', + 'features/software-templates/experimental', ], }, { diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 0c2ccbad22..e39122337f 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,6 +26,9 @@ import { discoveryApiRef, } from '@backstage/core-plugin-api'; import { AuthProxyDiscoveryApi } from './AuthProxyDiscoveryApi'; +import { formDecoratorsApiRef } from '@backstage/plugin-scaffolder/alpha'; +import { DefaultScaffolderFormDecoratorsApi } from '@backstage/plugin-scaffolder/alpha'; +import { mockDecorator } from './components/scaffolder/decorators'; export const apis: AnyApiFactory[] = [ createApiFactory({ @@ -39,5 +42,14 @@ export const apis: AnyApiFactory[] = [ factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), + createApiFactory({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => + DefaultScaffolderFormDecoratorsApi.create({ + decorators: [mockDecorator], + }), + }), + ScmAuth.createDefaultApiFactory(), ]; diff --git a/packages/app/src/components/scaffolder/decorators.ts b/packages/app/src/components/scaffolder/decorators.ts new file mode 100644 index 0000000000..ffb78562f8 --- /dev/null +++ b/packages/app/src/components/scaffolder/decorators.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { githubAuthApiRef } from '@backstage/core-plugin-api'; +import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; + +export const mockDecorator = createScaffolderFormDecorator({ + id: 'mock-decorator', + schema: { + input: { + test: z => z.string(), + }, + }, + deps: { + githubApi: githubAuthApiRef, + }, + decorator: async ( + { setSecrets, setFormState, input: { test } }, + { githubApi: _githubApi }, + ) => { + setFormState(state => ({ ...state, test, mock: 'MOCK' })); + setSecrets(state => ({ ...state, GITHUB_TOKEN: 'MOCK_TOKEN' })); + }, +}); diff --git a/packages/scaffolder-internal/package.json b/packages/scaffolder-internal/package.json index d07614f127..9350fa7ac1 100644 --- a/packages/scaffolder-internal/package.json +++ b/packages/scaffolder-internal/package.json @@ -23,6 +23,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-scaffolder-react": "workspace:^", "zod": "^3.22.4" }, diff --git a/packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts b/packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts new file mode 100644 index 0000000000..7942544acb --- /dev/null +++ b/packages/scaffolder-internal/src/wiring/InternalFormDecorator.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { OpaqueType } from '@internal/opaque'; +import { z } from 'zod'; + +import { + ScaffolderFormDecorator, + ScaffolderFormDecoratorContext, +} from '@backstage/plugin-scaffolder-react/alpha'; +import { AnyApiRef } from '@backstage/frontend-plugin-api'; + +/** @alpha */ +export const OpaqueFormDecorator = OpaqueType.create<{ + public: ScaffolderFormDecorator; + versions: { + readonly version: 'v1'; + readonly id: string; + readonly schema?: { + input?: { + [key in string]: (zImpl: typeof z) => z.ZodType; + }; + }; + readonly deps?: { [key in string]: AnyApiRef }; + readonly decorator: ( + ctx: ScaffolderFormDecoratorContext, + deps: { [depName in string]: AnyApiRef['T'] }, + ) => Promise; + }; +}>({ type: '@backstage/scaffolder/FormDecorator', versions: ['v1'] }); diff --git a/packages/scaffolder-internal/src/wiring/index.ts b/packages/scaffolder-internal/src/wiring/index.ts index c85755bccf..37c8bd09cb 100644 --- a/packages/scaffolder-internal/src/wiring/index.ts +++ b/packages/scaffolder-internal/src/wiring/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { OpaqueFormField, type FormField } from './InternalFormField'; +export { OpaqueFormDecorator } from './InternalFormDecorator'; diff --git a/plugins/catalog-import/report-alpha.api.md b/plugins/catalog-import/report-alpha.api.md index 1706404b94..c13f7a1a4a 100644 --- a/plugins/catalog-import/report-alpha.api.md +++ b/plugins/catalog-import/report-alpha.api.md @@ -18,21 +18,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:catalog-import': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:catalog-import': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -63,6 +48,21 @@ const _default: FrontendPlugin< routeRef?: RouteRef | undefined; }; }>; + 'api:catalog-import': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; } >; export default _default; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 1b1ce54d92..3a9f0c6c33 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -146,21 +146,6 @@ const _default: FrontendPlugin< unregisterRedirect: ExternalRouteRef; }, { - 'api:catalog': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'nav-item:catalog': ExtensionDefinition<{ kind: 'nav-item'; name: undefined; @@ -182,6 +167,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:catalog': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'api:catalog/starred-entities': ExtensionDefinition<{ kind: 'api'; name: 'starred-entities'; diff --git a/plugins/devtools/report-alpha.api.md b/plugins/devtools/report-alpha.api.md index 1b026d1f78..e3261e0f05 100644 --- a/plugins/devtools/report-alpha.api.md +++ b/plugins/devtools/report-alpha.api.md @@ -19,21 +19,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:devtools': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:devtools': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -85,6 +70,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:devtools': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; } >; export default _default; diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 9ba748577f..4f779df258 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -21,21 +21,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:kubernetes': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:kubernetes': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -62,6 +47,21 @@ const _default: FrontendPlugin< routeRef?: RouteRef | undefined; }; }>; + 'api:kubernetes': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'entity-content:kubernetes/kubernetes': ExtensionDefinition<{ kind: 'entity-content'; name: 'kubernetes'; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 261beb1f56..3bde8d19ae 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -475,6 +475,8 @@ export async function createRouter( description: schema.description, schema, })), + EXPERIMENTAL_formDecorators: + template.spec.EXPERIMENTAL_formDecorators, }); }, ) diff --git a/plugins/scaffolder-common/report.api.md b/plugins/scaffolder-common/report.api.md index 8d7a61910d..3aa7e08121 100644 --- a/plugins/scaffolder-common/report.api.md +++ b/plugins/scaffolder-common/report.api.md @@ -77,6 +77,10 @@ export interface TemplateEntityV1beta3 extends Entity { type: string; presentation?: TemplatePresentationV1beta3; EXPERIMENTAL_recovery?: TemplateRecoveryV1beta3; + EXPERIMENTAL_formDecorators?: { + id: string; + input?: JsonObject; + }[]; parameters?: TemplateParametersV1beta3 | TemplateParametersV1beta3[]; steps: Array; output?: { diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json index 4e34545d6d..58f8ef240a 100644 --- a/plugins/scaffolder-common/src/Template.v1beta3.schema.json +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -173,6 +173,23 @@ } } }, + "EXPERIMENTAL_formDecorators": { + "type": "array", + "description": "A list of decorators and their inputs that the form should trigger before submitting the job", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The form hook ID" + }, + "input": { + "type": "object", + "description": "A object describing the inputs to the form hook." + } + } + } + }, "steps": { "type": "array", "description": "A list of steps to execute.", diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts index 0e2fec4264..c5b576b5c2 100644 --- a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -56,6 +56,11 @@ export interface TemplateEntityV1beta3 extends Entity { */ EXPERIMENTAL_recovery?: TemplateRecoveryV1beta3; + /** + * Form hooks to be run + */ + EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[]; + /** * This is a JSONSchema or an array of JSONSchema's which is used to render a form in the frontend * to collect user input and validate it against that schema. This can then be used in the `steps` part below to template diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 7084625ad4..2cc8416b3c 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts /// +import { AnyApiRef } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; @@ -75,6 +76,39 @@ export function createFormField< TUiOptions extends z.ZodType, >(opts: FormFieldExtensionData): FormField; +// @alpha +export function createScaffolderFormDecorator< + TInputSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + } = { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + TDeps extends { + [key in string]: AnyApiRef; + } = { + [key in string]: AnyApiRef; + }, + TInput extends JsonObject = { + [key in keyof TInputSchema]: z.infer>; + }, +>(options: { + id: string; + schema?: { + input?: TInputSchema; + }; + deps?: TDeps; + decorator: ( + ctx: ScaffolderFormDecoratorContext, + deps: TDeps extends { + [key in string]: AnyApiRef; + } + ? { + [key in keyof TDeps]: TDeps[key]['T']; + } + : never, + ) => Promise; +}): ScaffolderFormDecorator; + // @alpha export const DefaultTemplateOutputs: (props: { output?: ScaffolderTaskOutput; @@ -190,6 +224,27 @@ export interface ScaffolderFieldProps { required?: boolean; } +// @alpha (undocumented) +export type ScaffolderFormDecorator = { + readonly $$type: '@backstage/scaffolder/FormDecorator'; + readonly id: string; + readonly TInput: TInput; +}; + +// @alpha (undocumented) +export type ScaffolderFormDecoratorContext< + TInput extends JsonObject = JsonObject, +> = { + input: TInput; + formState: Record; + setFormState: ( + fn: (currentState: Record) => Record, + ) => void; + setSecrets: ( + fn: (currentState: Record) => Record, + ) => void; +}; + // @alpha (undocumented) export function ScaffolderPageContextMenu( props: ScaffolderPageContextMenuProps, @@ -346,9 +401,9 @@ export const useFormDataFromQuery: ( // @alpha (undocumented) export const useTemplateParameterSchema: (templateRef: string) => { - manifest: TemplateParameterSchema | undefined; + manifest?: TemplateParameterSchema | undefined; loading: boolean; - error: Error | undefined; + error?: Error | undefined; }; // @alpha diff --git a/plugins/scaffolder-react/report.api.md b/plugins/scaffolder-react/report.api.md index 5523b52ad9..1ac2fd279b 100644 --- a/plugins/scaffolder-react/report.api.md +++ b/plugins/scaffolder-react/report.api.md @@ -538,6 +538,10 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + EXPERIMENTAL_formDecorators?: { + id: string; + input?: JsonObject; + }[]; }; // @public diff --git a/plugins/scaffolder-react/src/extensions/index.tsx b/plugins/scaffolder-react/src/extensions/createScaffolderFieldExtension.tsx similarity index 100% rename from plugins/scaffolder-react/src/extensions/index.tsx rename to plugins/scaffolder-react/src/extensions/createScaffolderFieldExtension.tsx diff --git a/plugins/scaffolder-react/src/extensions/index.ts b/plugins/scaffolder-react/src/extensions/index.ts new file mode 100644 index 0000000000..874fa607fb --- /dev/null +++ b/plugins/scaffolder-react/src/extensions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './createScaffolderFieldExtension'; diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts new file mode 100644 index 0000000000..d9bba3e34c --- /dev/null +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createScaffolderFormDecorator } from './createScaffolderFormDecorator'; + +describe('createScaffolderFormDecorator', () => { + it('should return a decorator', () => { + const decorator = createScaffolderFormDecorator({ + id: 'test', + deps: {}, + decorator: async () => {}, + }); + + expect(decorator).toMatchInlineSnapshot(` + { + "$$type": "@backstage/scaffolder/FormDecorator", + "TInput": null, + "decorator": [Function], + "deps": {}, + "id": "test", + "version": "v1", + } + `); + }); + + it('should allow passing schema and be typesafe', () => { + const decorator = createScaffolderFormDecorator({ + id: 'test', + deps: {}, + schema: { + input: { + name: z => z.string(), + age: z => z.number(), + }, + }, + decorator: async ctx => { + const name: string = ctx.input.name; + + // @ts-expect-error + const number: string = ctx.input.age; + + expect([name, number]).toBeDefined(); + }, + }); + + expect(decorator).toMatchInlineSnapshot(` + { + "$$type": "@backstage/scaffolder/FormDecorator", + "TInput": null, + "decorator": [Function], + "deps": {}, + "id": "test", + "schema": { + "input": { + "age": [Function], + "name": [Function], + }, + }, + "version": "v1", + } + `); + }); +}); diff --git a/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts new file mode 100644 index 0000000000..0064ae0397 --- /dev/null +++ b/plugins/scaffolder-react/src/next/extensions/createScaffolderFormDecorator.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AnyApiRef } from '@backstage/core-plugin-api'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { OpaqueFormDecorator } from '@internal/scaffolder'; +import { z } from 'zod'; + +/** @alpha */ +export type ScaffolderFormDecoratorContext< + TInput extends JsonObject = JsonObject, +> = { + input: TInput; + formState: Record; + + setFormState: ( + fn: (currentState: Record) => Record, + ) => void; + setSecrets: ( + fn: (currentState: Record) => Record, + ) => void; +}; + +/** @alpha */ +export type ScaffolderFormDecorator = { + readonly $$type: '@backstage/scaffolder/FormDecorator'; + readonly id: string; + readonly TInput: TInput; +}; + +/** + * Method for creating decorators which can be used to collect + * secrets from the user before submitting to the backend. + * @alpha + */ +export function createScaffolderFormDecorator< + TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef }, + TInput extends JsonObject = { + [key in keyof TInputSchema]: z.infer>; + }, +>(options: { + id: string; + schema?: { + input?: TInputSchema; + }; + deps?: TDeps; + decorator: ( + ctx: ScaffolderFormDecoratorContext, + deps: TDeps extends { [key in string]: AnyApiRef } + ? { [key in keyof TDeps]: TDeps[key]['T'] } + : never, + ) => Promise; +}): ScaffolderFormDecorator { + return OpaqueFormDecorator.createInstance('v1', { + ...options, + TInput: null as unknown as TInput, + } as { + id: string; + schema?: { + input?: TInputSchema; + }; + TInput: TInput; + deps?: TDeps; + decorator: ( + ctx: ScaffolderFormDecoratorContext, + deps: { [key in string]: AnyApiRef['T'] }, + ) => Promise; + }); +} diff --git a/plugins/scaffolder-react/src/next/extensions/index.ts b/plugins/scaffolder-react/src/next/extensions/index.ts new file mode 100644 index 0000000000..73bfa749f6 --- /dev/null +++ b/plugins/scaffolder-react/src/next/extensions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './createScaffolderFormDecorator'; diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts index 4b4f21ae71..452e3a67a8 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateParameterSchema.ts @@ -22,15 +22,21 @@ import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; /** * @alpha */ -export const useTemplateParameterSchema = (templateRef: string) => { +export const useTemplateParameterSchema = ( + templateRef: string, +): { manifest?: TemplateParameterSchema; loading: boolean; error?: Error } => { const scaffolderApi = useApi(scaffolderApiRef); - const { value, loading, error } = useAsync( + const { + value: manifest, + loading, + error, + } = useAsync( () => scaffolderApi.getTemplateParameterSchema(templateRef), [scaffolderApi, templateRef], ); return { - manifest: value as TemplateParameterSchema | undefined, + manifest, loading, error, }; diff --git a/plugins/scaffolder-react/src/next/index.ts b/plugins/scaffolder-react/src/next/index.ts index 07f4565166..c4e94f36a3 100644 --- a/plugins/scaffolder-react/src/next/index.ts +++ b/plugins/scaffolder-react/src/next/index.ts @@ -18,3 +18,4 @@ export * from './lib'; export * from './hooks'; export * from './overridableComponents'; export * from './blueprints'; +export * from './extensions'; diff --git a/plugins/scaffolder-react/src/types.ts b/plugins/scaffolder-react/src/types.ts index c1c26f32bb..5f557198c6 100644 --- a/plugins/scaffolder-react/src/types.ts +++ b/plugins/scaffolder-react/src/types.ts @@ -33,4 +33,5 @@ export type TemplateParameterSchema = { description?: string; schema: JsonObject; }>; + EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[]; }; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 4253b2b914..15ec32fa49 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -7,12 +7,14 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; +import { ApiRef } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { FormField } from '@internal/scaffolder'; +import { FormFieldExtensionData } from '@backstage/plugin-scaffolder-react/alpha'; import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -23,6 +25,7 @@ import { PathParams } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { RouteRef } from '@backstage/frontend-plugin-api'; +import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; import { SubRouteRef } from '@backstage/frontend-plugin-api'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; @@ -49,21 +52,6 @@ const _default: FrontendPlugin< }>; }, { - 'api:scaffolder': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:scaffolder': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -126,6 +114,21 @@ const _default: FrontendPlugin< field: () => Promise; }; }>; + 'api:scaffolder': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'api:scaffolder/form-fields': ExtensionDefinition<{ config: {}; configInput: {}; @@ -157,6 +160,24 @@ const _default: FrontendPlugin< >; export default _default; +// @alpha (undocumented) +export class DefaultScaffolderFormDecoratorsApi + implements ScaffolderFormDecoratorsApi +{ + // (undocumented) + static create(options?: { + decorators: ScaffolderFormDecorator[]; + }): DefaultScaffolderFormDecoratorsApi; + // (undocumented) + getFormDecorators(): Promise; +} + +// @alpha (undocumented) +export const formDecoratorsApiRef: ApiRef; + +// @alpha (undocumented) +export const formFieldsApiRef: ApiRef; + // @alpha @deprecated export type FormProps = Pick< FormProps_2, @@ -170,6 +191,18 @@ export type ScaffolderCustomFieldExplorerClassKey = | 'fieldForm' | 'preview'; +// @alpha (undocumented) +export interface ScaffolderFormDecoratorsApi { + // (undocumented) + getFormDecorators(): Promise; +} + +// @alpha (undocumented) +export interface ScaffolderFormFieldsApi { + // (undocumented) + getFormFields(): Promise; +} + // @public (undocumented) export type ScaffolderTemplateEditorClassKey = | 'root' diff --git a/plugins/scaffolder/src/alpha/api.tsx b/plugins/scaffolder/src/alpha/api.tsx deleted file mode 100644 index 06364cd6e9..0000000000 --- a/plugins/scaffolder/src/alpha/api.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - ApiBlueprint, - createApiFactory, - discoveryApiRef, - fetchApiRef, - identityApiRef, -} from '@backstage/frontend-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import { ScaffolderClient } from '../api'; - -export const scaffolderApi = ApiBlueprint.make({ - params: { - factory: createApiFactory({ - api: scaffolderApiRef, - deps: { - discoveryApi: discoveryApiRef, - scmIntegrationsApi: scmIntegrationsApiRef, - fetchApi: fetchApiRef, - identityApi: identityApiRef, - }, - factory: ({ discoveryApi, scmIntegrationsApi, fetchApi, identityApi }) => - new ScaffolderClient({ - discoveryApi, - scmIntegrationsApi, - fetchApi, - identityApi, - }), - }), - }, -}); diff --git a/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts new file mode 100644 index 0000000000..13c4b6d1a2 --- /dev/null +++ b/plugins/scaffolder/src/alpha/api/FormDecoratorsApi.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScaffolderFormDecoratorsApi } from './types'; +import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; + +/** @alpha */ +export class DefaultScaffolderFormDecoratorsApi + implements ScaffolderFormDecoratorsApi +{ + private constructor( + private readonly options: { + decorators: Array; + }, + ) {} + + static create(options?: { decorators: ScaffolderFormDecorator[] }) { + return new DefaultScaffolderFormDecoratorsApi( + options ?? { decorators: [] }, + ); + } + + async getFormDecorators(): Promise { + return this.options.decorators; + } +} diff --git a/plugins/scaffolder/src/alpha/api/index.ts b/plugins/scaffolder/src/alpha/api/index.ts index bbc30ef50b..0afefe17a5 100644 --- a/plugins/scaffolder/src/alpha/api/index.ts +++ b/plugins/scaffolder/src/alpha/api/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { formFieldsApiRef } from './ref'; -export type { ScaffolderFormFieldsApi } from './types'; +export { formFieldsApiRef, formDecoratorsApiRef } from './ref'; +export type { + ScaffolderFormFieldsApi, + ScaffolderFormDecoratorsApi, +} from './types'; +export { DefaultScaffolderFormDecoratorsApi } from './FormDecoratorsApi'; diff --git a/plugins/scaffolder/src/alpha/api/ref.ts b/plugins/scaffolder/src/alpha/api/ref.ts index 52bc44e674..9890a9a9b2 100644 --- a/plugins/scaffolder/src/alpha/api/ref.ts +++ b/plugins/scaffolder/src/alpha/api/ref.ts @@ -15,8 +15,14 @@ */ import { createApiRef } from '@backstage/frontend-plugin-api'; -import { ScaffolderFormFieldsApi } from './types'; +import { ScaffolderFormFieldsApi, ScaffolderFormDecoratorsApi } from './types'; +/** @alpha */ export const formFieldsApiRef = createApiRef({ id: 'plugin.scaffolder.form-fields', }); + +/** @alpha */ +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 81f08b18e2..10f97a6a10 100644 --- a/plugins/scaffolder/src/alpha/api/types.ts +++ b/plugins/scaffolder/src/alpha/api/types.ts @@ -14,8 +14,17 @@ * limitations under the License. */ -import { FormFieldExtensionData } from '@backstage/plugin-scaffolder-react/alpha'; +import { + FormFieldExtensionData, + ScaffolderFormDecorator, +} from '@backstage/plugin-scaffolder-react/alpha'; +/** @alpha */ export interface ScaffolderFormFieldsApi { getFormFields(): Promise; } + +/** @alpha */ +export interface ScaffolderFormDecoratorsApi { + getFormDecorators(): Promise; +} diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx index 18211d185a..7d978e5c38 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -33,6 +33,8 @@ import { rootRouteRef } from '../../../routes'; import { ANNOTATION_EDIT_URL } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { ScaffolderFormDecoratorsApi } from '../../api/types'; +import { formDecoratorsApiRef } from '../../api/ref'; jest.mock('react-router-dom', () => { return { @@ -55,11 +57,16 @@ const scaffolderApiMock: jest.Mocked = { autocomplete: jest.fn(), }; +const scaffolderDecoratorsMock: jest.Mocked = { + getFormDecorators: jest.fn().mockResolvedValue([]), +}; + const catalogApi = catalogApiMock.mock(); const analyticsApi = mockApis.analytics(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], + [formDecoratorsApiRef, scaffolderDecoratorsMock], [catalogApiRef, catalogApi], [analyticsApiRef, analyticsApi], [catalogApiRef, catalogApi], diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index b5e24a7d52..2ed6de4479 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { Navigate, useNavigate } from 'react-router-dom'; import useAsync from 'react-use/esm/useAsync'; import { @@ -36,9 +36,12 @@ import { } from '@backstage/plugin-scaffolder-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { Workflow } from '@backstage/plugin-scaffolder-react/alpha'; +import { + Workflow, + useTemplateParameterSchema, +} from '@backstage/plugin-scaffolder-react/alpha'; import { JsonValue } from '@backstage/types'; -import { Header, Page } from '@backstage/core-components'; +import { Header, Page, Progress } from '@backstage/core-components'; import { rootRouteRef, @@ -49,6 +52,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; import { TemplateWizardPageContextMenu } from './TemplateWizardPageContextMenu'; +import { useFormDecorators } from '../../hooks'; /** * @alpha @@ -70,9 +74,10 @@ export type TemplateWizardPageProps = { export const TemplateWizardPage = (props: TemplateWizardPageProps) => { const rootRef = useRouteRef(rootRouteRef); const taskRoute = useRouteRef(scaffolderTaskRouteRef); - const { secrets } = useTemplateSecrets(); + const { secrets: contextSecrets } = useTemplateSecrets(); const scaffolderApi = useApi(scaffolderApiRef); const catalogApi = useApi(catalogApiRef); + const [isCreating, setIsCreating] = useState(false); const navigate = useNavigate(); const { templateName, namespace } = useRouteRefParams( selectedTemplateRouteRef, @@ -85,12 +90,26 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { name: templateName, }); + const { manifest } = useTemplateParameterSchema(templateRef); + const decorators = useFormDecorators({ manifest }); + const { value: editUrl } = useAsync(async () => { const data = await catalogApi.getEntityByRef(templateRef); return data?.metadata.annotations?.[ANNOTATION_EDIT_URL]; }, [templateRef, catalogApi]); - const onCreate = async (values: Record) => { + const onCreate = async (initialValues: Record) => { + if (isCreating) { + return; + } + + setIsCreating(true); + + const { formState: values, secrets } = await decorators.run({ + formState: initialValues, + secrets: contextSecrets, + }); + const { taskId } = await scaffolderApi.scaffold({ templateRef, values, @@ -113,6 +132,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { > + {isCreating && } import('./fields/RepoUrlPicker').then(m => m.RepoUrlPicker), }, }); + +export const scaffolderApi = ApiBlueprint.make({ + params: { + factory: createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, + fetchApi: fetchApiRef, + identityApi: identityApiRef, + }, + factory: ({ discoveryApi, scmIntegrationsApi, fetchApi, identityApi }) => + new ScaffolderClient({ + discoveryApi, + scmIntegrationsApi, + fetchApi, + identityApi, + }), + }), + }, +}); diff --git a/plugins/scaffolder/src/alpha/hooks/index.ts b/plugins/scaffolder/src/alpha/hooks/index.ts new file mode 100644 index 0000000000..fd9b0f7912 --- /dev/null +++ b/plugins/scaffolder/src/alpha/hooks/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './useFormDecorators'; diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx new file mode 100644 index 0000000000..92475c3eea --- /dev/null +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DefaultScaffolderFormDecoratorsApi } from '../api/FormDecoratorsApi'; +import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; +import { createApiRef, errorApiRef } from '@backstage/core-plugin-api'; + +import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useFormDecorators } from './useFormDecorators'; +import React from 'react'; +import { formDecoratorsApiRef } from '../api/ref'; +import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; + +describe('useFormDecorators', () => { + const mockApiRef = createApiRef<{ + test: (input: string) => void; + }>({ id: 'test' }); + + const mockApiImplementation = { test: jest.fn() }; + + const mockDecorator = createScaffolderFormDecorator({ + id: 'test', + deps: { mockApiRef }, + schema: { + input: { + test: z => z.string(), + }, + }, + async decorator({ input: { test } }, { mockApiRef: mock }) { + mock.test(test); + }, + }); + + const manifest: TemplateParameterSchema = { + EXPERIMENTAL_formDecorators: [{ id: 'test', input: { test: 'hello' } }], + steps: [], + title: 'test', + }; + + it('should run the form decorators for a given manifest with the correct input', async () => { + const renderedHook = renderHook(() => useFormDecorators({ manifest }), { + wrapper: ({ children }) => ( + {} }], + ]} + > + {children} + + ), + }); + + await waitFor(async () => { + const result = renderedHook.result.current!; + + await result.run({ + formState: {}, + secrets: {}, + }); + + expect(mockApiImplementation.test).toHaveBeenCalledWith('hello'); + }); + }); +}); diff --git a/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts new file mode 100644 index 0000000000..8469a89bec --- /dev/null +++ b/plugins/scaffolder/src/alpha/hooks/useFormDecorators.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +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 { ScaffolderFormDecoratorContext } from '@backstage/plugin-scaffolder-react/alpha'; +import { OpaqueFormDecorator } from '@internal/scaffolder'; +import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; +import { JsonValue } from '@backstage/types'; + +/** @internal */ +type BoundFieldDecorator = { + decorator: (ctx: ScaffolderFormDecoratorContext) => Promise; +}; + +export const useFormDecorators = ({ + manifest, +}: { + manifest?: TemplateParameterSchema; +}) => { + const formDecoratorsApi = useApi(formDecoratorsApiRef); + const errorApi = useApi(errorApiRef); + const { value: decorators } = useAsync( + () => formDecoratorsApi.getFormDecorators(), + [], + ); + const apiHolder = useApiHolder(); + + const boundDecorators = useMemo(() => { + const decoratorsMap = new Map(); + + for (const decorator of decorators ?? []) { + try { + const { decorator: decoratorFn, deps } = + OpaqueFormDecorator.toInternal(decorator); + + const resolvedDeps = Object.entries(deps ?? {}).map(([key, value]) => { + const api = apiHolder.get(value); + if (!api) { + throw new Error( + `Failed to resolve apiRef ${value.id} for form decorator ${decorator.id} it will be disabled`, + ); + } + return [key, api]; + }); + + decoratorsMap.set(decorator.id, { + decorator: ctx => decoratorFn(ctx, Object.fromEntries(resolvedDeps)), + }); + } catch (ex) { + errorApi.post(ex); + return undefined; + } + } + return decoratorsMap; + }, [apiHolder, decorators, errorApi]); + + return { + run: async (opts: { + formState: Record; + secrets: Record; + }) => { + let formState: Record = { ...opts.formState }; + let secrets: Record = {}; + + if (manifest?.EXPERIMENTAL_formDecorators) { + // for each of the form decorators, go and call the decorator with the context + await Promise.all( + manifest.EXPERIMENTAL_formDecorators.map(async decorator => { + const formDecorator = boundDecorators?.get(decorator.id); + if (!formDecorator) { + errorApi.post( + new Error(`Failed to find form decorator ${decorator.id}`), + ); + return; + } + + await formDecorator.decorator({ + setSecrets: ( + handler: ( + oldState: Record, + ) => Record, + ) => { + secrets = { ...handler(secrets) }; + }, + setFormState: ( + handler: ( + oldState: Record, + ) => Record, + ) => { + formState = { ...handler(formState) }; + }, + formState, + input: decorator.input ?? {}, + }); + }), + ); + } + + return { formState, secrets }; + }, + }; +}; diff --git a/plugins/scaffolder/src/alpha/index.ts b/plugins/scaffolder/src/alpha/index.ts index bd3104a274..53e2b8f0bf 100644 --- a/plugins/scaffolder/src/alpha/index.ts +++ b/plugins/scaffolder/src/alpha/index.ts @@ -23,5 +23,6 @@ export { } from './components'; export { scaffolderTranslationRef } from '../translation'; +export * from './api'; export { default } from './plugin'; diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 54e375e875..f3f0e9ed9e 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -26,11 +26,11 @@ import { selectedTemplateRouteRef, viewTechDocRouteRef, } from '../routes'; -import { scaffolderApi } from './api'; import { repoUrlPickerFormField, scaffolderNavItem, scaffolderPage, + scaffolderApi, } from './extensions'; import { formFieldsApi } from './api/FormFieldsApi'; diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index 79684a694d..6c06322867 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -60,8 +60,10 @@ import { CustomFieldsPage, } from '../../alpha/components/TemplateEditorPage'; import { RequirePermission } from '@backstage/plugin-permission-react'; -import { taskReadPermission } from '@backstage/plugin-scaffolder-common/alpha'; -import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha'; +import { + taskReadPermission, + templateManagementPermission, +} from '@backstage/plugin-scaffolder-common/alpha'; /** * The Props for the Scaffolder Router diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index 8b24e86069..a6ccdef32c 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -78,6 +78,8 @@ import { } from './components/fields/MyGroupsPicker/MyGroupsPicker'; import { RepoBranchPicker } from './components/fields/RepoBranchPicker/RepoBranchPicker'; import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/schema'; +import { formDecoratorsApiRef } from './alpha/api/ref'; +import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi'; /** * The main plugin export for the scaffolder. @@ -102,6 +104,11 @@ export const scaffolderPlugin = createPlugin({ identityApi, }), }), + createApiFactory({ + api: formDecoratorsApiRef, + deps: {}, + factory: () => DefaultScaffolderFormDecoratorsApi.create(), + }), ], routes: { root: rootRouteRef, diff --git a/plugins/search/report-alpha.api.md b/plugins/search/report-alpha.api.md index 9b8f21fa43..d4a016bbe2 100644 --- a/plugins/search/report-alpha.api.md +++ b/plugins/search/report-alpha.api.md @@ -22,21 +22,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:search': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'nav-item:search': ExtensionDefinition<{ kind: 'nav-item'; name: undefined; @@ -58,6 +43,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:search': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'page:search': ExtensionDefinition<{ config: { noTrack: boolean; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 63e078dd43..d39c9d31ed 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -31,21 +31,6 @@ const _default: FrontendPlugin< }, {}, { - 'api:techdocs': ExtensionDefinition<{ - kind: 'api'; - name: undefined; - config: {}; - configInput: {}; - output: ConfigurableExtensionDataRef< - AnyApiFactory, - 'core.api.factory', - {} - >; - inputs: {}; - params: { - factory: AnyApiFactory; - }; - }>; 'page:techdocs': ExtensionDefinition<{ kind: 'page'; name: undefined; @@ -97,6 +82,21 @@ const _default: FrontendPlugin< routeRef: RouteRef; }; }>; + 'api:techdocs': ExtensionDefinition<{ + kind: 'api'; + name: undefined; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + params: { + factory: AnyApiFactory; + }; + }>; 'api:techdocs/storage': ExtensionDefinition<{ kind: 'api'; name: 'storage'; diff --git a/yarn.lock b/yarn.lock index a36ca91551..366b6c30ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10339,6 +10339,7 @@ __metadata: resolution: "@internal/scaffolder@workspace:packages/scaffolder-internal" dependencies: "@backstage/cli": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-scaffolder-react": "workspace:^" zod: ^3.22.4 languageName: unknown