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(
-
+
,
);