feat: implemented useFormHooks hook for calling hooks on create
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -475,6 +475,7 @@ export async function createRouter(
|
||||
description: schema.description,
|
||||
schema,
|
||||
})),
|
||||
EXPERIMENTAL_formHooks: template.spec.EXPERIMENTAL_formHooks,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface TemplateEntityV1beta3 extends Entity {
|
||||
/**
|
||||
* Form hooks to be run
|
||||
*/
|
||||
EXPERIMENTAL_formHooks?: { id: string; input: JsonObject }[];
|
||||
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
|
||||
|
||||
@@ -538,6 +538,10 @@ export type TemplateParameterSchema = {
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
EXPERIMENTAL_formHooks?: {
|
||||
id: string;
|
||||
input?: JsonObject;
|
||||
}[];
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -26,12 +26,16 @@ import { makeStyles } from '@material-ui/core/styles';
|
||||
import { errorApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema';
|
||||
import { Stepper, type StepperProps } from '../Stepper/Stepper';
|
||||
import { SecretsContextProvider } from '../../../secrets/SecretsContext';
|
||||
import {
|
||||
SecretsContextProvider,
|
||||
useTemplateSecrets,
|
||||
} from '../../../secrets/SecretsContext';
|
||||
import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties';
|
||||
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
|
||||
import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { ScaffolderFormHook } from '../../extensions';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { useFormHooks } from '../../../../../scaffolder/src/alpha/hooks/useFormHooks';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
markdown: {
|
||||
@@ -53,7 +57,6 @@ export type WorkflowProps = {
|
||||
description?: string;
|
||||
namespace: string;
|
||||
templateName: string;
|
||||
formHooks?: ScaffolderFormHook[];
|
||||
components?: {
|
||||
ReviewStepComponent?: React.ComponentType<ReviewStepProps>;
|
||||
};
|
||||
@@ -74,7 +77,6 @@ export type WorkflowProps = {
|
||||
export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
const { title, description, namespace, templateName, onCreate, ...props } =
|
||||
workflowProps;
|
||||
|
||||
const analytics = useAnalytics();
|
||||
const styles = useStyles();
|
||||
const templateRef = stringifyEntityRef({
|
||||
@@ -82,17 +84,34 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
namespace: namespace,
|
||||
name: templateName,
|
||||
});
|
||||
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const { loading, manifest, error } = useTemplateParameterSchema(templateRef);
|
||||
|
||||
const sortedManifest = useFilteredSchemaProperties(manifest);
|
||||
|
||||
const minutesSaved = useTemplateTimeSavedMinutes(templateRef);
|
||||
const { setSecrets } = useTemplateSecrets();
|
||||
const formHooks = useFormHooks();
|
||||
|
||||
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
|
||||
await Promise.all(
|
||||
manifest.EXPERIMENTAL_formHooks.map(async hook => {
|
||||
const formHook = formHooks.get(hook.id);
|
||||
if (!formHook) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to find form hook', hook.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await formHook.fn({
|
||||
setSecrets,
|
||||
input: hook.input,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onCreate(formState);
|
||||
|
||||
const name =
|
||||
@@ -101,7 +120,15 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
value: minutesSaved,
|
||||
});
|
||||
},
|
||||
[onCreate, analytics, templateName, minutesSaved],
|
||||
[
|
||||
manifest?.EXPERIMENTAL_formHooks,
|
||||
formHooks,
|
||||
onCreate,
|
||||
analytics,
|
||||
templateName,
|
||||
minutesSaved,
|
||||
setSecrets,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,18 +14,17 @@
|
||||
* 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;
|
||||
setSecrets: (input: Record<string, string>) => void;
|
||||
};
|
||||
|
||||
export type ScaffolderInitialFormHook<
|
||||
export type ScaffolderFormHook<
|
||||
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {},
|
||||
TDeps extends { [key in string]: AnyApiRef } = { [key in string]: AnyApiRef },
|
||||
TInput = {
|
||||
TInput extends {} = {
|
||||
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
|
||||
},
|
||||
> = {
|
||||
@@ -43,11 +42,6 @@ export type ScaffolderInitialFormHook<
|
||||
) => 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.
|
||||
@@ -56,7 +50,7 @@ export type ScaffolderFormHook<TInput = any> = {
|
||||
export function createScaffolderFormHook<
|
||||
TDeps extends { [key in string]: AnyApiRef },
|
||||
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
|
||||
TInput = {
|
||||
TInput extends {} = {
|
||||
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
|
||||
},
|
||||
>(options: {
|
||||
@@ -71,7 +65,7 @@ export function createScaffolderFormHook<
|
||||
? { [key in keyof TDeps]: TDeps[key]['T'] }
|
||||
: never,
|
||||
) => Promise<void>;
|
||||
}): ScaffolderInitialFormHook<TInputSchema, TDeps, TInput> {
|
||||
}): ScaffolderFormHook<TInputSchema, TDeps, TInput> {
|
||||
return {
|
||||
...options,
|
||||
version: 'v1',
|
||||
|
||||
@@ -17,20 +17,23 @@
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import { scaffolderApiRef } from '../../api/ref';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const useTemplateParameterSchema = (templateRef: string) => {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -33,4 +33,5 @@ export type TemplateParameterSchema = {
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
EXPERIMENTAL_formHooks?: { id: string; input?: JsonObject }[];
|
||||
};
|
||||
|
||||
@@ -1,85 +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 { 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]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,50 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { ScaffolderFormHooksApi } from './types';
|
||||
import {
|
||||
ScaffolderFormHook,
|
||||
ScaffolderInitialFormHook,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { ScaffolderFormHook } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
export class DefaultScaffolderFormHooksApi implements ScaffolderFormHooksApi {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
formHooks: Array<ScaffolderInitialFormHook>;
|
||||
apiHolder: ApiHolder;
|
||||
hooks: Array<ScaffolderFormHook>;
|
||||
},
|
||||
) {}
|
||||
|
||||
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];
|
||||
},
|
||||
);
|
||||
static create(options: { hooks: ScaffolderFormHook[] }) {
|
||||
return new DefaultScaffolderFormHooksApi(options);
|
||||
}
|
||||
|
||||
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);
|
||||
async getFormHooks(): Promise<ScaffolderFormHook[]> {
|
||||
return this.options.hooks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
extensions={props.customFieldExtensions}
|
||||
formProps={props.formProps}
|
||||
layouts={props.layouts}
|
||||
EXPERIMENTAL_formHooks={props.EXPERIMENTAL_formHooks}
|
||||
/>
|
||||
</Page>
|
||||
</AnalyticsContext>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 '../api/FormHooksApi';
|
||||
import { createScaffolderFormHook } 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 React from 'react';
|
||||
import { formHooksApiRef } from '../api/ref';
|
||||
|
||||
describe('useFormHooks', () => {
|
||||
const mockApiRef = createApiRef<{
|
||||
test: (input: string) => void;
|
||||
}>({ id: 'test' });
|
||||
|
||||
const mockApiImplementation = { test: jest.fn() };
|
||||
|
||||
const mockHook = createScaffolderFormHook({
|
||||
id: 'test',
|
||||
deps: { mockApiRef },
|
||||
schema: {
|
||||
input: {
|
||||
test: z => z.string(),
|
||||
},
|
||||
},
|
||||
async fn({ input: { test } }, { mockApiRef: mock }) {
|
||||
mock.test(test);
|
||||
},
|
||||
});
|
||||
|
||||
it('should wrap up the form hooks', async () => {
|
||||
const renderedHook = renderHook(() => useFormHooks(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[mockApiRef, mockApiImplementation],
|
||||
[
|
||||
formHooksApiRef,
|
||||
// @ts-expect-error - todo
|
||||
DefaultScaffolderFormHooksApi.create({ hooks: [mockHook] }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const result = renderedHook.result.current!;
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const testHook = result.get('test')!;
|
||||
expect(testHook).toBeDefined();
|
||||
|
||||
await testHook.fn({
|
||||
setSecrets: () => {},
|
||||
input: { test: 'input value' },
|
||||
});
|
||||
|
||||
expect(mockApiImplementation.test).toHaveBeenCalledWith('input value');
|
||||
});
|
||||
|
||||
it('should skip failing deps', async () => {
|
||||
const { error } = await withLogCollector(async () => {
|
||||
const renderedHook = renderHook(() => useFormHooks(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
formHooksApiRef,
|
||||
// @ts-expect-error - todo
|
||||
DefaultScaffolderFormHooksApi.create({ hooks: [mockHook] }),
|
||||
],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const result = renderedHook.result.current!;
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
expect(error[0]).toMatchInlineSnapshot(
|
||||
`[Error: Failed to resolve apiRef test for form hook test]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { useApi, useApiHolder } from '@backstage/core-plugin-api';
|
||||
import { formHooksApiRef } from '../api/ref';
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import { useMemo } from 'react';
|
||||
import { ScaffolderFormHookContext } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
/** @internal */
|
||||
type BoundFieldHook = {
|
||||
fn: (ctx: ScaffolderFormHookContext<any>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useFormHooks = () => {
|
||||
const formHooksApi = useApi(formHooksApiRef);
|
||||
const { value: hooks } = useAsync(() => formHooksApi.getFormHooks(), []);
|
||||
const apiHolder = useApiHolder();
|
||||
|
||||
return useMemo(() => {
|
||||
const hooksMap = new Map<string, BoundFieldHook>();
|
||||
|
||||
for (const hook of hooks ?? []) {
|
||||
try {
|
||||
const resolvedDeps = Object.entries(hook.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`,
|
||||
);
|
||||
}
|
||||
return [key, api];
|
||||
},
|
||||
);
|
||||
|
||||
hooksMap.set(hook.id, {
|
||||
fn: ctx => hook.fn(ctx, Object.fromEntries(resolvedDeps)),
|
||||
});
|
||||
} catch (ex) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(ex);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return hooksMap;
|
||||
}, [apiHolder, hooks]);
|
||||
};
|
||||
@@ -84,7 +84,6 @@ export type RouterProps = {
|
||||
EXPERIMENTAL_TemplateListPageComponent?: React.ComponentType<TemplateListPageProps>;
|
||||
EXPERIMENTAL_TemplateWizardPageComponent?: React.ComponentType<TemplateWizardPageProps>;
|
||||
};
|
||||
EXPERIMENTAL_formHooks?: ScaffolderFormHook[];
|
||||
groups?: TemplateGroupFilter[];
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
headerOptions?: {
|
||||
@@ -164,7 +163,6 @@ 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