Merge pull request #25975 from backstage/blam/secret-collection
scaffolder: Implement hooks for collecting secrets
This commit is contained in:
@@ -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
|
||||
@@ -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' }));
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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' }));
|
||||
},
|
||||
});
|
||||
@@ -23,6 +23,7 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-react": "workspace:^",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
}>({ type: '@backstage/scaffolder/FormDecorator', versions: ['v1'] });
|
||||
@@ -14,3 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { OpaqueFormField, type FormField } from './InternalFormField';
|
||||
export { OpaqueFormDecorator } from './InternalFormDecorator';
|
||||
|
||||
@@ -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<AnyRouteRefParams> | undefined;
|
||||
};
|
||||
}>;
|
||||
'api:catalog-import': ExtensionDefinition<{
|
||||
kind: 'api';
|
||||
name: undefined;
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ConfigurableExtensionDataRef<
|
||||
AnyApiFactory,
|
||||
'core.api.factory',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
factory: AnyApiFactory;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
@@ -146,21 +146,6 @@ const _default: FrontendPlugin<
|
||||
unregisterRedirect: ExternalRouteRef<undefined>;
|
||||
},
|
||||
{
|
||||
'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<undefined>;
|
||||
};
|
||||
}>;
|
||||
'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';
|
||||
|
||||
@@ -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<undefined>;
|
||||
};
|
||||
}>;
|
||||
'api:devtools': ExtensionDefinition<{
|
||||
kind: 'api';
|
||||
name: undefined;
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ConfigurableExtensionDataRef<
|
||||
AnyApiFactory,
|
||||
'core.api.factory',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
factory: AnyApiFactory;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
@@ -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<AnyRouteRefParams> | 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';
|
||||
|
||||
@@ -475,6 +475,8 @@ export async function createRouter(
|
||||
description: schema.description,
|
||||
schema,
|
||||
})),
|
||||
EXPERIMENTAL_formDecorators:
|
||||
template.spec.EXPERIMENTAL_formDecorators,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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<TemplateEntityStepV1beta3>;
|
||||
output?: {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
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<TReturnValue, TUiOptions>): 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<ReturnType<TInputSchema[key]>>;
|
||||
},
|
||||
>(options: {
|
||||
id: string;
|
||||
schema?: {
|
||||
input?: TInputSchema;
|
||||
};
|
||||
deps?: TDeps;
|
||||
decorator: (
|
||||
ctx: ScaffolderFormDecoratorContext<TInput>,
|
||||
deps: TDeps extends {
|
||||
[key in string]: AnyApiRef;
|
||||
}
|
||||
? {
|
||||
[key in keyof TDeps]: TDeps[key]['T'];
|
||||
}
|
||||
: never,
|
||||
) => Promise<void>;
|
||||
}): ScaffolderFormDecorator<TInput>;
|
||||
|
||||
// @alpha
|
||||
export const DefaultTemplateOutputs: (props: {
|
||||
output?: ScaffolderTaskOutput;
|
||||
@@ -190,6 +224,27 @@ export interface ScaffolderFieldProps {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export type ScaffolderFormDecorator<TInput extends JsonObject = JsonObject> = {
|
||||
readonly $$type: '@backstage/scaffolder/FormDecorator';
|
||||
readonly id: string;
|
||||
readonly TInput: TInput;
|
||||
};
|
||||
|
||||
// @alpha (undocumented)
|
||||
export type ScaffolderFormDecoratorContext<
|
||||
TInput extends JsonObject = JsonObject,
|
||||
> = {
|
||||
input: TInput;
|
||||
formState: Record<string, JsonValue>;
|
||||
setFormState: (
|
||||
fn: (currentState: Record<string, JsonValue>) => Record<string, JsonValue>,
|
||||
) => void;
|
||||
setSecrets: (
|
||||
fn: (currentState: Record<string, string>) => Record<string, string>,
|
||||
) => 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
|
||||
|
||||
@@ -538,6 +538,10 @@ export type TemplateParameterSchema = {
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
EXPERIMENTAL_formDecorators?: {
|
||||
id: string;
|
||||
input?: JsonObject;
|
||||
}[];
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -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';
|
||||
@@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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<string, JsonValue>;
|
||||
|
||||
setFormState: (
|
||||
fn: (currentState: Record<string, JsonValue>) => Record<string, JsonValue>,
|
||||
) => void;
|
||||
setSecrets: (
|
||||
fn: (currentState: Record<string, string>) => Record<string, string>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/** @alpha */
|
||||
export type ScaffolderFormDecorator<TInput extends JsonObject = JsonObject> = {
|
||||
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<ReturnType<TInputSchema[key]>>;
|
||||
},
|
||||
>(options: {
|
||||
id: string;
|
||||
schema?: {
|
||||
input?: TInputSchema;
|
||||
};
|
||||
deps?: TDeps;
|
||||
decorator: (
|
||||
ctx: ScaffolderFormDecoratorContext<TInput>,
|
||||
deps: TDeps extends { [key in string]: AnyApiRef }
|
||||
? { [key in keyof TDeps]: TDeps[key]['T'] }
|
||||
: never,
|
||||
) => Promise<void>;
|
||||
}): ScaffolderFormDecorator<TInput> {
|
||||
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<void>;
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from './lib';
|
||||
export * from './hooks';
|
||||
export * from './overridableComponents';
|
||||
export * from './blueprints';
|
||||
export * from './extensions';
|
||||
|
||||
@@ -33,4 +33,5 @@ export type TemplateParameterSchema = {
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[];
|
||||
};
|
||||
|
||||
@@ -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<FormField>;
|
||||
};
|
||||
}>;
|
||||
'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<ScaffolderFormDecorator[]>;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const formDecoratorsApiRef: ApiRef<ScaffolderFormDecoratorsApi>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const formFieldsApiRef: ApiRef<ScaffolderFormFieldsApi>;
|
||||
|
||||
// @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<ScaffolderFormDecorator[]>;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface ScaffolderFormFieldsApi {
|
||||
// (undocumented)
|
||||
getFormFields(): Promise<FormFieldExtensionData[]>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ScaffolderTemplateEditorClassKey =
|
||||
| 'root'
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -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<ScaffolderFormDecorator>;
|
||||
},
|
||||
) {}
|
||||
|
||||
static create(options?: { decorators: ScaffolderFormDecorator[] }) {
|
||||
return new DefaultScaffolderFormDecoratorsApi(
|
||||
options ?? { decorators: [] },
|
||||
);
|
||||
}
|
||||
|
||||
async getFormDecorators(): Promise<ScaffolderFormDecorator[]> {
|
||||
return this.options.decorators;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ScaffolderFormFieldsApi>({
|
||||
id: 'plugin.scaffolder.form-fields',
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export const formDecoratorsApiRef = createApiRef<ScaffolderFormDecoratorsApi>({
|
||||
id: 'plugin.scaffolder.form-decorators',
|
||||
});
|
||||
|
||||
@@ -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<FormFieldExtensionData[]>;
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export interface ScaffolderFormDecoratorsApi {
|
||||
getFormDecorators(): Promise<ScaffolderFormDecorator[]>;
|
||||
}
|
||||
|
||||
@@ -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<ScaffolderApi> = {
|
||||
autocomplete: jest.fn(),
|
||||
};
|
||||
|
||||
const scaffolderDecoratorsMock: jest.Mocked<ScaffolderFormDecoratorsApi> = {
|
||||
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],
|
||||
|
||||
@@ -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<string, JsonValue>) => {
|
||||
const onCreate = async (initialValues: Record<string, JsonValue>) => {
|
||||
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) => {
|
||||
>
|
||||
<TemplateWizardPageContextMenu editUrl={editUrl} />
|
||||
</Header>
|
||||
{isCreating && <Progress />}
|
||||
<Workflow
|
||||
namespace={namespace}
|
||||
templateName={templateName}
|
||||
|
||||
@@ -21,11 +21,19 @@ import {
|
||||
import {
|
||||
NavItemBlueprint,
|
||||
PageBlueprint,
|
||||
ApiBlueprint,
|
||||
createApiFactory,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import React from 'react';
|
||||
import { rootRouteRef } from '../routes';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { ScaffolderClient } from '../api';
|
||||
|
||||
export const scaffolderPage = PageBlueprint.make({
|
||||
params: {
|
||||
@@ -50,3 +58,24 @@ export const repoUrlPickerFormField = FormFieldBlueprint.make({
|
||||
field: () => 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,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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 }) => (
|
||||
<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: {},
|
||||
});
|
||||
|
||||
expect(mockApiImplementation.test).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<string, BoundFieldDecorator>();
|
||||
|
||||
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<string, JsonValue>;
|
||||
secrets: Record<string, string>;
|
||||
}) => {
|
||||
let formState: Record<string, JsonValue> = { ...opts.formState };
|
||||
let secrets: Record<string, string> = {};
|
||||
|
||||
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<string, string>,
|
||||
) => Record<string, string>,
|
||||
) => {
|
||||
secrets = { ...handler(secrets) };
|
||||
},
|
||||
setFormState: (
|
||||
handler: (
|
||||
oldState: Record<string, JsonValue>,
|
||||
) => Record<string, JsonValue>,
|
||||
) => {
|
||||
formState = { ...handler(formState) };
|
||||
},
|
||||
formState,
|
||||
input: decorator.input ?? {},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { formState, secrets };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -23,5 +23,6 @@ export {
|
||||
} from './components';
|
||||
|
||||
export { scaffolderTranslationRef } from '../translation';
|
||||
export * from './api';
|
||||
|
||||
export { default } from './plugin';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<undefined>;
|
||||
};
|
||||
}>;
|
||||
'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;
|
||||
|
||||
@@ -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<undefined>;
|
||||
};
|
||||
}>;
|
||||
'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';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user