feat: implemented useFormHooks hook for calling hooks on create

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-10-11 18:00:26 +02:00
parent 189201ef4b
commit aee334c14c
13 changed files with 226 additions and 148 deletions
@@ -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
+4
View File
@@ -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,
};
+1
View File
@@ -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>
}