wip: start to implement the hooks

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-10-11 10:42:06 +02:00
parent 56eff0b621
commit 189201ef4b
15 changed files with 358 additions and 5 deletions
@@ -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);
}
}
+5 -1
View File
@@ -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',
});
+8 -1
View File
@@ -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>
}