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:
@@ -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>
|
||||
|
||||
+27
-28
@@ -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',
|
||||
|
||||
+16
-1
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user