feat: enhance template editor usability

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-09-19 01:29:24 +02:00
parent b81c687c70
commit 09fcd95d43
10 changed files with 471 additions and 72 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Update the Scaffolder template editor to quickly access installed custom fields and actions when editing a template.
+3 -1
View File
@@ -227,6 +227,7 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'ongoingTask.startOverButtonTitle': 'Start Over';
readonly 'ongoingTask.hideLogsButtonTitle': 'Hide Logs';
readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs';
readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.';
readonly 'templateTypePicker.title': 'Categories';
readonly 'templateEditorPage.title': 'Template Editor';
readonly 'templateEditorPage.subtitle': 'Edit, preview, and try out templates and template forms';
@@ -238,10 +239,11 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'templateEditorPage.dryRunResultsView.tab.log': 'Log';
readonly 'templateEditorPage.dryRunResultsView.tab.files': 'Files';
readonly 'templateEditorPage.taskStatusStepper.skippedStepTitle': 'Skipped';
readonly 'templateEditorPage.customFieldExplorer.preview.title': 'Example Template Spec';
readonly 'templateEditorPage.customFieldExplorer.preview.title': 'Template Spec';
readonly 'templateEditorPage.customFieldExplorer.fieldForm.title': 'Field Options';
readonly 'templateEditorPage.customFieldExplorer.fieldForm.applyButtonTitle': 'Apply';
readonly 'templateEditorPage.customFieldExplorer.selectFieldLabel': 'Choose Custom Field Extension';
readonly 'templateEditorPage.customFieldExplorer.fieldPreview.title': 'Field Preview';
readonly 'templateEditorPage.templateEditorBrowser.closeConfirmMessage': 'Are you sure? Unsaved changes will be lost';
readonly 'templateEditorPage.templateEditorBrowser.saveIconTooltip': 'Save all files';
readonly 'templateEditorPage.templateEditorBrowser.reloadIconTooltip': 'Reload directory';
@@ -115,7 +115,7 @@ const ExamplesTable = (props: { examples: ActionExample[] }) => {
);
};
const ActionPageContent = () => {
export const ActionPageContent = () => {
const api = useApi(scaffolderApiRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -21,12 +21,13 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import TreeItem from '@material-ui/lab/TreeItem';
const useStyles = makeStyles({
const useStyles = makeStyles(theme => ({
root: {
whiteSpace: 'nowrap',
overflowY: 'auto',
padding: theme.spacing(1.5),
},
});
}));
export type FileEntry =
| {
@@ -0,0 +1,221 @@
/*
* 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, useMemo, useState } from 'react';
import yaml from 'yaml';
import validator from '@rjsf/validator-ajv8';
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
import { makeStyles } from '@material-ui/core/styles';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import Accordion from '@material-ui/core/Accordion';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { scaffolderTranslationRef } from '../../translation';
import { TemplateEditorForm } from './TemplateEditorForm';
const useStyles = makeStyles(
theme => ({
root: {
gridArea: 'pageContent',
display: 'grid',
gridTemplateRows: 'auto 1fr',
},
controls: {
marginBottom: theme.spacing(2),
},
code: {
width: '100%',
},
}),
{ name: 'ScaffolderCustomFieldExtensionsPlaygroud' },
);
export const CustomFieldPlaygroud = ({
fieldExtensions = [],
}: {
fieldExtensions?: FieldExtensionOptions<any, any>[];
}) => {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const fieldOptions = fieldExtensions.filter(field => !!field.schema);
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,
},
},
},
],
}),
[fieldFormState, selectedField],
);
const fieldComponents = useMemo(() => {
return Object.fromEntries(
fieldExtensions.map(({ name, component }) => [name, component]),
);
}, [fieldExtensions]);
const handleSelectionChange = useCallback(
(selection: FieldExtensionOptions) => {
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],
);
return (
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" fullWidth>
<InputLabel id="select-field-label">
{t('templateEditorPage.customFieldExplorer.selectFieldLabel')}
</InputLabel>
<Select
value={selectedField}
label={t('templateEditorPage.customFieldExplorer.selectFieldLabel')}
labelId="select-field-label"
onChange={e =>
handleSelectionChange(e.target.value as FieldExtensionOptions)
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
<div>
<Accordion defaultExpanded>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel-code-content"
id="panel-code-header"
>
<Typography variant="h6">
{t('templateEditorPage.customFieldExplorer.preview.title')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<div className={classes.code}>
<CodeMirror
readOnly
theme="dark"
height="100%"
width="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
value={sampleFieldTemplate}
/>
</div>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel-preview-content"
id="panel-preview-header"
>
<Typography variant="h6">
{t('templateEditorPage.customFieldExplorer.fieldPreview.title')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<TemplateEditorForm
key={refreshKey}
content={sampleFieldTemplate}
contentIsSpec
fieldExtensions={fieldExtensions}
setErrorText={() => null}
/>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel-options-content"
id="panel-options-header"
>
<Typography variant="h6">
{t('templateEditorPage.customFieldExplorer.fieldForm.title')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Form
showErrorList={false}
fields={{ ...fieldComponents }}
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
onSubmit={e => handleFieldConfigChange(e.formData)}
validator={validator}
schema={selectedField.schema?.uiOptions || {}}
experimental_defaultFormStateBehavior={{
allOf: 'populateDefaults',
}}
>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!selectedField.schema?.uiOptions}
>
{t(
'templateEditorPage.customFieldExplorer.fieldForm.applyButtonTitle',
)}
</Button>
</Form>
</AccordionDetails>
</Accordion>
</div>
</main>
);
};
@@ -15,6 +15,7 @@
*/
import { makeStyles } from '@material-ui/core/styles';
import React, { useState } from 'react';
import Paper from '@material-ui/core/Paper';
import type {
FormProps,
LayoutOptions,
@@ -22,6 +23,7 @@ import type {
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { TemplateDirectoryAccess } from '../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { TemplateEditorToolbar } from './TemplateEditorToolbar';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
import { DryRunProvider } from './DryRunContext';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
@@ -37,17 +39,23 @@ export type ScaffolderTemplateEditorClassKey =
| 'results';
const useStyles = makeStyles(
{
theme => ({
// Reset and fix sizing to make sure scrolling behaves correctly
root: {
height: '100%',
gridArea: 'pageContent',
height: '100%',
display: 'grid',
gridTemplateAreas: `
"toolbar toolbar toolbar"
"browser editor preview"
"results results results"
`,
gridTemplateColumns: '1fr 3fr 2fr',
gridTemplateRows: '1fr auto',
gridTemplateRows: 'auto 1fr auto',
},
toolbar: {
gridArea: 'toolbar',
},
browser: {
gridArea: 'browser',
@@ -56,15 +64,27 @@ const useStyles = makeStyles(
editor: {
gridArea: 'editor',
overflow: 'auto',
borderLeft: `1px solid ${theme.palette.divider}`,
},
preview: {
gridArea: 'preview',
position: 'relative',
borderLeft: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.default,
},
scroll: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: theme.spacing(1.5),
overflow: 'auto',
},
results: {
gridArea: 'results',
},
},
}),
{ name: 'ScaffolderTemplateEditor' },
);
@@ -76,13 +96,20 @@ export const TemplateEditor = (props: {
formProps?: FormProps;
}) => {
const classes = useStyles();
const [errorText, setErrorText] = useState<string>();
return (
<DirectoryEditorProvider directory={props.directory}>
<DryRunProvider>
<main className={classes.root}>
<Paper
className={classes.root}
component="main"
variant="outlined"
square
>
<section className={classes.toolbar}>
<TemplateEditorToolbar fieldExtensions={props.fieldExtensions} />
</section>
<section className={classes.browser}>
<TemplateEditorBrowser onClose={props.onClose} />
</section>
@@ -90,17 +117,19 @@ export const TemplateEditor = (props: {
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
</section>
<section className={classes.preview}>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layouts={props.layouts}
formProps={props.formProps}
/>
<div className={classes.scroll}>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layouts={props.layouts}
formProps={props.formProps}
/>
</div>
</section>
<section className={classes.results}>
<DryRunResults />
</section>
</main>
</Paper>
</DryRunProvider>
</DirectoryEditorProvider>
);
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Grid from '@material-ui/core/Grid';
import Divider from '@material-ui/core/Divider';
import IconButton from '@material-ui/core/IconButton';
import Tooltip from '@material-ui/core/Tooltip';
@@ -26,23 +27,19 @@ import { FileBrowser } from '../../components/FileBrowser';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
const useStyles = makeStyles(theme => ({
button: {
padding: theme.spacing(1),
},
buttons: {
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
justifyContent: 'flex-start',
},
buttonsGap: {
flex: '1 1 auto',
},
buttonsDivider: {
marginBottom: theme.spacing(1),
},
}));
const useStyles = makeStyles(
theme => ({
grid: {
'& svg': {
margin: theme.spacing(1),
},
},
closeButton: {
marginLeft: 'auto',
},
}),
{ name: 'ScaffolderTemplateEditorBrowser' },
);
/** The local file browser for the template editor */
export function TemplateEditorBrowser(props: { onClose?: () => void }) {
@@ -69,12 +66,12 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
return (
<>
<div className={classes.buttons}>
<Grid className={classes.grid} container spacing={0} alignItems="center">
<Tooltip
title={t('templateEditorPage.templateEditorBrowser.saveIconTooltip')}
>
<IconButton
className={classes.button}
size="small"
disabled={directoryEditor.files.every(file => !file.dirty)}
onClick={() => directoryEditor.save()}
>
@@ -86,23 +83,23 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
'templateEditorPage.templateEditorBrowser.reloadIconTooltip',
)}
>
<IconButton
className={classes.button}
onClick={() => directoryEditor.reload()}
>
<IconButton size="small" onClick={() => directoryEditor.reload()}>
<RefreshIcon />
</IconButton>
</Tooltip>
<div className={classes.buttonsGap} />
<Tooltip
title={t('templateEditorPage.templateEditorBrowser.closeIconTooltip')}
>
<IconButton className={classes.button} onClick={handleClose}>
<IconButton
size="small"
className={classes.closeButton}
onClick={handleClose}
>
<CloseIcon />
</IconButton>
</Tooltip>
</div>
<Divider className={classes.buttonsDivider} />
</Grid>
<Divider />
<FileBrowser
selected={directoryEditor.selectedFile?.path ?? ''}
onSelect={directoryEditor.setSelectedFile}
@@ -16,9 +16,12 @@
import { useApiHolder } from '@backstage/core-plugin-api';
import { JsonObject, JsonValue } from '@backstage/types';
import { makeStyles } from '@material-ui/core/styles';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import React, { Component, ReactNode, useMemo, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import yaml from 'yaml';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import {
LayoutOptions,
TemplateParameterSchema,
@@ -31,20 +34,11 @@ import {
} from '@backstage/plugin-scaffolder-react/alpha';
import { useDryRun } from './DryRunContext';
import { useDirectoryEditor } from './DirectoryEditorContext';
import { scaffolderTranslationRef } from '../../translation';
const useStyles = makeStyles({
containerWrapper: {
position: 'relative',
width: '100%',
height: '100%',
},
container: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
overflow: 'auto',
},
});
@@ -107,6 +101,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
} = props;
const classes = useStyles();
const apiHolder = useApiHolder();
const { t } = useTranslationRef(scaffolderTranslationRef);
const [steps, setSteps] = useState<TemplateParameterSchema['steps']>();
@@ -181,26 +176,28 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
[contentIsSpec, content, apiHolder],
);
if (!steps) {
return null;
}
return (
<div className={classes.containerWrapper}>
<div className={classes.container}>
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
<Stepper
manifest={{ steps, title: 'Template Editor' }}
extensions={fieldExtensions}
components={fields}
onCreate={async options => {
await onDryRun?.(options);
}}
layouts={layouts}
formProps={props.formProps}
/>
</ErrorBoundary>
</div>
{steps ? (
<Paper variant="outlined">
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
<Stepper
manifest={{ steps, title: 'Template Editor' }}
extensions={fieldExtensions}
components={fields}
onCreate={async options => {
await onDryRun?.(options);
}}
layouts={layouts}
formProps={props.formProps}
/>
</ErrorBoundary>
</Paper>
) : (
<Typography variant="body1" color="textSecondary">
{t('templateEditorForm.stepper.emptyText')}
</Typography>
)}
</div>
);
}
@@ -0,0 +1,139 @@
/*
* Copyright 2024 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, { useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Tooltip from '@material-ui/core/Tooltip';
import ButtonGroup from '@material-ui/core/ButtonGroup';
import Button from '@material-ui/core/Button';
import Drawer from '@material-ui/core/Drawer';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogActions from '@material-ui/core/DialogActions';
import ExtensionIcon from '@material-ui/icons/Extension';
import DescriptionIcon from '@material-ui/icons/Description';
import { Link } from '@backstage/core-components';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { ActionPageContent } from '../../components/ActionsPage/ActionsPage';
import { CustomFieldPlaygroud } from './CustomFieldPlaygroud';
const useStyles = makeStyles(
theme => ({
paper: {
width: '40%',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.default,
},
appbar: {
zIndex: 1,
},
toolbar: {
display: 'grid',
justifyItems: 'flex-end',
padding: theme.spacing(0, 1.5),
backgroundColor: theme.palette.background.paper,
},
}),
{ name: 'ScaffolderTemplateEditorToolbar' },
);
export function TemplateEditorToolbar(props: {
fieldExtensions?: FieldExtensionOptions<any, any>[];
}) {
const { fieldExtensions } = props;
const classes = useStyles();
const [showFieldsDrawer, setShowFieldsDrawer] = useState(false);
const [showActionsDrawer, setShowActionsDrawer] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
return (
<AppBar className={classes.appbar} position="relative">
<Toolbar className={classes.toolbar}>
<ButtonGroup variant="outlined" color="primary">
<Tooltip title="Custom Fields Explorer">
<Button size="small" onClick={() => setShowFieldsDrawer(true)}>
<ExtensionIcon />
</Button>
</Tooltip>
<Tooltip title="Installed Actions Documentation">
<Button size="small" onClick={() => setShowActionsDrawer(true)}>
<DescriptionIcon />
</Button>
</Tooltip>
<Button onClick={() => setShowPublishModal(true)}>Publish</Button>
</ButtonGroup>
<Drawer
classes={{ paper: classes.paper }}
anchor="right"
open={showFieldsDrawer}
onClose={() => setShowFieldsDrawer(false)}
>
<CustomFieldPlaygroud fieldExtensions={fieldExtensions} />
</Drawer>
<Drawer
classes={{ paper: classes.paper }}
anchor="right"
open={showActionsDrawer}
onClose={() => setShowActionsDrawer(false)}
>
<ActionPageContent />
</Drawer>
<Dialog
onClose={() => setShowPublishModal(false)}
open={showPublishModal}
aria-labelledby="publish-dialog-title"
aria-describedby="publish-dialog-description"
>
<DialogTitle id="publish-dialog-title">Publish changes</DialogTitle>
<DialogContent dividers>
<DialogContentText id="publish-dialog-slide-description">
Follow the instructions below to create or update a template:
<ol>
<li>Save the template files in a local directory</li>
<li>
Create a pull request to a new or existing git repository
</li>
<li>
If the template already exists, the changes will be reflected
in the software catalog once the pull request gets merged
</li>
<li>
But if you are creating a new template, follow this{' '}
<Link to="https://backstage.io/docs/features/software-templates/adding-templates/">
documentation
</Link>{' '}
to register the new template repository in software catalog
</li>
</ol>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setShowPublishModal(false)}>
Close
</Button>
</DialogActions>
</Dialog>
</Toolbar>
</AppBar>
);
}
+9 -1
View File
@@ -184,6 +184,11 @@ export const scaffolderTranslationRef = createTranslationRef({
cancel: 'Cancel',
},
},
templateEditorForm: {
stepper: {
emptyText: 'There is no spec parameters in the template to preview.',
},
},
templateTypePicker: {
title: 'Categories',
},
@@ -214,8 +219,11 @@ export const scaffolderTranslationRef = createTranslationRef({
title: 'Field Options',
applyButtonTitle: 'Apply',
},
fieldPreview: {
title: 'Field Preview',
},
preview: {
title: 'Example Template Spec',
title: 'Template Spec',
},
},
templateEditorBrowser: {