diff --git a/.changeset/fix-scaffolder-nfs-custom-field-explorer.md b/.changeset/fix-scaffolder-nfs-custom-field-explorer.md new file mode 100644 index 0000000000..4a9b33e286 --- /dev/null +++ b/.changeset/fix-scaffolder-nfs-custom-field-explorer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Fixed the NFS custom field explorer so loaded form fields render field options and previews correctly. diff --git a/plugins/scaffolder/src/alpha/components/EditorSubPage.test.tsx b/plugins/scaffolder/src/alpha/components/EditorSubPage.test.tsx new file mode 100644 index 0000000000..4dc01095e7 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/EditorSubPage.test.tsx @@ -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' }, + }, + }); + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx index 8667098e7d..9c955d69bc 100644 --- a/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx +++ b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx @@ -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 ( - + ); } -function FormPreviewContent() { +function FormPreviewContent(props: { + fieldExtensions: FieldExtensionOptions[]; +}) { const classes = useEditorStyles(); const navigate = useNavigate(); @@ -87,15 +120,20 @@ function FormPreviewContent() { return ( - + ); } -function CustomFieldsContent() { +function CustomFieldsContent(props: { + fieldExtensions: FieldExtensionOptions[]; +}) { return ( - + ); } @@ -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 ( } /> - } /> - } /> - } /> + } + /> + } + /> + } + /> diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldPlayground.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldPlayground.tsx index 88e192d510..fcc77449af 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldPlayground.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldPlayground.tsx @@ -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 (
@@ -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', diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx index 2f09a79317..346aa9dd10 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.test.tsx @@ -102,6 +102,21 @@ describe('TemplateEditorToolbar', () => { ).toBeInTheDocument(); }); + it('should not crash when no custom fields are available', async () => { + await renderInTestApp(); + + 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( @@ -123,7 +138,7 @@ describe('TemplateEditorToolbar', () => { it('should accept custom toolbar actions', async () => { await renderInTestApp( - + , );