feat(TemplatePreviewPage): implement a view for previewing template form UIs

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2022-02-26 16:47:45 -05:00
parent 2d63d04743
commit d8716924d6
11 changed files with 584 additions and 30 deletions
+1
View File
@@ -200,6 +200,7 @@ export type RouterProps = {
titleComponent?: React_2.ReactNode;
filter: (entity: Entity) => boolean;
}>;
defaultPreviewTemplate?: string;
};
// @public
+6 -1
View File
@@ -34,7 +34,6 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@types/json-schema": "^7.0.9",
"@backstage/catalog-client": "^0.9.0",
"@backstage/catalog-model": "^0.13.0",
"@backstage/config": "^0.1.15",
@@ -49,11 +48,16 @@
"@backstage/plugin-scaffolder-common": "^0.3.0",
"@backstage/theme": "^0.2.15",
"@backstage/types": "^0.1.3",
"@codemirror/legacy-modes": "^0.19.0",
"@codemirror/panel": "^0.19.1",
"@codemirror/stream-parser": "^0.19.6",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@rjsf/core": "^3.2.1",
"@rjsf/material-ui": "^3.2.1",
"@types/json-schema": "^7.0.9",
"@uiw/react-codemirror": "^4.5.1",
"classnames": "^2.2.6",
"git-url-parse": "^11.6.0",
"humanize-duration": "^3.25.1",
@@ -66,6 +70,7 @@
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"use-immer": "^0.6.0",
"yaml": "^1.9.2",
"zen-observable": "^0.8.15"
},
"peerDependencies": {
@@ -51,7 +51,7 @@ type Props = {
formData: Record<string, any>;
onChange: (e: IChangeEvent) => void;
onReset: () => void;
onFinish: () => Promise<void>;
onFinish?: () => Promise<void>;
widgets?: FormProps<any>['widgets'];
fields?: FormProps<any>['fields'];
};
@@ -163,6 +163,10 @@ export const MultistepJsonForm = (props: Props) => {
};
const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0));
const handleCreate = async () => {
if (!onFinish) {
return;
}
setDisableButtons(true);
try {
await onFinish();
@@ -232,7 +236,7 @@ export const MultistepJsonForm = (props: Props) => {
variant="contained"
color="primary"
onClick={handleCreate}
disabled={disableButtons}
disabled={!onFinish || disableButtons}
>
Create
</Button>
+14 -1
View File
@@ -23,6 +23,7 @@ import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
import { ActionsPage } from './ActionsPage';
import { SecretsContextProvider } from './secrets/SecretsContext';
import { TemplatePreviewPage } from './TemplatePreviewPage';
import {
FieldExtensionOptions,
@@ -48,6 +49,7 @@ export type RouterProps = {
titleComponent?: React.ReactNode;
filter: (entity: Entity) => boolean;
}>;
defaultPreviewTemplate?: string;
};
/**
@@ -56,7 +58,7 @@ export type RouterProps = {
* @public
*/
export const Router = (props: RouterProps) => {
const { groups, components = {} } = props;
const { groups, components = {}, defaultPreviewTemplate } = props;
const { TemplateCardComponent, TaskPageComponent } = components;
@@ -104,6 +106,17 @@ export const Router = (props: RouterProps) => {
/>
<Route path="/tasks/:taskId" element={<TaskPageElement />} />
<Route path="/actions" element={<ActionsPage />} />
<Route
path="/preview"
element={
<SecretsContextProvider>
<TemplatePreviewPage
defaultPreviewTemplate={defaultPreviewTemplate}
customFieldExtensions={fieldExtensions}
/>
</SecretsContextProvider>
}
/>
</Routes>
);
};
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplatePage } from './TemplatePage';
export { createValidator, TemplatePage } from './TemplatePage';
@@ -0,0 +1,269 @@
/*
* 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, { useCallback, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import useDebounce from 'react-use/lib/useDebounce';
import { Entity } from '@backstage/catalog-model';
import { Content, Header, InfoCard, Page } from '@backstage/core-components';
import { alertApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api';
import {
catalogApiRef,
humanizeEntityRef,
} from '@backstage/plugin-catalog-react';
import { JsonObject } from '@backstage/types';
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
import { showPanel } from '@codemirror/panel';
import { StreamLanguage } from '@codemirror/stream-parser';
import {
FormControl,
Grid,
InputLabel,
LinearProgress,
makeStyles,
MenuItem,
Select,
} from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import CodeMirror from '@uiw/react-codemirror';
import yaml from 'yaml';
import { FieldExtensionOptions } from '../../extensions';
import { TemplateParameterSchema } from '../../types';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { createValidator } from '../TemplatePage';
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters:
- title: Fill in some steps
required:
- name
properties:
name:
title: Name
type: string
description: Unique name of the component
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
allowedKinds:
- Group
- title: Choose a location
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
`;
type TemplateOption = {
label: string;
value: Entity;
};
const useStyles = makeStyles({
templateSelect: {
marginBottom: '10px',
},
grid: {
height: '100%',
},
codeMirror: {
height: '95%',
},
});
export const TemplatePreviewPage = ({
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
customFieldExtensions = [],
}: {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
}) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const apiHolder = useApiHolder();
const [selectedTemplate, setSelectedTemplate] = useState('');
const [schema, setSchema] = useState<TemplateParameterSchema>({
title: '',
steps: [],
});
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
const [formState, setFormState] = useState({});
const { loading } = useAsync(
() =>
catalogApi
.getEntities({
filter: { kind: 'template' },
fields: [
'kind',
'metadata.namespace',
'metadata.name',
'metadata.title',
'spec.parameters',
],
})
.then(({ items }) =>
setTemplateOptions(
items.map(template => ({
label:
template.metadata.title ??
humanizeEntityRef(template, { defaultKind: 'template' }),
value: template,
})),
),
)
.catch(e =>
alertApi.post({
message: `Error loading exisiting templates: ${e.message}`,
severity: 'error',
}),
),
[catalogApi],
);
const errorPanel = document.createElement('div');
errorPanel.style.color = 'red';
useDebounce(
() => {
try {
const parsedTemplate = yaml.parse(templateYaml);
setSchema({
title: 'Preview',
steps: parsedTemplate.parameters.map((param: JsonObject) => ({
title: param.title,
schema: param,
})),
});
setFormState({});
} catch (e) {
errorPanel.textContent = e.message;
}
},
250,
[setFormState, setSchema, templateYaml],
);
const handleSelectChange = useCallback(
selected => {
setSelectedTemplate(selected);
setTemplateYaml(yaml.stringify(selected.spec));
},
[setTemplateYaml],
);
const handleFormReset = () => setFormState({});
const handleFormChange = useCallback(
(e: IChangeEvent) => setFormState(e.formData),
[setFormState],
);
const handleCodeChange = useCallback(
(code: string) => {
setTemplateYaml(code);
},
[setTemplateYaml],
);
const customFieldComponents = Object.fromEntries(
customFieldExtensions.map(({ name, component }) => [name, component]),
);
const customFieldValidators = Object.fromEntries(
customFieldExtensions.map(({ name, validation }) => [name, validation]),
);
return (
<Page themeId="home">
<Header
title="Template Preview"
subtitle="Preview your template parameter UI"
/>
<Content>
{loading && <LinearProgress />}
<Grid container className={classes.grid}>
<Grid item xs={6}>
<FormControl
className={classes.templateSelect}
variant="outlined"
fullWidth
>
<InputLabel id="select-template-label">
Load Existing Template
</InputLabel>
<Select
value={selectedTemplate}
label="Load Existing Template"
labelId="select-template-label"
onChange={e => handleSelectChange(e.target.value)}
>
{templateOptions.map((option, idx) => (
<MenuItem key={idx} value={option.value as any}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<CodeMirror
className={classes.codeMirror}
value={templateYaml}
theme="dark"
height="100%"
extensions={[
StreamLanguage.define(yamlSupport),
showPanel.of(() => ({ dom: errorPanel, top: true })),
]}
onChange={handleCodeChange}
/>
</Grid>
<Grid item xs={6}>
{schema && (
<InfoCard key={JSON.stringify(schema)}>
<MultistepJsonForm
formData={formState}
fields={customFieldComponents}
onChange={handleFormChange}
onReset={handleFormReset}
steps={schema.steps.map(step => {
return {
...step,
validate: createValidator(
step.schema,
customFieldValidators,
{ apiHolder },
),
};
})}
/>
</InfoCard>
)}
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -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 { TemplatePreviewPage } from './TemplatePreviewPage';