scaffolder: fix NFS custom field explorer wiring (#33599)

* scaffolder: fix NFS custom field explorer wiring

Signed-off-by: Adam Kunicki <kunickiaj@gmail.com>

* refactor: use useMountEffect instead of useEffect for async execute

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: Adam Kunicki <kunickiaj@gmail.com>
Signed-off-by: benjdlambert <ben@blam.sh>
Co-authored-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Adam Kunicki
2026-04-14 04:57:58 -07:00
committed by GitHub
parent d3627319a1
commit 297302ebce
5 changed files with 190 additions and 40 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Fixed the NFS custom field explorer so loaded form fields render field options and previews correctly.
@@ -0,0 +1,69 @@
/*
* Copyright 2026 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 { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
import {
buildEditorFieldExtensions,
toFieldExtensionOptions,
} from './EditorSubPage';
describe('buildEditorFieldExtensions', () => {
it('includes default field extensions when no custom fields are loaded', () => {
expect(buildEditorFieldExtensions()).toEqual(
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
);
});
it('keeps loaded field extensions ahead of defaults and de-duplicates by name', () => {
const customField: FieldExtensionOptions = {
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS[0],
component: jest.fn(() => null),
} as FieldExtensionOptions;
const fieldExtensions = buildEditorFieldExtensions([customField]);
expect(fieldExtensions[0]).toBe(customField);
expect(
fieldExtensions.filter(field => field.name === customField.name),
).toHaveLength(1);
});
});
describe('toFieldExtensionOptions', () => {
it('flattens FieldSchema wrappers from form field blueprints', () => {
const field = {
$$type: '@backstage/scaffolder/FormField',
version: 'v1',
name: 'EntityPicker',
component: jest.fn(() => null),
schema: {
schema: {
returnValue: { type: 'string' },
uiOptions: { type: 'object' },
},
},
} as any;
expect(toFieldExtensionOptions(field)).toMatchObject({
name: 'EntityPicker',
schema: {
returnValue: { type: 'string' },
uiOptions: { type: 'object' },
},
});
});
});
@@ -14,13 +14,22 @@
* limitations under the License.
*/
import { useCallback } from 'react';
import { useAsync, useMountEffect } from '@react-hookz/web';
import { useCallback, useMemo } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';
import { Content } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { makeStyles } from '@material-ui/core/styles';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha';
import { SecretsContextProvider } from '@backstage/plugin-scaffolder-react';
import {
type FieldExtensionOptions,
SecretsContextProvider,
} from '@backstage/plugin-scaffolder-react';
import type { FormField } from '@backstage/plugin-scaffolder-react/alpha';
import { OpaqueFormField } from '@internal/scaffolder';
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
import { formFieldsApiRef } from '../formFieldsApi';
import { TemplateEditorIntro } from './TemplateEditorPage/TemplateEditorIntro';
import { TemplateEditor } from './TemplateEditorPage/TemplateEditor';
import { TemplateFormPreviewer } from './TemplateEditorPage/TemplateFormPreviewer';
@@ -68,16 +77,40 @@ function EditorIntroContent() {
);
}
function EditorContent() {
export function buildEditorFieldExtensions(
formFields: FieldExtensionOptions[] = [],
): FieldExtensionOptions[] {
return [
...formFields,
...(DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
({ name }) => !formFields.some(formField => formField.name === name),
) as FieldExtensionOptions[]),
];
}
export function toFieldExtensionOptions(
formField: FormField,
): FieldExtensionOptions {
const internal = OpaqueFormField.toInternal(formField);
return {
...internal,
schema: internal.schema?.schema ?? internal.schema,
} as FieldExtensionOptions;
}
function EditorContent(props: { fieldExtensions: FieldExtensionOptions[] }) {
const classes = useEditorStyles();
return (
<Content className={classes.editorContent}>
<TemplateEditor />
<TemplateEditor fieldExtensions={props.fieldExtensions} />
</Content>
);
}
function FormPreviewContent() {
function FormPreviewContent(props: {
fieldExtensions: FieldExtensionOptions[];
}) {
const classes = useEditorStyles();
const navigate = useNavigate();
@@ -87,15 +120,20 @@ function FormPreviewContent() {
return (
<Content className={classes.formContent}>
<TemplateFormPreviewer onClose={handleClose} />
<TemplateFormPreviewer
customFieldExtensions={props.fieldExtensions}
onClose={handleClose}
/>
</Content>
);
}
function CustomFieldsContent() {
function CustomFieldsContent(props: {
fieldExtensions: FieldExtensionOptions[];
}) {
return (
<Content>
<CustomFieldExplorer />
<CustomFieldExplorer customFieldExtensions={props.fieldExtensions} />
</Content>
);
}
@@ -107,14 +145,38 @@ function CustomFieldsContent() {
* @internal
*/
export function EditorSubPage() {
const formFieldsApi = useApi(formFieldsApiRef);
const [{ result: customFieldExtensions = [] }, { execute }] = useAsync(
async () => {
const formFields = await formFieldsApi.loadFormFields();
return formFields.map(toFieldExtensionOptions);
},
);
useMountEffect(execute);
const fieldExtensions = useMemo(
() => buildEditorFieldExtensions(customFieldExtensions),
[customFieldExtensions],
);
return (
<RequirePermission permission={templateManagementPermission}>
<SecretsContextProvider>
<Routes>
<Route index element={<EditorIntroContent />} />
<Route path="template" element={<EditorContent />} />
<Route path="template-form" element={<FormPreviewContent />} />
<Route path="custom-fields" element={<CustomFieldsContent />} />
<Route
path="template"
element={<EditorContent fieldExtensions={fieldExtensions} />}
/>
<Route
path="template-form"
element={<FormPreviewContent fieldExtensions={fieldExtensions} />}
/>
<Route
path="custom-fields"
element={<CustomFieldsContent fieldExtensions={fieldExtensions} />}
/>
</Routes>
</SecretsContextProvider>
</RequirePermission>
@@ -68,24 +68,26 @@ export const CustomFieldPlayground = ({
const [refreshKey, setRefreshKey] = useState(Date.now());
const [fieldFormState, setFieldFormState] = useState({});
const [selectedField, setSelectedField] = useState(fieldOptions[0]);
const sampleFieldTemplate = useMemo(
() =>
yaml.stringify({
parameters: [
{
title: `${selectedField.name} Example`,
properties: {
[selectedField.name]: {
type: selectedField.schema?.returnValue?.type,
'ui:field': selectedField.name,
'ui:options': fieldFormState,
},
const sampleFieldTemplate = useMemo(() => {
if (!selectedField) {
return '';
}
return yaml.stringify({
parameters: [
{
title: `${selectedField.name} Example`,
properties: {
[selectedField.name]: {
type: selectedField.schema?.returnValue?.type,
'ui:field': selectedField.name,
'ui:options': fieldFormState,
},
},
],
}),
[fieldFormState, selectedField],
);
},
],
});
}, [fieldFormState, selectedField]);
const fieldComponents = useMemo(() => {
return Object.fromEntries(
@@ -98,18 +100,15 @@ export const CustomFieldPlayground = ({
setSelectedField(selection);
setFieldFormState({});
},
[setFieldFormState, setSelectedField],
[],
);
const handleFieldConfigChange = useCallback(
(state: {}) => {
setFieldFormState(state);
// Force TemplateEditorForm to re-render since some fields
// may not be responsive to ui:option changes
setRefreshKey(Date.now());
},
[setFieldFormState, setRefreshKey],
);
const handleFieldConfigChange = useCallback((state: {}) => {
setFieldFormState(state);
// Force TemplateEditorForm to re-render since some fields
// may not be responsive to ui:option changes
setRefreshKey(Date.now());
}, []);
return (
<main className={classes.root}>
@@ -211,7 +210,7 @@ export const CustomFieldPlayground = ({
formContext={{ fieldFormState }}
onSubmit={e => handleFieldConfigChange(e.formData)}
validator={validator}
schema={selectedField.schema?.uiOptions || {}}
schema={selectedField?.schema?.uiOptions || {}}
experimental_defaultFormStateBehavior={{
allOf: 'populateDefaults',
}}
@@ -220,7 +219,7 @@ export const CustomFieldPlayground = ({
variant="contained"
color="primary"
type="submit"
disabled={!selectedField.schema?.uiOptions}
disabled={!selectedField?.schema?.uiOptions}
>
{t(
'templateEditorPage.customFieldExplorer.fieldForm.applyButtonTitle',
@@ -102,6 +102,21 @@ describe('TemplateEditorToolbar', () => {
).toBeInTheDocument();
});
it('should not crash when no custom fields are available', async () => {
await renderInTestApp(<TemplateEditorToolbar fieldExtensions={[]} />);
await userEvent.click(
screen.getByRole('button', { name: 'Custom Fields Explorer' }),
);
expect(
screen.getByPlaceholderText('Choose Custom Field Extension'),
).toHaveValue('');
expect(
screen.getByRole('heading', { name: 'Template Spec' }),
).toBeInTheDocument();
});
it('should open the installed actions documentation', async () => {
await renderInTestApp(
<ApiProvider apis={apis}>
@@ -123,7 +138,7 @@ describe('TemplateEditorToolbar', () => {
it('should accept custom toolbar actions', async () => {
await renderInTestApp(
<TemplateEditorToolbar>
<button>Custom action</button>
<button type="button">Custom action</button>
</TemplateEditorToolbar>,
);