@@ -173,6 +173,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EXPERIMENTAL_formHooks": {
|
||||
"type": "object",
|
||||
"description": "A list of hooks and their inputs that the form should trigger before submitting the job",
|
||||
"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_formHooks?: { 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
|
||||
|
||||
@@ -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';
|
||||
@@ -31,6 +31,7 @@ import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProper
|
||||
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
|
||||
import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { ScaffolderFormHook } from '../../extensions';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
markdown: {
|
||||
@@ -52,6 +53,7 @@ export type WorkflowProps = {
|
||||
description?: string;
|
||||
namespace: string;
|
||||
templateName: string;
|
||||
formHooks?: ScaffolderFormHook[];
|
||||
components?: {
|
||||
ReviewStepComponent?: React.ComponentType<ReviewStepProps>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 { createScaffolderFormHook } from './createScaffolderFormHook';
|
||||
|
||||
describe('createScaffolderFormHook', () => {
|
||||
it('should return a hook', () => {
|
||||
const hook = createScaffolderFormHook({
|
||||
id: 'test',
|
||||
deps: {},
|
||||
fn: async () => {},
|
||||
});
|
||||
|
||||
expect(hook).toMatchInlineSnapshot();
|
||||
});
|
||||
|
||||
it('should allow passing schema and be typesafe', () => {
|
||||
const hook = createScaffolderFormHook({
|
||||
id: 'test',
|
||||
deps: {},
|
||||
schema: {
|
||||
input: {
|
||||
name: z => z.string(),
|
||||
age: z => z.number(),
|
||||
},
|
||||
},
|
||||
fn: async ctx => {
|
||||
const name: string = ctx.input.name;
|
||||
|
||||
// @ts-expect-error
|
||||
const number: string = ctx.input.age;
|
||||
|
||||
expect([name, number]).toBeDefined();
|
||||
},
|
||||
});
|
||||
|
||||
expect(hook).toMatchInlineSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { JsonValue } from '@backstage/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ScaffolderFormHookContext<TInput> = {
|
||||
input: TInput;
|
||||
setSecret: (key: string, value: JsonValue) => void;
|
||||
};
|
||||
|
||||
export type ScaffolderInitialFormHook<
|
||||
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {},
|
||||
TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef },
|
||||
TInput = {
|
||||
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
|
||||
},
|
||||
> = {
|
||||
version: 'v1';
|
||||
id: string;
|
||||
schema?: {
|
||||
input?: TInputSchema;
|
||||
};
|
||||
deps?: TDeps;
|
||||
fn: (
|
||||
ctx: ScaffolderFormHookContext<TInput>,
|
||||
deps: TDeps extends { [key in string]: AnyApiRef }
|
||||
? { [key in keyof TDeps]: TDeps[key]['T'] }
|
||||
: never,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ScaffolderFormHook<TInput = any> = {
|
||||
id: string;
|
||||
fn: (ctx: ScaffolderFormHookContext<TInput>) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Method for creating hooks which can be used to collect
|
||||
* secrets from the user before submitting to the backend.
|
||||
* @public
|
||||
*/
|
||||
export function createScaffolderFormHook<
|
||||
TDeps extends { [key in string]: AnyApiRef },
|
||||
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
|
||||
TInput = {
|
||||
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
|
||||
},
|
||||
>(options: {
|
||||
id: string;
|
||||
schema?: {
|
||||
input?: TInputSchema;
|
||||
};
|
||||
deps?: TDeps;
|
||||
fn: (
|
||||
ctx: ScaffolderFormHookContext<TInput>,
|
||||
deps: TDeps extends { [key in string]: AnyApiRef }
|
||||
? { [key in keyof TDeps]: TDeps[key]['T'] }
|
||||
: never,
|
||||
) => Promise<void>;
|
||||
}): ScaffolderInitialFormHook<TInputSchema, TDeps, TInput> {
|
||||
return {
|
||||
...options,
|
||||
version: 'v1',
|
||||
};
|
||||
}
|
||||
@@ -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 './createScaffolderFormHook';
|
||||
@@ -18,3 +18,4 @@ export * from './lib';
|
||||
export * from './hooks';
|
||||
export * from './overridableComponents';
|
||||
export * from './blueprints';
|
||||
export * from './extensions';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 { DefaultScaffolderFormHooksApi } from './FormHooksApi';
|
||||
import { createScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
import { TestApiRegistry, withLogCollector } from '@backstage/test-utils';
|
||||
|
||||
describe('FormHooksApi', () => {
|
||||
const mockApiRef = createApiRef<{
|
||||
test: (input: string) => void;
|
||||
}>({ id: 'test' });
|
||||
const mockApiImplementation = { test: jest.fn() };
|
||||
|
||||
it('should wrap up the form hooks', async () => {
|
||||
const mockHook = createScaffolderFormHook({
|
||||
id: 'test',
|
||||
deps: { mockApiRef },
|
||||
schema: {
|
||||
input: {
|
||||
test: z => z.string(),
|
||||
},
|
||||
},
|
||||
async fn({ input: { test } }, { mockApiRef: mock }) {
|
||||
mock.test(test);
|
||||
},
|
||||
});
|
||||
|
||||
const hooks = new DefaultScaffolderFormHooksApi({
|
||||
formHooks: [mockHook],
|
||||
apiHolder: TestApiRegistry.from([mockApiRef, mockApiImplementation]),
|
||||
});
|
||||
|
||||
const formHooks = await hooks.getFormHooks();
|
||||
const [boundHook] = formHooks;
|
||||
|
||||
expect(formHooks.length).toBe(1);
|
||||
expect(boundHook.id).toBe('test');
|
||||
|
||||
await boundHook.fn({ setSecret: () => {}, input: { test: 'input value' } });
|
||||
expect(mockApiImplementation.test).toHaveBeenCalledWith('input value');
|
||||
});
|
||||
|
||||
it('should skip failing deps', async () => {
|
||||
const mockHook = createScaffolderFormHook({
|
||||
id: 'test',
|
||||
deps: { mockApiRef },
|
||||
schema: {
|
||||
input: {
|
||||
test: z => z.string(),
|
||||
},
|
||||
},
|
||||
async fn({ input: { test } }, { mockApiRef: mock }) {
|
||||
mock.test(test);
|
||||
},
|
||||
});
|
||||
|
||||
const hooks = new DefaultScaffolderFormHooksApi({
|
||||
formHooks: [mockHook],
|
||||
apiHolder: TestApiRegistry.from(),
|
||||
});
|
||||
|
||||
const { error } = await withLogCollector(async () => {
|
||||
const formHooks = await hooks.getFormHooks();
|
||||
expect(formHooks.length).toBe(0);
|
||||
});
|
||||
|
||||
expect(error[0]).toMatchInlineSnapshot(
|
||||
`[Error: Failed to resolve apiRef test for form hook test - it will be disabled]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { ScaffolderFormHooksApi } from './types';
|
||||
import {
|
||||
ScaffolderFormHook,
|
||||
ScaffolderInitialFormHook,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
export class DefaultScaffolderFormHooksApi implements ScaffolderFormHooksApi {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
formHooks: Array<ScaffolderInitialFormHook>;
|
||||
apiHolder: ApiHolder;
|
||||
},
|
||||
) {}
|
||||
|
||||
async getFormHooks(): Promise<ScaffolderFormHook[]> {
|
||||
return this.options.formHooks
|
||||
.map(hook => {
|
||||
try {
|
||||
const resolvedDeps = Object.entries(hook.deps ?? {}).map(
|
||||
([key, value]) => {
|
||||
const api = this.options.apiHolder.get(value);
|
||||
if (!api) {
|
||||
// eslint-disable-next-line no-console
|
||||
throw new Error(
|
||||
`Failed to resolve apiRef ${value.id} for form hook ${hook.id} - it will be disabled`,
|
||||
);
|
||||
}
|
||||
return [key, api];
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: hook.id,
|
||||
// todo(blam): should probably use zod to validate the input here
|
||||
// or maybe move that to the `createScaffolderFormHook` method instead.
|
||||
fn: input => hook.fn(input, Object.fromEntries(resolvedDeps)),
|
||||
} as ScaffolderFormHook;
|
||||
} catch (ex) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(ex);
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((h): h is ScaffolderFormHook => !!h);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,12 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ScaffolderFormFieldsApi } from './types';
|
||||
import { ScaffolderFormFieldsApi, ScaffolderFormHooksApi } from './types';
|
||||
|
||||
export const formFieldsApiRef = createApiRef<ScaffolderFormFieldsApi>({
|
||||
id: 'plugin.scaffolder.form-fields',
|
||||
});
|
||||
|
||||
export const formHooksApiRef = createApiRef<ScaffolderFormHooksApi>({
|
||||
id: 'plugin.scaffolder.form-hooks',
|
||||
});
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FormFieldExtensionData } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import {
|
||||
FormFieldExtensionData,
|
||||
ScaffolderFormHook,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
export interface ScaffolderFormFieldsApi {
|
||||
getFormFields(): Promise<FormFieldExtensionData[]>;
|
||||
}
|
||||
|
||||
export interface ScaffolderFormHooksApi {
|
||||
getFormHooks(): Promise<ScaffolderFormHook[]>;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { Workflow } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import {
|
||||
ScaffolderFormHook,
|
||||
Workflow,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Header, Page } from '@backstage/core-components';
|
||||
|
||||
@@ -65,6 +68,7 @@ export type TemplateWizardPageProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
EXPERIMENTAL_formHooks?: ScaffolderFormHook[];
|
||||
};
|
||||
|
||||
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
@@ -122,6 +126,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
extensions={props.customFieldExtensions}
|
||||
formProps={props.formProps}
|
||||
layouts={props.layouts}
|
||||
EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks}
|
||||
/>
|
||||
</Page>
|
||||
</AnalyticsContext>
|
||||
|
||||
@@ -60,8 +60,11 @@ 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';
|
||||
import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
/**
|
||||
* The Props for the Scaffolder Router
|
||||
@@ -81,6 +84,7 @@ export type RouterProps = {
|
||||
EXPERIMENTAL_TemplateListPageComponent?: React.ComponentType<TemplateListPageProps>;
|
||||
EXPERIMENTAL_TemplateWizardPageComponent?: React.ComponentType<TemplateWizardPageProps>;
|
||||
};
|
||||
EXPERIMENTAL_formHooks?: ScaffolderFormHook[];
|
||||
groups?: TemplateGroupFilter[];
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
headerOptions?: {
|
||||
@@ -160,6 +164,7 @@ export const Router = (props: PropsWithChildren<RouterProps>) => {
|
||||
layouts={customLayouts}
|
||||
components={{ ReviewStepComponent }}
|
||||
formProps={props.formProps}
|
||||
EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user