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
+21 -8
View File
@@ -29,6 +29,7 @@ import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import { ScaffolderClient } from '../api';
import { formFieldsApiRef } from './formFieldsApi';
export const scaffolderPage = PageBlueprint.makeWithOverrides({
inputs: {
@@ -36,17 +37,29 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({
FormFieldBlueprint.dataRefs.formFieldLoader,
]),
},
factory(originalFactory, { inputs }) {
const formFieldLoaders = inputs.formFields.map(i =>
i.get(FormFieldBlueprint.dataRefs.formFieldLoader),
);
factory(originalFactory, { apis, inputs }) {
const formFieldsApi = apis.get(formFieldsApiRef);
return originalFactory({
routeRef: rootRouteRef,
path: '/create',
loader: () =>
import('../components/Router/Router').then(m => (
<m.InternalRouter formFieldLoaders={formFieldLoaders} />
)),
loader: async () => {
// Merge form fields from the API with old-style direct attachments
const apiFormFields = (await formFieldsApi?.loadFormFields()) ?? [];
const formFieldLoaders = inputs.formFields.map(output =>
output.get(FormFieldBlueprint.dataRefs.formFieldLoader),
);
// Resolve direct attachments and combine with API form fields
const loadedFormFields = await Promise.all(
formFieldLoaders.map(loader => loader()),
);
const formFields = [...apiFormFields, ...loadedFormFields];
return import('../components/Router/Router').then(m => (
<m.InternalRouter formFields={formFields} />
));
},
});
},
});
@@ -0,0 +1,71 @@
/*
* 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,
createApiRef,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha';
import { OpaqueFormField } from '@internal/scaffolder';
interface FormField {
readonly $$type: '@backstage/scaffolder/FormField';
}
interface ScaffolderFormFieldsApi {
loadFormFields(): Promise<FormField[]>;
}
const formFieldsApiRef = createApiRef<ScaffolderFormFieldsApi>({
id: 'plugin.scaffolder.form-fields-loader',
});
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: () => ({
async loadFormFields() {
const formFields = await Promise.all(
formFieldLoaders.map(loader => loader()),
);
const internalFormFields = formFields.map(
OpaqueFormField.toInternal,
);
return internalFormFields;
},
}),
}),
);
},
});
export { formFieldsApiRef };
-5
View File
@@ -25,9 +25,4 @@ export {
export { scaffolderTranslationRef } from '../translation';
export * from './api';
export {
formFieldsApiRef,
type ScaffolderFormFieldsApi,
} from '@backstage/plugin-scaffolder-react/alpha';
export { default } from './plugin';
+1 -1
View File
@@ -42,7 +42,7 @@ import {
scaffolderPage,
} from './extensions';
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { formFieldsApi } from '@backstage/plugin-scaffolder-react/alpha';
import { formFieldsApi } from './formFieldsApi';
import { formDecoratorsApi } from './api';
import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha';
import { useScaffolderTemplateIconLinkProps } from './hooks/useScaffolderTemplateIconLinkProps';
@@ -13,13 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ReactElement } from 'react';
import { Router } from './Router';
import {
renderInTestApp,
TestApiProvider,
TestAppOptions,
} from '@backstage/test-utils';
import { renderInTestApp } from '@backstage/test-utils';
import {
createScaffolderFieldExtension,
ScaffolderFieldExtensions,
@@ -30,23 +25,12 @@ import {
ScaffolderLayouts,
} from '@backstage/plugin-scaffolder-react';
import { TemplateListPage, TemplateWizardPage } from '../../alpha/components';
import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
jest.mock('../../alpha/components', () => ({
TemplateWizardPage: jest.fn(() => null),
TemplateListPage: jest.fn(() => null),
}));
const wrapInApisAndRender = (element: ReactElement, opts?: TestAppOptions) =>
renderInTestApp(
<TestApiProvider
apis={[[formFieldsApiRef, { getFormFields: async () => [] }]]}
>
{element}
</TestApiProvider>,
opts,
);
describe('Router', () => {
beforeEach(() => {
(TemplateWizardPage as jest.Mock).mockClear();
@@ -54,13 +38,13 @@ describe('Router', () => {
});
describe('/', () => {
it('should render the TemplateListPage', async () => {
await wrapInApisAndRender(<Router />);
await renderInTestApp(<Router />);
expect(TemplateListPage).toHaveBeenCalled();
});
it('should render user-provided TemplateListPage', async () => {
const { getByText } = await wrapInApisAndRender(
const { getByText } = await renderInTestApp(
<Router
components={{
EXPERIMENTAL_TemplateListPageComponent: () => <>foobar</>,
@@ -76,7 +60,7 @@ describe('Router', () => {
it('should render not found error page', async () => {
await expect(
wrapInApisAndRender(<Router />, {
renderInTestApp(<Router />, {
routeEntries: ['/foonotfounderror'],
}),
).rejects.toThrow('Reached NotFound Page');
@@ -85,7 +69,7 @@ describe('Router', () => {
describe('/templates/:templateName', () => {
it('should render the TemplateWizard page', async () => {
await wrapInApisAndRender(<Router />, {
await renderInTestApp(<Router />, {
routeEntries: ['/templates/default/foo'],
});
@@ -93,7 +77,7 @@ describe('Router', () => {
});
it('should render user-provided TemplateWizardPage', async () => {
const { getByText } = await wrapInApisAndRender(
const { getByText } = await renderInTestApp(
<Router
components={{
EXPERIMENTAL_TemplateWizardPageComponent: () => <>foobar</>,
@@ -110,7 +94,7 @@ describe('Router', () => {
it('should pass through the FormProps property', async () => {
const transformErrorsMock = jest.fn();
await wrapInApisAndRender(
await renderInTestApp(
<Router
formProps={{
transformErrors: transformErrorsMock,
@@ -142,7 +126,7 @@ describe('Router', () => {
}),
);
await wrapInApisAndRender(
await renderInTestApp(
<Router>
<ScaffolderFieldExtensions>
<CustomFieldExtension />
@@ -170,7 +154,7 @@ describe('Router', () => {
}),
);
await wrapInApisAndRender(
await renderInTestApp(
<Router>
<ScaffolderLayouts>
<Layout />
@@ -63,7 +63,6 @@ import { RequirePermission } from '@backstage/plugin-permission-react';
import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha';
import { useApp } from '@backstage/core-plugin-api';
import { OpaqueFormField } from '@internal/scaffolder';
import { useAsync, useMountEffect } from '@react-hookz/web';
import { TemplatingExtensionsPage } from '../TemplatingExtensionsPage';
import { FormField } from '@backstage/plugin-scaffolder-react/alpha';
@@ -116,7 +115,9 @@ export type RouterProps = {
*/
export const InternalRouter = (
props: PropsWithChildren<
RouterProps & { formFieldLoaders?: Array<() => Promise<FormField>> }
RouterProps & {
formFields?: Array<FormField>;
}
>,
) => {
const {
@@ -133,14 +134,13 @@ export const InternalRouter = (
} = props;
const outlet = useOutlet() || props.children;
const customFieldExtensions = useCustomFieldExtensions(outlet);
const loadedFieldExtensions = useFormFieldLoaders(props.formFieldLoaders);
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
const fieldExtensions = [
...customFieldExtensions,
...loadedFieldExtensions,
...(props.formFields?.map(OpaqueFormField.toInternal) ?? []),
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
({ name }) =>
!customFieldExtensions.some(
@@ -261,17 +261,3 @@ export const InternalRouter = (
export const Router = (props: PropsWithChildren<RouterProps>) => {
return <InternalRouter {...props} />;
};
function useFormFieldLoaders(
formFieldLoaders?: Array<() => Promise<FormField>>,
) {
const [{ result: loadedFieldExtensions }, { execute }] =
useAsync(async () => {
const loaded = await Promise.all(
(formFieldLoaders ?? []).map(loader => loader()),
);
return loaded.map(f => OpaqueFormField.toInternal(f));
}, []);
useMountEffect(execute);
return loadedFieldExtensions;
}
-6
View File
@@ -81,7 +81,6 @@ import { RepoBranchPicker } from './components/fields/RepoBranchPicker/RepoBranc
import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/schema';
import { formDecoratorsApiRef } from './alpha/api/ref';
import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi';
import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
import {
RepoOwnerPicker,
RepoOwnerPickerSchema,
@@ -115,11 +114,6 @@ export const scaffolderPlugin = createPlugin({
deps: {},
factory: () => DefaultScaffolderFormDecoratorsApi.create(),
}),
createApiFactory({
api: formFieldsApiRef,
deps: {},
factory: () => ({ getFormFields: async () => [] }),
}),
],
routes: {
root: rootRouteRef,