allow a custom ObjectFieldTemplate to be supplied through the ui:ObjectFieldTemplate field

Signed-off-by: Paul Cowan <paul.cowan@cutting.scot>
This commit is contained in:
Paul Cowan
2022-08-09 19:54:57 +01:00
committed by blam
parent cff14ff377
commit ff28494249
18 changed files with 315 additions and 4 deletions
@@ -55,6 +55,7 @@ type Props = {
widgets?: FormProps<any>['widgets'];
fields?: FormProps<any>['fields'];
finishButtonLabel?: string;
layout?: FormProps<any>['ObjectFieldTemplate'];
};
export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] {
@@ -119,6 +120,7 @@ export const MultistepJsonForm = (props: Props) => {
fields,
widgets,
finishButtonLabel,
layout,
} = props;
const [activeStep, setActiveStep] = useState(0);
const [disableButtons, setDisableButtons] = useState(false);
@@ -203,6 +205,7 @@ export const MultistepJsonForm = (props: Props) => {
</StepLabel>
<StepContent key={title}>
<Form
ObjectFieldTemplate={layout}
showErrorList={false}
fields={{ ...fieldOverrides, ...fields }}
widgets={widgets}
+24 -1
View File
@@ -45,6 +45,12 @@ import {
selectedTemplateRouteRef,
} from '../routes';
import { ListTasksPage } from './ListTasksPage';
import {
DEFAULT_SCAFFOLDER_LAYOUT,
LayoutOptions,
LAYOUTS_KEY,
LAYOUTS_WRAPPER_KEY,
} from '../layouts';
/**
* The props for the entrypoint `ScaffolderPage` component the plugin.
@@ -105,6 +111,19 @@ export const Router = (props: RouterProps) => {
),
),
];
const customLayouts = useElementFilter(outlet, elements =>
elements
.selectByComponentData({
key: LAYOUTS_WRAPPER_KEY,
})
.findComponentData<LayoutOptions>({
key: LAYOUTS_KEY,
}),
);
const layout = customLayouts?.[0] ?? DEFAULT_SCAFFOLDER_LAYOUT;
/**
* This component can be deleted once the older routes have been deprecated.
*/
@@ -142,7 +161,10 @@ export const Router = (props: RouterProps) => {
path={selectedTemplateRouteRef.path}
element={
<SecretsContextProvider>
<TemplatePage customFieldExtensions={fieldExtensions} />
<TemplatePage
customFieldExtensions={fieldExtensions}
layout={layout}
/>
</SecretsContextProvider>
}
/>
@@ -159,6 +181,7 @@ export const Router = (props: RouterProps) => {
<TemplateEditorPage
defaultPreviewTemplate={defaultPreviewTemplate}
customFieldExtensions={fieldExtensions}
layout={layout}
/>
</SecretsContextProvider>
}
@@ -16,6 +16,7 @@
import { makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import { FieldExtensionOptions } from '../../extensions';
import { LayoutOptions } from '../../layouts';
import { TemplateDirectoryAccess } from '../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { DryRunProvider } from './DryRunContext';
@@ -57,6 +58,7 @@ const useStyles = makeStyles({
export const TemplateEditor = (props: {
directory: TemplateDirectoryAccess;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layout?: LayoutOptions;
onClose?: () => void;
}) => {
const classes = useStyles();
@@ -77,6 +79,7 @@ export const TemplateEditor = (props: {
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layout={props.layout}
/>
</section>
<section className={classes.results}>
@@ -20,6 +20,7 @@ import React, { Component, ReactNode, useMemo, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import yaml from 'yaml';
import { FieldExtensionOptions } from '../../extensions';
import { DEFAULT_SCAFFOLDER_LAYOUT, LayoutOptions } from '../../layouts';
import { TemplateParameterSchema } from '../../types';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { createValidator } from '../TemplatePage';
@@ -83,6 +84,7 @@ interface TemplateEditorFormProps {
onDryRun?: (data: JsonObject) => Promise<void>;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layout?: LayoutOptions;
}
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
@@ -99,6 +101,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
onDryRun,
setErrorText,
fieldExtensions = [],
layout = DEFAULT_SCAFFOLDER_LAYOUT,
} = props;
const classes = useStyles();
const apiHolder = useApiHolder();
@@ -188,6 +191,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
onReset={() => onUpdate({})}
finishButtonLabel={onDryRun && 'Try It'}
onFinish={onDryRun && (() => onDryRun(data))}
layout={layout.component}
/>
</ErrorBoundary>
</div>
@@ -197,7 +201,10 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */
export function TemplateEditorFormDirectoryEditorDryRun(
props: Pick<TemplateEditorFormProps, 'setErrorText' | 'fieldExtensions'>,
props: Pick<
TemplateEditorFormProps,
'setErrorText' | 'fieldExtensions' | 'layout'
>,
) {
const { setErrorText, fieldExtensions = [] } = props;
const dryRun = useDryRun();
@@ -23,6 +23,7 @@ import { TemplateEditorIntro } from './TemplateEditorIntro';
import { TemplateEditor } from './TemplateEditor';
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
import { FieldExtensionOptions } from '../../extensions';
import { LayoutOptions } from '../../layouts';
type Selection =
| {
@@ -36,6 +37,7 @@ type Selection =
interface TemplateEditorPageProps {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
layout?: LayoutOptions;
}
export function TemplateEditorPage(props: TemplateEditorPageProps) {
@@ -48,6 +50,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
directory={selection.directory}
fieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
layout={props.layout}
/>
);
} else if (selection?.type === 'form') {
@@ -56,6 +59,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
defaultPreviewTemplate={props.defaultPreviewTemplate}
customFieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
layout={props.layout}
/>
);
} else {
@@ -33,6 +33,7 @@ import React, { useCallback, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import yaml from 'yaml';
import { FieldExtensionOptions } from '../../extensions';
import { DEFAULT_SCAFFOLDER_LAYOUT, LayoutOptions } from '../../layouts';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
@@ -110,10 +111,12 @@ export const TemplateFormPreviewer = ({
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
customFieldExtensions = [],
onClose,
layout = DEFAULT_SCAFFOLDER_LAYOUT,
}: {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
onClose?: () => void;
layout?: LayoutOptions;
}) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
@@ -208,6 +211,7 @@ export const TemplateFormPreviewer = ({
data={formState}
onUpdate={setFormState}
setErrorText={setErrorText}
layout={layout}
/>
</div>
</main>
@@ -39,6 +39,7 @@ import {
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { DEFAULT_SCAFFOLDER_LAYOUT, LayoutOptions } from '../../layouts';
const useTemplateParameterSchema = (templateRef: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
@@ -51,8 +52,10 @@ const useTemplateParameterSchema = (templateRef: string) => {
export const TemplatePage = ({
customFieldExtensions = [],
layout = DEFAULT_SCAFFOLDER_LAYOUT,
}: {
customFieldExtensions?: FieldExtensionOptions<any, any>[];
layout?: LayoutOptions;
}) => {
const apiHolder = useApiHolder();
const secretsContext = useContext(SecretsContext);
@@ -146,6 +149,7 @@ export const TemplatePage = ({
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
layout={layout.component}
steps={schema.steps.map(step => {
return {
...step,
@@ -0,0 +1,79 @@
/*
* Copyright 2022 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 React from 'react';
import { ObjectFieldTemplateProps, utils } from '@rjsf/core';
import { Button, Grid } from '@material-ui/core';
const { canExpand } = utils;
export const DefaultStepFormLayout = ({
DescriptionField,
description,
TitleField,
title,
properties,
required,
disabled,
readonly,
uiSchema,
idSchema,
schema,
formData,
onAddClick,
}: ObjectFieldTemplateProps) => {
return (
<>
<h1>THIS IS OUR OBJECTFIELDTEMPLATE!!!!!!</h1>
{(uiSchema['ui:title'] || title) && (
<TitleField
id={`${idSchema.$id}-title`}
title={title}
required={required}
/>
)}
{description && (
<DescriptionField
id={`${idSchema.$id}-description`}
description={description}
/>
)}
<Grid container spacing={2} style={{ marginTop: '10px' }}>
{properties.map((element, index) =>
// Remove the <Grid> if the inner element is hidden as the <Grid>
// itself would otherwise still take up space.
element.hidden ? (
element.content
) : (
<Grid item xs={12} key={index} style={{ marginBottom: '10px' }}>
{element.content}
</Grid>
),
)}
{canExpand(schema, uiSchema, formData) && (
<Grid container justifyContent="flex-end">
<Grid item>
<Button
className="object-property-expand"
onClick={onAddClick(schema)}
disabled={disabled || readonly}
/>
</Grid>
</Grid>
)}
</Grid>
</>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 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.
*/
export { DefaultStepFormLayout } from './DefaultStepFormLayout';
+21
View File
@@ -0,0 +1,21 @@
import { DefaultStepFormLayout } from '../components/layouts/DefaultStepFormLayout';
/*
* Copyright 2022 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.
*/
export const DEFAULT_SCAFFOLDER_LAYOUT = {
component: DefaultStepFormLayout,
name: 'DefaultStepFormLayout',
};
+46
View File
@@ -0,0 +1,46 @@
/*
* Copyright 2022 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 { attachComponentData, Extension } from '@backstage/core-plugin-api';
import type { ObjectFieldTemplateProps } from '@rjsf/core';
import type { LayoutOptions } from './types';
import type { FunctionComponent } from 'react';
export const LAYOUTS_KEY = 'scaffolder.layout.v1';
export const LAYOUTS_WRAPPER_KEY = 'scaffolder.layouts.wrapper.v1';
export function createScaffolderLayout(
options: LayoutOptions,
): Extension<FunctionComponent<ObjectFieldTemplateProps>> {
return {
expose() {
const LayoutDataHolder: any = () => null;
attachComponentData(LayoutDataHolder, LAYOUTS_KEY, options);
return LayoutDataHolder;
},
};
}
export const ScaffolderLayouts: React.ComponentType = (): JSX.Element | null =>
null;
attachComponentData(ScaffolderLayouts, LAYOUTS_WRAPPER_KEY, true);
export type { LayoutOptions } from './types';
export { DEFAULT_SCAFFOLDER_LAYOUT } from './default';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2022 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 { ObjectFieldTemplateProps } from '@rjsf/core';
import type { FunctionComponent } from 'react';
export type LayoutOptions = {
name: string;
component: FunctionComponent<ObjectFieldTemplateProps>;
};
@@ -22,7 +22,13 @@ import {
createScaffolderFieldExtension,
ScaffolderFieldExtensions,
} from '../../extensions';
import {
createScaffolderLayout,
DEFAULT_SCAFFOLDER_LAYOUT,
ScaffolderLayouts,
} from '../../layouts';
import { scaffolderPlugin } from '../../plugin';
import { ObjectFieldTemplateProps } from '@rjsf/core';
jest.mock('../TemplateListPage', () => ({
TemplateListPage: jest.fn(() => null),
@@ -81,5 +87,44 @@ describe('Router', () => {
]),
);
});
it('should use the default layout', async () => {
await renderInTestApp(<Router />, {
routeEntries: ['/templates/default/foo'],
});
const mock = TemplateWizardPage as jest.Mock;
const [{ layout }] = mock.mock.calls[0];
expect(layout).toEqual(DEFAULT_SCAFFOLDER_LAYOUT);
});
it('should extract the custom layout and pass it through', async () => {
const mockLayout = () => null;
const CustomLayout = scaffolderPlugin.provide(
createScaffolderLayout({
name: 'customLayout',
component: mockLayout,
}),
);
const props = {} as ObjectFieldTemplateProps;
await renderInTestApp(
<Router>
<ScaffolderLayouts>
<CustomLayout {...props} />
</ScaffolderLayouts>
</Router>,
{ routeEntries: ['/templates/default/foo'] },
);
const mock = TemplateWizardPage as jest.Mock;
// eslint-disable-next-line no-console
const [{ layout }] = mock.mock.calls[0];
expect(layout).toEqual({ name: 'customLayout', component: mockLayout });
});
});
});
+22 -1
View File
@@ -29,6 +29,12 @@ import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups';
import { nextSelectedTemplateRouteRef } from '../../routes';
import { SecretsContextProvider } from '../../components/secrets/SecretsContext';
import {
DEFAULT_SCAFFOLDER_LAYOUT,
LayoutOptions,
LAYOUTS_KEY,
LAYOUTS_WRAPPER_KEY,
} from '../../layouts';
/**
* The Props for the Scaffolder Router
@@ -75,6 +81,18 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
),
];
const customLayouts = useElementFilter(outlet, elements =>
elements
.selectByComponentData({
key: LAYOUTS_WRAPPER_KEY,
})
.findComponentData<LayoutOptions>({
key: LAYOUTS_KEY,
}),
);
const layout = customLayouts?.[0] ?? DEFAULT_SCAFFOLDER_LAYOUT;
return (
<Routes>
<Route
@@ -91,7 +109,10 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
path={nextSelectedTemplateRouteRef.path}
element={
<SecretsContextProvider>
<TemplateWizardPage customFieldExtensions={fieldExtensions} />
<TemplateWizardPage
customFieldExtensions={fieldExtensions}
layout={layout}
/>
</SecretsContextProvider>
}
/>
@@ -18,6 +18,7 @@ import { TemplateParameterSchema } from '../../../types';
import { Stepper } from './Stepper';
import { renderInTestApp } from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import { DEFAULT_SCAFFOLDER_LAYOUT } from '../../../layouts';
describe('Stepper', () => {
it('should render the step titles for each step of the manifest', async () => {
@@ -30,7 +31,11 @@ describe('Stepper', () => {
};
const { getByText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} />,
<Stepper
manifest={manifest}
extensions={[]}
layout={DEFAULT_SCAFFOLDER_LAYOUT}
/>,
);
for (const step of manifest.steps) {
@@ -134,6 +139,7 @@ describe('Stepper', () => {
<Stepper
manifest={manifest}
extensions={[{ name: 'Mock', component: MockComponent }]}
layout={DEFAULT_SCAFFOLDER_LAYOUT}
/>,
);
@@ -26,6 +26,7 @@ import { FieldValidation, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import React, { useMemo, useState } from 'react';
import { FieldExtensionOptions } from '../../../extensions';
import type { LayoutOptions } from '../../../layouts';
import { TemplateParameterSchema } from '../../../types';
import { createAsyncValidators } from './createAsyncValidators';
import { useTemplateSchema } from './useTemplateSchema';
@@ -49,6 +50,7 @@ const useStyles = makeStyles(theme => ({
export interface StepperProps {
manifest: TemplateParameterSchema;
extensions: FieldExtensionOptions<any, any>[];
layout?: LayoutOptions;
}
const Form = withTheme(MuiTheme);
@@ -41,6 +41,7 @@ describe('useTemplateSchema', () => {
description: 'Step 2 Description',
schema: {
type: 'object',
'ui:ObjectFieldTemplate': 'MyLayout',
properties: {
field2: { type: 'string', 'ui:field': 'MyCoolerComponent' },
},
@@ -74,6 +75,7 @@ describe('useTemplateSchema', () => {
expect(second.uiSchema).toEqual({
field2: { 'ui:field': 'MyCoolerComponent' },
'ui:ObjectFieldTemplate': 'MyLayout',
});
expect(second.schema).toEqual({
@@ -37,9 +37,11 @@ import { makeStyles } from '@material-ui/core';
import { Stepper } from './Stepper';
import { BackstageTheme } from '@backstage/theme';
import { nextRouteRef, selectedTemplateRouteRef } from '../../routes';
import type { LayoutOptions } from '../../layouts';
export interface TemplateWizardPageProps {
customFieldExtensions: FieldExtensionOptions<any, any>[];
layout?: LayoutOptions;
}
const useStyles = makeStyles<BackstageTheme>(() => ({
@@ -113,6 +115,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
<Stepper
manifest={manifest}
extensions={props.customFieldExtensions}
layout={props.layout}
/>
</InfoCard>
)}