Merge pull request #13112 from dagda1/step-layout

Provide API to extend scaffolder form layout
This commit is contained in:
Ben Lambert
2022-09-20 13:43:55 +02:00
committed by GitHub
16 changed files with 246 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Ability to modify the layout of the step form
+5
View File
@@ -64,6 +64,7 @@ import {
ScaffolderPage,
NextScaffolderPage,
scaffolderPlugin,
ScaffolderLayouts,
} from '@backstage/plugin-scaffolder';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
@@ -104,6 +105,7 @@ import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common';
import { PlaylistIndexPage } from '@backstage/plugin-playlist';
import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts';
const app = createApp({
apis,
@@ -218,6 +220,9 @@ const routes = (
<ScaffolderFieldExtensions>
<LowerCaseValuePickerFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
</ScaffolderLayouts>
</Route>
<Route
path="/create/next"
@@ -0,0 +1,54 @@
/*
* 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 {
createScaffolderLayout,
LayoutTemplate,
scaffolderPlugin,
} from '@backstage/plugin-scaffolder';
import { Grid } from '@material-ui/core';
const TwoColumn: LayoutTemplate = ({ properties, description, title }) => {
const mid = Math.ceil(properties.length / 2);
const left = properties.slice(0, mid);
const right = properties.slice(mid);
return (
<>
<h1>{title}</h1>
<h2>In two column layout!!</h2>
<Grid container justifyContent="flex-end">
{left.map(prop => (
<Grid item xs={6} key={prop.content.key}>
{prop.content}
</Grid>
))}
{right.map(prop => (
<Grid item xs={6} key={prop.content.key}>
{prop.content}
</Grid>
))}
</Grid>
{description}
</>
);
};
export const TwoColumnLayout = scaffolderPlugin.provide(
createScaffolderLayout({
name: 'TwoColumn',
component: TwoColumn,
}),
);
@@ -17,6 +17,7 @@
export const defaultPreviewTemplate = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters:
- title: Fill in some steps
ui:ObjectFieldTemplate: TwoColumn
required:
- name
properties:
+23
View File
@@ -16,6 +16,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { FieldProps } from '@rjsf/core';
import { FieldValidation } from '@rjsf/core';
import type { FormProps } from '@rjsf/core';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
@@ -37,6 +38,11 @@ export function createScaffolderFieldExtension<
options: FieldExtensionOptions<TReturnValue, TInputProps>,
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>>;
// @public
export function createScaffolderLayout<TInputProps = unknown>(
options: LayoutOptions,
): Extension<LayoutComponent<TInputProps>>;
// @public
export type CustomFieldValidator<TFieldReturnValue> = (
data: TFieldReturnValue,
@@ -110,6 +116,20 @@ export type FieldExtensionOptions<
validation?: CustomFieldValidator<TFieldReturnValue>;
};
// @public
export type LayoutComponent<_TInputProps> = () => null;
// @public
export interface LayoutOptions<P = any> {
// (undocumented)
component: LayoutTemplate<P>;
// (undocumented)
name: string;
}
// @public
export type LayoutTemplate<T = any> = FormProps<T>['ObjectFieldTemplate'];
// @public
export type ListActionsResponse = Array<{
id: string;
@@ -349,6 +369,9 @@ export interface ScaffolderGetIntegrationsListResponse {
}[];
}
// @public
export const ScaffolderLayouts: React.ComponentType;
// @public (undocumented)
export type ScaffolderOutputLink = {
title?: string;
@@ -36,6 +36,7 @@ import { transformSchemaToProps } from './schema';
import { Content, StructuredMetadataTable } from '@backstage/core-components';
import cloneDeep from 'lodash/cloneDeep';
import * as fieldOverrides from './FieldOverrides';
import { LayoutOptions } from '../../layouts';
const Form = withTheme(MuiTheme);
type Step = {
@@ -55,6 +56,7 @@ type Props = {
widgets?: FormProps<any>['widgets'];
fields?: FormProps<any>['fields'];
finishButtonLabel?: string;
layouts: LayoutOptions[];
};
export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] {
@@ -119,6 +121,7 @@ export const MultistepJsonForm = (props: Props) => {
fields,
widgets,
finishButtonLabel,
layouts,
} = props;
const [activeStep, setActiveStep] = useState(0);
const [disableButtons, setDisableButtons] = useState(false);
@@ -214,7 +217,7 @@ export const MultistepJsonForm = (props: Props) => {
if (e.errors.length === 0) handleNext();
}}
{...formProps}
{...transformSchemaToProps(schema)}
{...transformSchemaToProps(schema, layouts)}
>
<Button disabled={activeStep === 0} onClick={handleBack}>
Back
@@ -15,7 +15,8 @@
*/
import { JsonObject } from '@backstage/types';
import { FormProps } from '@rjsf/core';
import { FormProps, UiSchema } from '@rjsf/core';
import { LayoutOptions } from '../../layouts';
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -99,14 +100,29 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
}
}
export function transformSchemaToProps(inputSchema: JsonObject): {
export function transformSchemaToProps(
inputSchema: JsonObject,
layouts: LayoutOptions[] = [],
): {
schema: FormProps<any>['schema'];
uiSchema: FormProps<any>['uiSchema'];
} {
const customLayoutName = inputSchema['ui:ObjectFieldTemplate'];
inputSchema.type = inputSchema.type || 'object';
const schema = JSON.parse(JSON.stringify(inputSchema));
delete schema.title; // Rendered separately
const uiSchema = {};
const uiSchema: UiSchema = {};
extractUiSchema(schema, uiSchema);
if (customLayoutName) {
const Layout = layouts.find(
layout => layout.name === customLayoutName,
)?.component;
if (Layout) {
uiSchema['ui:ObjectFieldTemplate'] = Layout;
}
}
return { schema, uiSchema };
}
+17 -1
View File
@@ -45,6 +45,7 @@ import {
selectedTemplateRouteRef,
} from '../routes';
import { ListTasksPage } from './ListTasksPage';
import { LayoutOptions, LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from '../layouts';
/**
* The props for the entrypoint `ScaffolderPage` component the plugin.
@@ -105,6 +106,17 @@ export const Router = (props: RouterProps) => {
),
),
];
const customLayouts = useElementFilter(outlet, elements =>
elements
.selectByComponentData({
key: LAYOUTS_WRAPPER_KEY,
})
.findComponentData<LayoutOptions>({
key: LAYOUTS_KEY,
}),
);
/**
* This component can be deleted once the older routes have been deprecated.
*/
@@ -142,7 +154,10 @@ export const Router = (props: RouterProps) => {
path={selectedTemplateRouteRef.path}
element={
<SecretsContextProvider>
<TemplatePage customFieldExtensions={fieldExtensions} />
<TemplatePage
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
/>
</SecretsContextProvider>
}
/>
@@ -159,6 +174,7 @@ export const Router = (props: RouterProps) => {
<TemplateEditorPage
defaultPreviewTemplate={defaultPreviewTemplate}
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
/>
</SecretsContextProvider>
}
@@ -16,6 +16,7 @@
import { makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import { FieldExtensionOptions } from '../../extensions';
import type { 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>[];
layouts?: LayoutOptions[];
onClose?: () => void;
}) => {
const classes = useStyles();
@@ -77,6 +79,7 @@ export const TemplateEditor = (props: {
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layouts={props.layouts}
/>
</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 { 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>[];
layouts?: LayoutOptions[];
}
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
@@ -99,6 +101,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
onDryRun,
setErrorText,
fieldExtensions = [],
layouts = [],
} = props;
const classes = useStyles();
const apiHolder = useApiHolder();
@@ -138,6 +141,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
}
const { parameters } = rootObj;
if (!Array.isArray(parameters)) {
setErrorText('Template parameters must be an array');
setSteps(undefined);
@@ -188,6 +192,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
onReset={() => onUpdate({})}
finishButtonLabel={onDryRun && 'Try It'}
onFinish={onDryRun && (() => onDryRun(data))}
layouts={layouts}
/>
</ErrorBoundary>
</div>
@@ -197,7 +202,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' | 'layouts'
>,
) {
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 type { LayoutOptions } from '../../layouts';
type Selection =
| {
@@ -36,6 +37,7 @@ type Selection =
interface TemplateEditorPageProps {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}
export function TemplateEditorPage(props: TemplateEditorPageProps) {
@@ -48,6 +50,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
directory={selection.directory}
fieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
layouts={props.layouts}
/>
);
} else if (selection?.type === 'form') {
@@ -56,6 +59,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
defaultPreviewTemplate={props.defaultPreviewTemplate}
customFieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
layouts={props.layouts}
/>
);
} 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 { 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,
layouts = [],
}: {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
onClose?: () => void;
layouts?: LayoutOptions[];
}) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
@@ -208,6 +211,7 @@ export const TemplateFormPreviewer = ({
data={formState}
onUpdate={setFormState}
setErrorText={setErrorText}
layouts={layouts}
/>
</div>
</main>
@@ -39,6 +39,7 @@ import {
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { LayoutOptions } from '../../layouts';
const useTemplateParameterSchema = (templateRef: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
@@ -51,8 +52,10 @@ const useTemplateParameterSchema = (templateRef: string) => {
export const TemplatePage = ({
customFieldExtensions = [],
layouts = [],
}: {
customFieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}) => {
const apiHolder = useApiHolder();
const secretsContext = useContext(SecretsContext);
@@ -146,6 +149,7 @@ export const TemplatePage = ({
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
layouts={layouts}
steps={schema.steps.map(step => {
return {
...step,
+2
View File
@@ -48,6 +48,8 @@ export type {
FieldExtensionComponentProps,
FieldExtensionComponent,
} from './extensions';
export { createScaffolderLayout, ScaffolderLayouts } from './layouts';
export type { LayoutOptions, LayoutTemplate, LayoutComponent } from './layouts';
export {
EntityPickerFieldExtension,
EntityNamePickerFieldExtension,
+59
View File
@@ -0,0 +1,59 @@
/*
* 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 { LayoutOptions } from './types';
export const LAYOUTS_KEY = 'scaffolder.layout.v1';
export const LAYOUTS_WRAPPER_KEY = 'scaffolder.layouts.wrapper.v1';
/**
* The type used to wrap up the Layout and embed the input props
*
* @public
*/
export type LayoutComponent<_TInputProps> = () => null;
/**
* Method for creating custom Layouts that can be used in the scaffolder frontend form
*
* @public
*/
export function createScaffolderLayout<TInputProps = unknown>(
options: LayoutOptions,
): Extension<LayoutComponent<TInputProps>> {
return {
expose() {
const LayoutDataHolder: any = () => null;
attachComponentData(LayoutDataHolder, LAYOUTS_KEY, options);
return LayoutDataHolder;
},
};
}
/**
* The wrapping component for defining scaffolder layouts as children
*
* @public
*/
export const ScaffolderLayouts: React.ComponentType = (): JSX.Element | null =>
null;
attachComponentData(ScaffolderLayouts, LAYOUTS_WRAPPER_KEY, true);
export type { LayoutOptions, LayoutTemplate } from './types';
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 type { FormProps } from '@rjsf/core';
/**
* The field template from \@rjsf/core which is a react component that gets passed \@rjsf/core field related props.
*
* @public
*/
export type LayoutTemplate<T = any> = FormProps<T>['ObjectFieldTemplate'];
/**
* The type of layouts that is passed to the TemplateForms
*
* @public
*/
export interface LayoutOptions<P = any> {
name: string;
component: LayoutTemplate<P>;
}