@@ -475,7 +475,8 @@ export async function createRouter(
|
||||
description: schema.description,
|
||||
schema,
|
||||
})),
|
||||
EXPERIMENTAL_formHooks: template.spec.EXPERIMENTAL_formHooks,
|
||||
EXPERIMENTAL_formDecorators:
|
||||
template.spec.EXPERIMENTAL_formDecorators,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
||||
@@ -173,9 +173,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"EXPERIMENTAL_formHooks": {
|
||||
"EXPERIMENTAL_formDecorators": {
|
||||
"type": "object",
|
||||
"description": "A list of hooks and their inputs that the form should trigger before submitting the job",
|
||||
"description": "A list of decorators and their inputs that the form should trigger before submitting the job",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface TemplateEntityV1beta3 extends Entity {
|
||||
/**
|
||||
* Form hooks to be run
|
||||
*/
|
||||
EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[];
|
||||
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
|
||||
|
||||
@@ -538,7 +538,7 @@ export type TemplateParameterSchema = {
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
EXPERIMENTAL_formHooks?: {
|
||||
EXPERIMENTAL_formDecorators?: {
|
||||
id: string;
|
||||
input?: JsonObject;
|
||||
}[];
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
|
||||
import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { useFormHooks } from '../../../../../scaffolder/src/alpha/hooks/useFormHooks';
|
||||
import { useFormDecorators } from '../../../../../scaffolder/src/alpha/hooks/useFormDecorators';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
markdown: {
|
||||
@@ -89,24 +89,24 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
const sortedManifest = useFilteredSchemaProperties(manifest);
|
||||
const minutesSaved = useTemplateTimeSavedMinutes(templateRef);
|
||||
const { setSecrets } = useTemplateSecrets();
|
||||
const formHooks = useFormHooks();
|
||||
const formDecorators = useFormDecorators();
|
||||
|
||||
const workflowOnCreate = useCallback(
|
||||
async (formState: Record<string, JsonValue>) => {
|
||||
if (manifest?.EXPERIMENTAL_formHooks && formHooks?.size) {
|
||||
// for each of the form hooks, go and call the hook with the context
|
||||
if (manifest?.EXPERIMENTAL_formDecorators && formDecorators?.size) {
|
||||
// for each of the form decorators, go and call the decorator with the context
|
||||
await Promise.all(
|
||||
manifest.EXPERIMENTAL_formHooks.map(async hook => {
|
||||
const formHook = formHooks.get(hook.id);
|
||||
if (!formHook) {
|
||||
manifest.EXPERIMENTAL_formDecorators.map(async decorator => {
|
||||
const formDecorator = formDecorators.get(decorator.id);
|
||||
if (!formDecorator) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to find form hook', hook.id);
|
||||
console.error('Failed to find form decorator', decorator.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await formHook.fn({
|
||||
await formDecorator.fn({
|
||||
setSecrets,
|
||||
input: hook.input,
|
||||
input: decorator.input,
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -121,8 +121,8 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
});
|
||||
},
|
||||
[
|
||||
manifest?.EXPERIMENTAL_formHooks,
|
||||
formHooks,
|
||||
manifest?.EXPERIMENTAL_formDecorators,
|
||||
formDecorators,
|
||||
onCreate,
|
||||
analytics,
|
||||
templateName,
|
||||
|
||||
+7
-7
@@ -13,21 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createScaffolderFormHook } from './createScaffolderFormHook';
|
||||
import { createScaffolderFormDecorator } from './createScaffolderFormDecorator';
|
||||
|
||||
describe('createScaffolderFormHook', () => {
|
||||
it('should return a hook', () => {
|
||||
const hook = createScaffolderFormHook({
|
||||
describe('createScaffolderFormDecorator', () => {
|
||||
it('should return a decorator', () => {
|
||||
const decorator = createScaffolderFormDecorator({
|
||||
id: 'test',
|
||||
deps: {},
|
||||
fn: async () => {},
|
||||
});
|
||||
|
||||
expect(hook).toMatchInlineSnapshot();
|
||||
expect(decorator).toMatchInlineSnapshot();
|
||||
});
|
||||
|
||||
it('should allow passing schema and be typesafe', () => {
|
||||
const hook = createScaffolderFormHook({
|
||||
const decorator = createScaffolderFormDecorator({
|
||||
id: 'test',
|
||||
deps: {},
|
||||
schema: {
|
||||
@@ -46,6 +46,6 @@ describe('createScaffolderFormHook', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(hook).toMatchInlineSnapshot();
|
||||
expect(decorator).toMatchInlineSnapshot();
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -16,12 +16,12 @@
|
||||
import { AnyApiRef } from '@backstage/core-plugin-api';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ScaffolderFormHookContext<TInput> = {
|
||||
export type ScaffolderFormDecoratorContext<TInput> = {
|
||||
input: TInput;
|
||||
setSecrets: (input: Record<string, string>) => void;
|
||||
};
|
||||
|
||||
export type ScaffolderFormHook<
|
||||
export type ScaffolderFormDecorator<
|
||||
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {},
|
||||
TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef },
|
||||
TInput extends {} = {
|
||||
@@ -35,7 +35,7 @@ export type ScaffolderFormHook<
|
||||
};
|
||||
deps?: TDeps;
|
||||
fn: (
|
||||
ctx: ScaffolderFormHookContext<TInput>,
|
||||
ctx: ScaffolderFormDecoratorContext<TInput>,
|
||||
deps: TDeps extends { [key in string]: AnyApiRef }
|
||||
? { [key in keyof TDeps]: TDeps[key]['T'] }
|
||||
: never,
|
||||
@@ -43,11 +43,11 @@ export type ScaffolderFormHook<
|
||||
};
|
||||
|
||||
/**
|
||||
* Method for creating hooks which can be used to collect
|
||||
* Method for creating decorators which can be used to collect
|
||||
* secrets from the user before submitting to the backend.
|
||||
* @public
|
||||
*/
|
||||
export function createScaffolderFormHook<
|
||||
export function createScaffolderFormDecorator<
|
||||
TDeps extends { [key in string]: AnyApiRef },
|
||||
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
|
||||
TInput extends {} = {
|
||||
@@ -60,12 +60,12 @@ export function createScaffolderFormHook<
|
||||
};
|
||||
deps?: TDeps;
|
||||
fn: (
|
||||
ctx: ScaffolderFormHookContext<TInput>,
|
||||
ctx: ScaffolderFormDecoratorContext<TInput>,
|
||||
deps: TDeps extends { [key in string]: AnyApiRef }
|
||||
? { [key in keyof TDeps]: TDeps[key]['T'] }
|
||||
: never,
|
||||
) => Promise<void>;
|
||||
}): ScaffolderFormHook<TInputSchema, TDeps, TInput> {
|
||||
}): ScaffolderFormDecorator<TInputSchema, TDeps, TInput> {
|
||||
return {
|
||||
...options,
|
||||
version: 'v1',
|
||||
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './createScaffolderFormHook';
|
||||
export * from './createScaffolderFormDecorator';
|
||||
|
||||
@@ -33,5 +33,5 @@ export type TemplateParameterSchema = {
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[];
|
||||
EXPERIMENTAL_formDecorators?: { id: string; input?: JsonObject }[];
|
||||
};
|
||||
|
||||
+10
-8
@@ -14,21 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScaffolderFormHooksApi } from './types';
|
||||
import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { ScaffolderFormDecoratorsApi } from './types';
|
||||
import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
export class DefaultScaffolderFormHooksApi implements ScaffolderFormHooksApi {
|
||||
export class DefaultScaffolderFormDecoratorsApi
|
||||
implements ScaffolderFormDecoratorsApi
|
||||
{
|
||||
constructor(
|
||||
private readonly options: {
|
||||
hooks: Array<ScaffolderFormHook>;
|
||||
decorators: Array<ScaffolderFormDecorator>;
|
||||
},
|
||||
) {}
|
||||
|
||||
static create(options: { hooks: ScaffolderFormHook[] }) {
|
||||
return new DefaultScaffolderFormHooksApi(options);
|
||||
static create(options: { decorators: ScaffolderFormDecorator[] }) {
|
||||
return new DefaultScaffolderFormDecoratorsApi(options);
|
||||
}
|
||||
|
||||
async getFormHooks(): Promise<ScaffolderFormHook[]> {
|
||||
return this.options.hooks;
|
||||
async getFormDecorators(): Promise<ScaffolderFormDecorator[]> {
|
||||
return this.options.decorators;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,12 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ScaffolderFormFieldsApi, ScaffolderFormHooksApi } from './types';
|
||||
import { ScaffolderFormFieldsApi, ScaffolderFormDecoratorsApi } from './types';
|
||||
|
||||
export const formFieldsApiRef = createApiRef<ScaffolderFormFieldsApi>({
|
||||
id: 'plugin.scaffolder.form-fields',
|
||||
});
|
||||
|
||||
export const formHooksApiRef = createApiRef<ScaffolderFormHooksApi>({
|
||||
id: 'plugin.scaffolder.form-hooks',
|
||||
export const formDecoratorsApiRef = createApiRef<ScaffolderFormDecoratorsApi>({
|
||||
id: 'plugin.scaffolder.form-decorators',
|
||||
});
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
import {
|
||||
FormFieldExtensionData,
|
||||
ScaffolderFormHook,
|
||||
ScaffolderFormDecorator,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
export interface ScaffolderFormFieldsApi {
|
||||
getFormFields(): Promise<FormFieldExtensionData[]>;
|
||||
}
|
||||
|
||||
export interface ScaffolderFormHooksApi {
|
||||
getFormHooks(): Promise<ScaffolderFormHook[]>;
|
||||
export interface ScaffolderFormDecoratorsApi {
|
||||
getFormDecorators(): Promise<ScaffolderFormDecorator[]>;
|
||||
}
|
||||
|
||||
@@ -36,10 +36,7 @@ import {
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
import {
|
||||
ScaffolderFormHook,
|
||||
Workflow,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { Workflow } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Header, Page } from '@backstage/core-components';
|
||||
|
||||
@@ -68,7 +65,6 @@ export type TemplateWizardPageProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
EXPERIMENTAL_formHooks?: ScaffolderFormHook[];
|
||||
};
|
||||
|
||||
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
|
||||
+22
-18
@@ -13,24 +13,24 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { DefaultScaffolderFormHooksApi } from '../api/FormHooksApi';
|
||||
import { createScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { DefaultScaffolderFormDecoratorsApi } from '../api/FormDecoratorsApi';
|
||||
import { createScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
import { TestApiProvider, withLogCollector } from '@backstage/test-utils';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useFormHooks } from './useFormHooks';
|
||||
import { useFormDecorators } from './useFormDecorators';
|
||||
import React from 'react';
|
||||
import { formHooksApiRef } from '../api/ref';
|
||||
import { formDecoratorsApiRef } from '../api/ref';
|
||||
|
||||
describe('useFormHooks', () => {
|
||||
describe('useFormDecorators', () => {
|
||||
const mockApiRef = createApiRef<{
|
||||
test: (input: string) => void;
|
||||
}>({ id: 'test' });
|
||||
|
||||
const mockApiImplementation = { test: jest.fn() };
|
||||
|
||||
const mockHook = createScaffolderFormHook({
|
||||
const mockDecorator = createScaffolderFormDecorator({
|
||||
id: 'test',
|
||||
deps: { mockApiRef },
|
||||
schema: {
|
||||
@@ -43,16 +43,18 @@ describe('useFormHooks', () => {
|
||||
},
|
||||
});
|
||||
|
||||
it('should wrap up the form hooks', async () => {
|
||||
const renderedHook = renderHook(() => useFormHooks(), {
|
||||
it('should wrap up the form decorators', async () => {
|
||||
const renderedHook = renderHook(() => useFormDecorators(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[mockApiRef, mockApiImplementation],
|
||||
[
|
||||
formHooksApiRef,
|
||||
// @ts-expect-error - todo
|
||||
DefaultScaffolderFormHooksApi.create({ hooks: [mockHook] }),
|
||||
formDecoratorsApiRef,
|
||||
DefaultScaffolderFormDecoratorsApi.create({
|
||||
// @ts-expect-error - todo
|
||||
decorators: [mockDecorator],
|
||||
}),
|
||||
],
|
||||
]}
|
||||
>
|
||||
@@ -65,10 +67,10 @@ describe('useFormHooks', () => {
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const testHook = result.get('test')!;
|
||||
expect(testHook).toBeDefined();
|
||||
const testDecorator = result.get('test')!;
|
||||
expect(testDecorator).toBeDefined();
|
||||
|
||||
await testHook.fn({
|
||||
await testDecorator.fn({
|
||||
setSecrets: () => {},
|
||||
input: { test: 'input value' },
|
||||
});
|
||||
@@ -78,14 +80,16 @@ describe('useFormHooks', () => {
|
||||
|
||||
it('should skip failing deps', async () => {
|
||||
const { error } = await withLogCollector(async () => {
|
||||
const renderedHook = renderHook(() => useFormHooks(), {
|
||||
const renderedHook = renderHook(() => useFormDecorators(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
formHooksApiRef,
|
||||
// @ts-expect-error - todo
|
||||
DefaultScaffolderFormHooksApi.create({ hooks: [mockHook] }),
|
||||
formDecoratorsApiRef,
|
||||
DefaultScaffolderFormDecoratorsApi.create({
|
||||
// @ts-expect-error - todo
|
||||
decorators: [mockDecorator],
|
||||
}),
|
||||
],
|
||||
]}
|
||||
>
|
||||
+18
-15
@@ -14,40 +14,43 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useApi, useApiHolder } from '@backstage/core-plugin-api';
|
||||
import { formHooksApiRef } from '../api/ref';
|
||||
import { formDecoratorsApiRef } from '../api/ref';
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import { useMemo } from 'react';
|
||||
import { ScaffolderFormHookContext } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { ScaffolderFormDecoratorContext } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
/** @internal */
|
||||
type BoundFieldHook = {
|
||||
fn: (ctx: ScaffolderFormHookContext<any>) => Promise<void>;
|
||||
type BoundFieldDecorator = {
|
||||
fn: (ctx: ScaffolderFormDecoratorContext<any>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useFormHooks = () => {
|
||||
const formHooksApi = useApi(formHooksApiRef);
|
||||
const { value: hooks } = useAsync(() => formHooksApi.getFormHooks(), []);
|
||||
export const useFormDecorators = () => {
|
||||
const formDecoratorsApi = useApi(formDecoratorsApiRef);
|
||||
const { value: decorators } = useAsync(
|
||||
() => formDecoratorsApi.getFormDecorators(),
|
||||
[],
|
||||
);
|
||||
const apiHolder = useApiHolder();
|
||||
|
||||
return useMemo(() => {
|
||||
const hooksMap = new Map<string, BoundFieldHook>();
|
||||
const decoratorsMap = new Map<string, BoundFieldDecorator>();
|
||||
|
||||
for (const hook of hooks ?? []) {
|
||||
for (const decorator of decorators ?? []) {
|
||||
try {
|
||||
const resolvedDeps = Object.entries(hook.deps ?? {}).map(
|
||||
const resolvedDeps = Object.entries(decorator.deps ?? {}).map(
|
||||
([key, value]) => {
|
||||
const api = apiHolder.get(value);
|
||||
if (!api) {
|
||||
throw new Error(
|
||||
`Failed to resolve apiRef ${value.id} for form hook ${hook.id} it will be disabled`,
|
||||
`Failed to resolve apiRef ${value.id} for form decorator ${decorator.id} it will be disabled`,
|
||||
);
|
||||
}
|
||||
return [key, api];
|
||||
},
|
||||
);
|
||||
|
||||
hooksMap.set(hook.id, {
|
||||
fn: ctx => hook.fn(ctx, Object.fromEntries(resolvedDeps)),
|
||||
decoratorsMap.set(decorator.id, {
|
||||
fn: ctx => decorator.fn(ctx, Object.fromEntries(resolvedDeps)),
|
||||
});
|
||||
} catch (ex) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -55,6 +58,6 @@ export const useFormHooks = () => {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return hooksMap;
|
||||
}, [apiHolder, hooks]);
|
||||
return decoratorsMap;
|
||||
}, [apiHolder, decorators]);
|
||||
};
|
||||
Reference in New Issue
Block a user