scaffolder: migrate nfs form fields to utility API

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-26 11:10:06 +01:00
parent 22dce2b644
commit 2eeca031c4
17 changed files with 95 additions and 279 deletions
+5 -50
View File
@@ -3,19 +3,14 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiRef } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react';
import { Dispatch } from 'react';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { FieldSchema } from '@backstage/plugin-scaffolder-react';
@@ -26,7 +21,6 @@ import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { Overrides } from '@material-ui/core/styles/overrides';
import { PropsWithChildren } from 'react';
import { ReactElement } from 'react';
@@ -204,39 +198,6 @@ export type FormFieldExtensionData<
schema?: FieldSchema<z.output<TReturnValue>, z.output<TUiOptions>>;
};
// @alpha (undocumented)
export const formFieldsApi: OverridableExtensionDefinition<{
config: {};
configInput: {};
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
inputs: {
formFields: ExtensionInput<
ConfigurableExtensionDataRef<
() => Promise<FormField>,
'scaffolder.form-field-loader',
{}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
kind: 'api';
name: 'form-fields';
params: <
TApi,
TImpl extends TApi,
TDeps extends { [name in string]: unknown },
>(
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
// @alpha @deprecated (undocumented)
export const formFieldsApiRef: ApiRef<ScaffolderFormFieldsApi>;
// @alpha (undocumented)
export type FormValidation = {
[name: string]: FieldValidation | FormValidation;
@@ -311,12 +272,6 @@ export type ScaffolderFormDecoratorContext<
) => void;
};
// @alpha @deprecated (undocumented)
export interface ScaffolderFormFieldsApi {
// (undocumented)
getFormFields(): Promise<FormFieldExtensionData[]>;
}
// @alpha (undocumented)
export function ScaffolderPageContextMenu(
props: ScaffolderPageContextMenuProps,
@@ -345,6 +300,11 @@ export const scaffolderReactTranslationRef: TranslationRef<
'scaffolder-react',
{
readonly 'workflow.noDescription': 'No description';
readonly 'stepper.backButtonText': 'Back';
readonly 'stepper.createButtonText': 'Create';
readonly 'stepper.reviewButtonText': 'Review';
readonly 'stepper.nextButtonText': 'Next';
readonly 'stepper.stepIndexLabel': 'Step {{index, number}}';
readonly 'passwordWidget.content': 'This widget is insecure. Please use [`ui:field: Secret`](https://backstage.io/docs/features/software-templates/writing-templates/#using-secrets) instead of `ui:widget: password`';
readonly 'scaffolderPageContextMenu.createLabel': 'Create';
readonly 'scaffolderPageContextMenu.moreLabel': 'more';
@@ -352,11 +312,6 @@ export const scaffolderReactTranslationRef: TranslationRef<
readonly 'scaffolderPageContextMenu.actionsLabel': 'Installed Actions';
readonly 'scaffolderPageContextMenu.tasksLabel': 'Task List';
readonly 'scaffolderPageContextMenu.templatingExtensionsLabel': 'Templating Extensions';
readonly 'stepper.backButtonText': 'Back';
readonly 'stepper.createButtonText': 'Create';
readonly 'stepper.reviewButtonText': 'Review';
readonly 'stepper.nextButtonText': 'Next';
readonly 'stepper.stepIndexLabel': 'Step {{index, number}}';
readonly 'templateCategoryPicker.title': 'Categories';
readonly 'templateCard.noDescription': 'No description';
readonly 'templateCard.chooseButtonText': 'Choose';
@@ -16,9 +16,8 @@
import { PropsWithChildren } from 'react';
import { createPlugin } from '@backstage/core-plugin-api';
import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
import { renderHook, waitFor } from '@testing-library/react';
import { ScaffolderFormFieldsApi, formFieldsApiRef } from '../alpha';
import { wrapInTestApp } from '@backstage/test-utils';
import { renderHook } from '@testing-library/react';
import { useCustomFieldExtensions } from './useCustomFieldExtensions';
import {
ScaffolderFieldExtensions,
@@ -33,22 +32,10 @@ const plugin = createPlugin({
});
describe('useCustomFieldExtensions', () => {
const mockFormFieldsApi: jest.Mocked<ScaffolderFormFieldsApi> = {
getFormFields: jest.fn(),
};
const wrapper = ({ children }: PropsWithChildren<{}>) =>
wrapInTestApp(
<TestApiProvider apis={[[formFieldsApiRef, mockFormFieldsApi]]}>
{children}
</TestApiProvider>,
);
beforeEach(() => {
jest.resetAllMocks();
});
wrapInTestApp(<>{children}</>);
it('should return field extensions from the React tree', async () => {
mockFormFieldsApi.getFormFields.mockResolvedValue([]);
const CustomFieldExtension = plugin.provide(
createScaffolderFieldExtension({
name: 'test',
@@ -70,60 +57,4 @@ describe('useCustomFieldExtensions', () => {
expect(result.current).toEqual([expect.objectContaining({ name: 'test' })]);
});
it('should return field extensions from formFieldsApi', async () => {
mockFormFieldsApi.getFormFields.mockResolvedValue([
{
name: 'blueprint',
component: () => <div>Test</div>,
},
]);
const { result } = renderHook(() => useCustomFieldExtensions(<div />), {
wrapper,
});
await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
});
expect(result.current).toEqual([
expect.objectContaining({ name: 'blueprint' }),
]);
});
it('should return field extensions from both sources', async () => {
mockFormFieldsApi.getFormFields.mockResolvedValue([
{
name: 'blueprint',
component: () => <div>Test</div>,
},
]);
const CustomFieldExtension = plugin.provide(
createScaffolderFieldExtension({
name: 'test',
component: () => <div>Test</div>,
}),
);
const { result } = renderHook(
() =>
useCustomFieldExtensions(
<ScaffolderFieldExtensions>
<CustomFieldExtension />
</ScaffolderFieldExtensions>,
),
{
wrapper,
},
);
await waitFor(() => {
expect(result.current).toHaveLength(2);
});
const fieldNames = result.current.map(field => field.name);
expect(fieldNames).toEqual(expect.arrayContaining(['test', 'blueprint']));
});
});
@@ -13,9 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useAsync, useMountEffect } from '@react-hookz/web';
import { useApi, useElementFilter } from '@backstage/core-plugin-api';
import { formFieldsApiRef } from '../next';
import { useElementFilter } from '@backstage/core-plugin-api';
import { FieldExtensionOptions } from '../extensions';
import {
FIELD_EXTENSION_KEY,
@@ -32,14 +30,6 @@ export const useCustomFieldExtensions = <
>(
outlet: React.ReactNode,
) => {
// Get custom fields created with FormFieldBlueprint
const formFieldsApi = useApi(formFieldsApiRef);
const [{ result: blueprintFields }, { execute }] = useAsync(
() => formFieldsApi.getFormFields(),
[],
);
useMountEffect(execute);
// Get custom fields created with ScaffolderFieldExtensions
const outletFields = useElementFilter(outlet, elements =>
elements
@@ -51,17 +41,5 @@ export const useCustomFieldExtensions = <
}),
);
// This should really be a different type moving forward, but we do this to keep type compatibility.
// should probably also move the defaults into the API eventually too, but that will come with the move
// to the new frontend system.
const blueprintsToLegacy: FieldExtensionOptions[] = blueprintFields?.map(
field => ({
component: field.component,
name: field.name,
validation: field.validation,
schema: field.schema?.schema,
}),
);
return [...blueprintsToLegacy, ...outletFields] as TComponentDataType[];
return outletFields as TComponentDataType[];
};
@@ -1,65 +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 {
ApiBlueprint,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { formFieldsApiRef } from './ref';
import { FormField, ScaffolderFormFieldsApi } from './types';
import { FormFieldBlueprint } from '../blueprints';
import { OpaqueFormField } from '@internal/scaffolder';
class DefaultScaffolderFormFieldsApi implements ScaffolderFormFieldsApi {
private readonly formFieldLoaders: Array<() => Promise<FormField>>;
constructor(formFieldLoaders: Array<() => Promise<FormField>> = []) {
this.formFieldLoaders = formFieldLoaders;
}
async getFormFields() {
const formFields = await Promise.all(
this.formFieldLoaders.map(loader => loader()),
);
const internalFormFields = formFields.map(OpaqueFormField.toInternal);
return internalFormFields;
}
}
/** @alpha */
export const formFieldsApi = ApiBlueprint.makeWithOverrides({
name: 'form-fields',
inputs: {
formFields: createExtensionInput([
FormFieldBlueprint.dataRefs.formFieldLoader,
]),
},
factory(originalFactory, { inputs }) {
const formFieldLoaders = inputs.formFields.map(e =>
e.get(FormFieldBlueprint.dataRefs.formFieldLoader),
);
return originalFactory(defineParams =>
defineParams({
api: formFieldsApiRef,
deps: {},
factory: () => new DefaultScaffolderFormFieldsApi(formFieldLoaders),
}),
);
},
});
@@ -14,6 +14,4 @@
* limitations under the License.
*/
export { formFieldsApi } from './FormFieldsApi';
export { formFieldsApiRef } from './ref';
export type { ScaffolderFormFieldsApi, FormField } from './types';
export { type FormField } from './types';
@@ -1,26 +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 { createApiRef } from '@backstage/frontend-plugin-api';
import { ScaffolderFormFieldsApi } from './types';
/**
* @alpha
* @deprecated This API is no longer necessary and will be removed
*/
export const formFieldsApiRef = createApiRef<ScaffolderFormFieldsApi>({
id: 'plugin.scaffolder.form-fields',
});
@@ -14,16 +14,6 @@
* limitations under the License.
*/
import { FormFieldExtensionData } from '../blueprints';
/**
* @alpha
* @deprecated This API is no longer necessary and will be removed
*/
export interface ScaffolderFormFieldsApi {
getFormFields(): Promise<FormFieldExtensionData[]>;
}
/** @alpha */
export interface FormField {
readonly $$type: '@backstage/scaffolder/FormField';
@@ -35,10 +35,7 @@ const formFieldExtensionDataRef = createExtensionDataRef<
* */
export const FormFieldBlueprint = createExtensionBlueprint({
kind: 'scaffolder-form-field',
attachTo: [
{ id: 'page:scaffolder', input: 'formFields' },
{ id: 'api:scaffolder/form-fields', input: 'formFields' },
],
attachTo: { id: 'api:scaffolder/form-fields', input: 'formFields' },
dataRefs: {
formFieldLoader: formFieldExtensionDataRef,
},