feat(TemplatePreviewPage): implement a view for previewing template form UIs
Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
@@ -200,6 +200,7 @@ export type RouterProps = {
|
||||
titleComponent?: React_2.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
}>;
|
||||
defaultPreviewTemplate?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user