refactor: standardize template editor layout

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-10-01 13:51:14 +02:00
parent de9a686752
commit d4cafc837a
27 changed files with 836 additions and 477 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Standardize template editor pages desktop and mobile layouts.
+17 -5
View File
@@ -173,6 +173,7 @@ export type ScaffolderCustomFieldExplorerClassKey =
// @public (undocumented)
export type ScaffolderTemplateEditorClassKey =
| 'root'
| 'toolbar'
| 'browser'
| 'editor'
| 'preview'
@@ -181,6 +182,7 @@ export type ScaffolderTemplateEditorClassKey =
// @public (undocumented)
export type ScaffolderTemplateFormPreviewerClassKey =
| 'root'
| 'toolbar'
| 'controls'
| 'textArea'
| 'preview';
@@ -275,9 +277,13 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs';
readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.';
readonly 'templateTypePicker.title': 'Categories';
readonly 'templateFormPage.title': 'Template Form Playground';
readonly 'templateFormPage.subtitle': 'Edit, preview, and try out templates and template forms';
readonly 'templateEditorPage.title': 'Manage Templates';
readonly 'templateIntroPage.title': 'Manage Templates';
readonly 'templateIntroPage.subtitle': 'Edit, preview, and try out templates, forms, and custom fields';
readonly 'templateFormPage.title': 'Template Editor';
readonly 'templateFormPage.subtitle': 'Edit, preview, and try out templates forms';
readonly 'templateCustomFieldPage.title': 'Custom Field Explorer';
readonly 'templateCustomFieldPage.subtitle': 'Edit, preview, and try out custom fields';
readonly 'templateEditorPage.title': 'Template Editor';
readonly 'templateEditorPage.subtitle': 'Edit, preview, and try out templates and template forms';
readonly 'templateEditorPage.dryRunResults.title': 'Dry-run results';
readonly 'templateEditorPage.dryRunResultsList.title': 'Result {{resultId}}';
@@ -303,12 +309,13 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'templateEditorPage.templateEditorIntro.createLocal.title': 'Create New Template';
readonly 'templateEditorPage.templateEditorIntro.createLocal.description': 'Create a local template directory, allowing you to both edit and try executing your own template.';
readonly 'templateEditorPage.templateEditorIntro.createLocal.unsupportedTooltip': 'Only supported in some Chromium-based browsers';
readonly 'templateEditorPage.templateEditorIntro.formEditor.title': 'Template playground';
readonly 'templateEditorPage.templateEditorIntro.formEditor.title': 'Template Form Playground';
readonly 'templateEditorPage.templateEditorIntro.formEditor.description': 'Preview and edit a template form, either using a sample template or by loading a template from the catalog.';
readonly 'templateEditorPage.templateEditorIntro.fieldExplorer.title': 'Custom Field Explorer';
readonly 'templateEditorPage.templateEditorIntro.fieldExplorer.description': 'View and play around with available installed custom field extensions.';
readonly 'templateEditorPage.templateEditorTextArea.saveIconTooltip': 'Save file';
readonly 'templateEditorPage.templateEditorTextArea.refreshIconTooltip': 'Reload file';
readonly 'templateEditorPage.templateEditorTextArea.emptyStateParagraph': 'Please select an action on the file menu.';
readonly 'templateEditorPage.templateFormPreviewer.title': 'Load Existing Template';
readonly 'templateListPage.title': 'Create a new component';
readonly 'templateListPage.subtitle': 'Create new software components using standard templates in your organization';
@@ -330,6 +337,11 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsListItems': 'Save the template files in a local directory\nCreate a pull request to a new or existing git repository\nIf the template already exists, the changes will be reflected in the software catalog once the pull request gets merged\nBut if you are creating a new template, follow the documentation linked below to register the new template repository in software catalog';
readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationUrl': 'https://backstage.io/docs/features/software-templates/adding-templates/';
readonly 'templateEditorToolbar.addToCatalogDialogActions.documentationButton': 'Go to the documentation';
readonly 'templateEditorToolbarFileMenu.button': 'File';
readonly 'templateEditorToolbarFileMenu.options.openDirectory': 'Open template directory';
readonly 'templateEditorToolbarFileMenu.options.createDirectory': 'Create template directory';
readonly 'templateEditorToolbarFileMenu.options.closeEditor': 'Close template editor';
readonly 'templateEditorToolbarTemplatesMenu.button': 'Templates';
}
>;
@@ -370,7 +382,7 @@ export type TemplateWizardPageProps = {
// Warnings were encountered during analysis:
//
// src/alpha/components/TemplateEditorPage/CustomFieldExplorer.d.ts:4:1 - (ae-undocumented) Missing documentation for "ScaffolderCustomFieldExplorerClassKey".
// src/alpha/components/TemplateEditorPage/TemplateEditor.d.ts:6:1 - (ae-undocumented) Missing documentation for "ScaffolderTemplateEditorClassKey".
// src/alpha/components/TemplateEditorPage/TemplateEditor.d.ts:5:1 - (ae-undocumented) Missing documentation for "ScaffolderTemplateEditorClassKey".
// src/alpha/components/TemplateEditorPage/TemplateFormPreviewer.d.ts:4:1 - (ae-undocumented) Missing documentation for "ScaffolderTemplateFormPreviewerClassKey".
// src/alpha/components/TemplateListPage/TemplateListPage.d.ts:7:1 - (ae-undocumented) Missing documentation for "TemplateListPageProps".
// src/alpha/components/TemplateWizardPage/TemplateWizardPage.d.ts:6:1 - (ae-undocumented) Missing documentation for "TemplateWizardPageProps".
@@ -19,13 +19,7 @@ import Button from '@material-ui/core/Button';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import FormControl from '@material-ui/core/FormControl';
import IconButton from '@material-ui/core/IconButton';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import Select from '@material-ui/core/Select';
import { makeStyles } from '@material-ui/core/styles';
import CloseIcon from '@material-ui/icons/Close';
import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useMemo, useState } from 'react';
import yaml from 'yaml';
@@ -35,6 +29,10 @@ import validator from '@rjsf/validator-ajv8';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import InputAdornment from '@material-ui/core/InputAdornment';
import TextField from '@material-ui/core/TextField';
import SearchIcon from '@material-ui/icons/Search';
import Autocomplete from '@material-ui/lab/Autocomplete';
/** @public */
export type ScaffolderCustomFieldExplorerClassKey =
@@ -48,25 +46,35 @@ const useStyles = makeStyles(
root: {
gridArea: 'pageContent',
display: 'grid',
gridGap: theme.spacing(2),
gridTemplateAreas: `
"controls"
"fieldForm"
"preview"
`,
[theme.breakpoints.up('md')]: {
gridTemplateAreas: `
"controls controls"
"fieldForm preview"
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
},
},
controls: {
gridArea: 'controls',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
margin: theme.spacing(1),
},
fieldForm: {
gridArea: 'fieldForm',
},
preview: {
gridArea: 'preview',
display: 'grid',
gridGap: theme.spacing(2),
alignContent: 'start',
},
}),
{ name: 'ScaffolderCustomFieldExplorer' },
@@ -74,10 +82,8 @@ const useStyles = makeStyles(
export const CustomFieldExplorer = ({
customFieldExtensions = [],
onClose,
}: {
customFieldExtensions?: FieldExtensionOptions<any, any>[];
onClose?: () => void;
}) => {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
@@ -131,29 +137,39 @@ export const CustomFieldExplorer = ({
return (
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" size="small" 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)
<Autocomplete
id="custom-fields-autocomplete"
value={selectedField}
options={fieldOptions}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField
{...params}
aria-label={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
placeholder={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
if (option) {
handleSelectionChange(option);
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose} aria-label="Close">
<CloseIcon />
</IconButton>
}}
disableClearable
fullWidth
/>
</div>
<div className={classes.fieldForm}>
<Card>
@@ -22,16 +22,16 @@ 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 Autocomplete from '@material-ui/lab/Autocomplete';
import TextField from '@material-ui/core/TextField';
import Button from '@material-ui/core/Button';
import InputAdornment from '@material-ui/core/InputAdornment';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import SearchIcon from '@material-ui/icons/Search';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
@@ -48,7 +48,7 @@ const useStyles = makeStyles(
gridTemplateRows: 'auto 1fr',
},
controls: {
marginBottom: theme.spacing(2),
marginBottom: theme.spacing(3),
},
code: {
width: '100%',
@@ -114,25 +114,39 @@ export const CustomFieldPlaygroud = ({
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)
<Autocomplete
id="custom-fields-autocomplete"
value={selectedField}
options={fieldOptions}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField
{...params}
aria-label={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
placeholder={t(
'templateEditorPage.customFieldExplorer.selectFieldLabel',
)}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
onChange={(_event, option) => {
if (option) {
handleSelectionChange(option);
}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
}}
disableClearable
fullWidth
/>
</div>
<div>
<Accordion defaultExpanded>
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import React from 'react';
import { Page, Header, Content } from '@backstage/core-components';
import { useRouteRef } from '@backstage/core-plugin-api';
@@ -32,25 +31,19 @@ interface CustomFieldsPageProps {
}
export function CustomFieldsPage(props: CustomFieldsPageProps) {
const navigate = useNavigate();
const editLink = useRouteRef(editRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const handleClose = useCallback(() => {
navigate(editLink());
}, [navigate, editLink]);
return (
<Page themeId="home">
<Header
title={t('templateEditorPage.title')}
subtitle={t('templateEditorPage.subtitle')}
title={t('templateCustomFieldPage.title')}
subtitle={t('templateCustomFieldPage.subtitle')}
type={t('templateIntroPage.title')}
typeLink={editLink()}
/>
<Content>
<CustomFieldExplorer
customFieldExtensions={props.fieldExtensions}
onClose={handleClose}
/>
<CustomFieldExplorer customFieldExtensions={props.fieldExtensions} />
</Content>
</Page>
);
@@ -209,7 +209,11 @@ export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
const { directory } = props;
const [{ result, error }, { execute }] = useAsync(
async (dir: TemplateDirectoryAccess) => {
async (dir?: TemplateDirectoryAccess) => {
if (!dir) {
return undefined;
}
const manager = new DirectoryEditorManager(dir);
await manager.reload();
@@ -223,9 +227,7 @@ export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
);
useEffect(() => {
if (directory) {
execute(directory);
}
execute(directory);
}, [execute, directory]);
if (error) {
@@ -13,126 +13,84 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { makeStyles } from '@material-ui/core/styles';
import React, { useState } from 'react';
import Paper from '@material-ui/core/Paper';
import type {
FormProps,
LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { TemplateDirectoryAccess } from '../../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import {
TemplateEditorLayout,
TemplateEditorLayoutToolbar,
TemplateEditorLayoutBrowser,
TemplateEditorLayoutFiles,
TemplateEditorLayoutPreview,
TemplateEditorLayoutConsole,
} from './TemplateEditorLayout';
import { TemplateEditorToolbar } from './TemplateEditorToolbar';
import { TemplateEditorToolbarFileMenu } from './TemplateEditorToolbarFileMenu';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
import { DryRunProvider } from './DryRunContext';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { TemplateEditorForm } from './TemplateEditorForm';
import { DryRunResults } from './DryRunResults';
import { useTemplateDirectory } from './useTemplateDirectory';
/** @public */
export type ScaffolderTemplateEditorClassKey =
| 'root'
| 'toolbar'
| 'browser'
| 'editor'
| 'preview'
| 'results';
const useStyles = makeStyles(
theme => ({
// Reset and fix sizing to make sure scrolling behaves correctly
root: {
height: '100%',
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"toolbar toolbar toolbar"
"browser editor preview"
"results results results"
`,
gridTemplateColumns: '1fr 3fr 2fr',
gridTemplateRows: 'auto 1fr auto',
},
toolbar: {
gridArea: 'toolbar',
},
browser: {
gridArea: 'browser',
overflow: 'auto',
},
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),
overflow: 'auto',
},
results: {
gridArea: 'results',
},
}),
{ name: 'ScaffolderTemplateEditor' },
);
export const TemplateEditor = (props: {
directory?: TemplateDirectoryAccess;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
onClose?: () => void;
formProps?: FormProps;
onLoad?: () => void;
fieldExtensions?: FieldExtensionOptions<any, any>[];
}) => {
const classes = useStyles();
const { layouts, formProps, fieldExtensions } = props;
const [errorText, setErrorText] = useState<string>();
const {
directory,
handleOpenDirectory,
handleCreateDirectory,
handleCloseDirectory,
} = useTemplateDirectory();
return (
<DirectoryEditorProvider directory={props.directory}>
<DirectoryEditorProvider directory={directory}>
<DryRunProvider>
<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>
<section className={classes.editor}>
<TemplateEditorTextArea.DirectoryEditor
errorText={errorText}
onLoad={props.onLoad}
/>
</section>
<section className={classes.preview}>
<div className={classes.scroll}>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layouts={props.layouts}
formProps={props.formProps}
<TemplateEditorLayout>
<TemplateEditorLayoutToolbar>
<TemplateEditorToolbar fieldExtensions={fieldExtensions}>
<TemplateEditorToolbarFileMenu
onOpenDirectory={handleOpenDirectory}
onCloseDirectory={handleCloseDirectory}
onCreateDirectory={handleCreateDirectory}
/>
</div>
</section>
<section className={classes.results}>
</TemplateEditorToolbar>
</TemplateEditorLayoutToolbar>
<TemplateEditorLayoutBrowser>
<TemplateEditorBrowser onClose={handleCloseDirectory} />
</TemplateEditorLayoutBrowser>
<TemplateEditorLayoutFiles>
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
</TemplateEditorLayoutFiles>
<TemplateEditorLayoutPreview>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={fieldExtensions}
layouts={layouts}
formProps={formProps}
/>
</TemplateEditorLayoutPreview>
<TemplateEditorLayoutConsole>
<DryRunResults />
</section>
</Paper>
</TemplateEditorLayoutConsole>
</TemplateEditorLayout>
</DryRunProvider>
</DirectoryEditorProvider>
);
@@ -64,6 +64,10 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
props.onClose();
};
if (!directoryEditor) {
return null;
}
return (
<>
<Grid className={classes.grid} container spacing={0} alignItems="center">
@@ -72,8 +76,8 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
>
<IconButton
size="small"
disabled={directoryEditor?.files.every(file => !file.dirty)}
onClick={() => directoryEditor?.save()}
disabled={directoryEditor.files.every(file => !file.dirty)}
onClick={() => directoryEditor.save()}
>
<SaveIcon />
</IconButton>
@@ -83,7 +87,7 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
'templateEditorPage.templateEditorBrowser.reloadIconTooltip',
)}
>
<IconButton size="small" onClick={() => directoryEditor?.reload()}>
<IconButton size="small" onClick={() => directoryEditor.reload()}>
<RefreshIcon />
</IconButton>
</Tooltip>
@@ -101,9 +105,9 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) {
</Grid>
<Divider />
<FileBrowser
selected={directoryEditor?.selectedFile?.path ?? ''}
onSelect={directoryEditor?.setSelectedFile}
filePaths={directoryEditor?.files.map(file => file.path) ?? []}
selected={directoryEditor.selectedFile?.path ?? ''}
onSelect={directoryEditor.setSelectedFile}
filePaths={directoryEditor.files.map(file => file.path) ?? []}
/>
</>
);
@@ -239,7 +239,11 @@ export function TemplateEditorFormDirectoryEditorDryRun(
? selectedFile.content
: undefined;
return directoryEditor ? (
if (!directoryEditor) {
return null;
}
return (
<TemplateEditorForm
onDryRun={handleDryRun}
fieldExtensions={fieldExtensions}
@@ -248,7 +252,7 @@ export function TemplateEditorFormDirectoryEditorDryRun(
layouts={layouts}
formProps={props.formProps}
/>
) : null;
);
}
TemplateEditorForm.DirectoryEditorDryRun =
@@ -40,13 +40,13 @@ const useStyles = makeStyles(theme => ({
justifyContent: 'center',
},
cardGrid: {
display: 'grid',
maxWidth: 1000,
gridTemplateColumns: '1fr 1fr',
gridAutoRows: '1fr 1fr',
gap: '1rem',
[theme.breakpoints.down('sm')]: {
gridAutoFlow: 'row',
display: 'grid',
gridGap: theme.spacing(2),
gridAutoFlow: 'row',
[theme.breakpoints.up('md')]: {
gridTemplateRows: '1fr 1fr',
gridTemplateColumns: '1fr 1fr',
},
},
card: {
@@ -167,6 +167,15 @@ export function TemplateEditorIntro(props: EditorIntroProps) {
Icon={CreateNewFolderIcon}
/>
<ActionCard
title={t('templateEditorPage.templateEditorIntro.formEditor.title')}
description={t(
'templateEditorPage.templateEditorIntro.formEditor.description',
)}
Icon={ListAltIcon}
action={() => props.onSelect?.('form')}
/>
<ActionCard
title={t(
'templateEditorPage.templateEditorIntro.fieldExplorer.title',
@@ -177,15 +186,6 @@ export function TemplateEditorIntro(props: EditorIntroProps) {
Icon={FormatListBulletedIcon}
action={() => props.onSelect?.('field-explorer')}
/>
<ActionCard
title={t('templateEditorPage.templateEditorIntro.formEditor.title')}
description={t(
'templateEditorPage.templateEditorIntro.formEditor.description',
)}
Icon={ListAltIcon}
action={() => props.onSelect?.('form')}
/>
</div>
</div>
</div>
@@ -0,0 +1,126 @@
/*
* 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, { PropsWithChildren } from 'react';
import { WithStyles, withStyles } from '@material-ui/core/styles';
export const TemplateEditorLayout = withStyles(
theme => ({
root: {
height: '100%',
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"toolbar"
"browser"
"editor"
"preview"
"results"
`,
[theme.breakpoints.up('md')]: {
gridTemplateAreas: `
"toolbar toolbar toolbar"
"browser editor preview"
"results results results"
`,
gridTemplateColumns: '1fr 3fr 2fr',
gridTemplateRows: 'auto 1fr auto',
},
},
}),
{ name: 'ScaffolderTemplateEditorLayout' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<main className={classes.root}>{children}</main>
));
export const TemplateEditorLayoutToolbar = withStyles(
{
root: {
gridArea: 'toolbar',
},
},
{ name: 'ScaffolderTemplateEditorLayoutToolbar' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
export const TemplateEditorLayoutBrowser = withStyles(
theme => ({
root: {
gridArea: 'browser',
overflow: 'auto',
[theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.palette.divider}`,
},
},
}),
{ name: 'ScaffolderTemplateEditorLayoutBrowser' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
export const TemplateEditorLayoutFiles = withStyles(
{
root: {
gridArea: 'editor',
overflow: 'auto',
},
},
{ name: 'ScaffolderTemplateEditorLayoutFiles' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
export const TemplateEditorLayoutPreview = withStyles(
theme => ({
root: {
gridArea: 'preview',
position: 'relative',
backgroundColor: theme.palette.background.default,
[theme.breakpoints.up('md')]: {
borderLeft: `1px solid ${theme.palette.divider}`,
},
},
scroll: {
height: '100%',
padding: theme.spacing(1),
[theme.breakpoints.up('md')]: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
},
},
}),
{ name: 'ScaffolderTemplateEditorLayoutPreview' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>
<div className={classes.scroll}>{children}</div>
</section>
));
export const TemplateEditorLayoutConsole = withStyles(
{
root: {
gridArea: 'results',
},
},
{ name: 'ScaffolderTemplateEditorLayoutConsole' },
)(({ children, classes }: PropsWithChildren<WithStyles>) => (
<section className={classes.root}>{children}</section>
));
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Backstage Authors
* 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.
@@ -14,63 +14,53 @@
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Content, Header, Page } from '@backstage/core-components';
import { WebFileSystemAccess } from '../../../lib/filesystem';
import { TemplateEditorIntro } from './TemplateEditorIntro';
import { useNavigate } from 'react-router-dom';
import { useRouteRef } from '@backstage/core-plugin-api';
import {
editorRouteRef,
customFieldsRouteRef,
rootRouteRef,
templateFormRouteRef,
} from '../../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
FormProps,
FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { scaffolderTranslationRef } from '../../../translation';
import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemAccess';
import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate';
import { editRouteRef } from '../../../routes';
import { TemplateEditor } from './TemplateEditor';
export function TemplateEditorPage() {
const navigate = useNavigate();
const createLink = useRouteRef(rootRouteRef);
const editorLink = useRouteRef(editorRouteRef);
const customFieldsLink = useRouteRef(customFieldsRouteRef);
const templateFormLink = useRouteRef(templateFormRouteRef);
const useStyles = makeStyles(
{
content: {
padding: 0,
},
},
{ name: 'ScaffolderTemplateEditorToolbar' },
);
interface TemplatePageProps {
defaultPreviewTemplate?: string;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
formProps?: FormProps;
}
export function TemplateEditorPage(props: TemplatePageProps) {
const classes = useStyles();
const editLink = useRouteRef(editRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
return (
<Page themeId="home">
<Header
title={t('templateEditorPage.title')}
type="Scaffolder"
typeLink={createLink()}
subtitle={t('templateEditorPage.subtitle')}
type={t('templateIntroPage.title')}
typeLink={editLink()}
/>
<Content>
<TemplateEditorIntro
onSelect={option => {
if (option === 'local') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => WebFileSystemStore.setDirectory(directory))
.then(() => navigate(editorLink()))
.catch(() => {});
} else if (option === 'create-template') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => {
createExampleTemplate(directory).then(() => {
WebFileSystemStore.setDirectory(directory);
navigate(editorLink());
});
})
.catch(() => {});
} else if (option === 'form') {
navigate(templateFormLink());
} else if (option === 'field-explorer') {
navigate(customFieldsLink());
}
}}
<Content className={classes.content}>
<TemplateEditor
layouts={props.layouts}
formProps={props.formProps}
fieldExtensions={props.fieldExtensions}
/>
</Content>
</Page>
@@ -20,7 +20,6 @@ import { showPanel } from '@codemirror/view';
import IconButton from '@material-ui/core/IconButton';
import Paper from '@material-ui/core/Paper';
import Tooltip from '@material-ui/core/Tooltip';
import Link from '@material-ui/core/Link';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import RefreshIcon from '@material-ui/icons/Refresh';
@@ -45,11 +44,14 @@ const useStyles = makeStyles(theme => ({
verticalAlign: 'top',
},
codeMirror: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
height: '100%',
[theme.breakpoints.up('md')]: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
},
errorPanel: {
color: theme.palette.error.main,
@@ -150,10 +152,10 @@ export function TemplateEditorTextArea(props: {
/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */
export function TemplateEditorDirectoryEditorTextArea(props: {
errorText?: string;
onLoad?: () => void;
}) {
const classes = useStyles();
const directoryEditor = useDirectoryEditor();
const { t } = useTranslationRef(scaffolderTranslationRef);
if (!directoryEditor) {
return (
@@ -162,16 +164,7 @@ export function TemplateEditorDirectoryEditorTextArea(props: {
color="textSecondary"
align="center"
>
Please{' '}
<Link
className={classes.button}
component="button"
variant="body1"
onClick={props.onLoad}
>
load
</Link>{' '}
a template directory.
{t('templateEditorPage.templateEditorTextArea.emptyStateParagraph')}
</Typography>
);
}
@@ -31,19 +31,25 @@ import DialogActions from '@material-ui/core/DialogActions';
import ExtensionIcon from '@material-ui/icons/Extension';
import DescriptionIcon from '@material-ui/icons/Description';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { ActionPageContent } from '../../../components/ActionsPage/ActionsPage';
import { CustomFieldPlaygroud } from './CustomFieldPlaygroud';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
import { CustomFieldPlaygroud } from './CustomFieldPlaygroud';
const useStyles = makeStyles(
theme => ({
paper: {
width: '40%',
width: '90%',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.default,
[theme.breakpoints.up('sm')]: {
width: '70%',
},
[theme.breakpoints.up('md')]: {
width: '50%',
},
},
appbar: {
zIndex: 1,
@@ -83,11 +89,7 @@ export function TemplateEditorToolbar(props: {
<AppBar className={classes.appbar} position="relative">
<Toolbar className={classes.toolbar}>
<div className={classes.toolbarCustomActions}>{children}</div>
<ButtonGroup
className={classes.toolbarDefaultActions}
variant="outlined"
color="primary"
>
<ButtonGroup className={classes.toolbarDefaultActions} variant="text">
<Tooltip
title={t('templateEditorToolbar.customFieldExplorerTooltip')}
>
@@ -0,0 +1,109 @@
/*
* 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, { MouseEvent, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { useRouteRef } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { editRouteRef } from '../../../routes';
import { scaffolderTranslationRef } from '../../../translation';
export function TemplateEditorToolbarFileMenu(props: {
onOpenDirectory?: () => void;
onCreateDirectory?: () => void;
onCloseDirectory?: () => void;
}) {
const { onOpenDirectory, onCreateDirectory, onCloseDirectory } = props;
const navigate = useNavigate();
const editLink = useRouteRef(editRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleOpenMenu = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
},
[setAnchorEl],
);
const handleCloseMenu = useCallback(() => {
setAnchorEl(null);
}, [setAnchorEl]);
const handleOpenDirectory = useCallback(() => {
handleCloseMenu();
onOpenDirectory?.();
}, [handleCloseMenu, onOpenDirectory]);
const handleCreateDirectory = useCallback(() => {
handleCloseMenu();
onCreateDirectory?.();
}, [handleCloseMenu, onCreateDirectory]);
const handleCloseEditor = useCallback(() => {
handleCloseMenu();
onCloseDirectory?.();
navigate(editLink());
}, [handleCloseMenu, onCloseDirectory, navigate, editLink]);
return (
<>
<Button
aria-controls="file-menu"
aria-haspopup="true"
onClick={handleOpenMenu}
>
{t('templateEditorToolbarFileMenu.button')}
</Button>
<Menu
id="file-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
getContentAnchorEl={null}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
keepMounted
>
<MenuItem onClick={handleOpenDirectory} disabled={!onOpenDirectory}>
{t('templateEditorToolbarFileMenu.options.openDirectory')}
</MenuItem>
<MenuItem
onClick={handleCreateDirectory}
disabled={!onCreateDirectory}
divider
>
{t('templateEditorToolbarFileMenu.options.createDirectory')}
</MenuItem>
<MenuItem onClick={handleCloseEditor}>
{t('templateEditorToolbarFileMenu.options.closeEditor')}
</MenuItem>
</Menu>
</>
);
}
@@ -0,0 +1,110 @@
/*
* 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, { MouseEvent, useCallback, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { Entity } from '@backstage/catalog-model';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { scaffolderTranslationRef } from '../../../translation';
const ITEM_HEIGHT = 48;
const useStyles = makeStyles({
menu: {
maxHeight: ITEM_HEIGHT * 5,
},
});
export type TemplateOption = {
label: string;
value: Entity;
};
export function TemplateEditorToolbarTemplatesMenu(props: {
options: TemplateOption[];
selectedOption?: TemplateOption;
onSelectOption: (option: TemplateOption) => void;
}) {
const { options, selectedOption, onSelectOption } = props;
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { t } = useTranslationRef(scaffolderTranslationRef);
const handleOpenMenu = useCallback(
(event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
},
[setAnchorEl],
);
const handleCloseMenu = useCallback(() => {
setAnchorEl(null);
}, [setAnchorEl]);
const handleSelectOption = useCallback(
(option: TemplateOption) => {
handleCloseMenu();
onSelectOption(option);
},
[handleCloseMenu, onSelectOption],
);
return (
<>
<Button
aria-controls="templates-menu"
aria-haspopup="true"
onClick={handleOpenMenu}
>
{t('templateEditorToolbarTemplatesMenu.button')}
</Button>
<Menu
id="templates-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
getContentAnchorEl={null}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
PaperProps={{
className: classes.menu,
}}
keepMounted
>
{options.map((option, index) => (
<MenuItem
key={index}
selected={!!selectedOption && selectedOption === option}
onClick={() => handleSelectOption(option)}
>
{option.label}
</MenuItem>
))}
</Menu>
</>
);
}
@@ -61,6 +61,8 @@ export function TemplateFormPage(props: TemplateFormPageProps) {
<Header
title={t('templateFormPage.title')}
subtitle={t('templateFormPage.subtitle')}
type={t('templateIntroPage.title')}
typeLink={editLink()}
/>
<Content className={classes.root}>
<TemplateFormPreviewer
@@ -14,22 +14,12 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import {
catalogApiRef,
humanizeEntityRef,
} from '@backstage/plugin-catalog-react';
import LinearProgress from '@material-ui/core/LinearProgress';
import Paper from '@material-ui/core/Paper';
import FormControl from '@material-ui/core/FormControl';
import Input from '@material-ui/core/Input';
import Select from '@material-ui/core/Select';
import Tooltip from '@material-ui/core/Tooltip';
import IconButton from '@material-ui/core/IconButton';
import MenuItem from '@material-ui/core/MenuItem';
import { makeStyles } from '@material-ui/core/styles';
import CloseIcon from '@material-ui/icons/Close';
import React, { useCallback, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import yaml from 'yaml';
@@ -38,11 +28,20 @@ import {
FieldExtensionOptions,
FormProps,
} from '@backstage/plugin-scaffolder-react';
import {
TemplateEditorLayout,
TemplateEditorLayoutToolbar,
TemplateEditorLayoutFiles,
TemplateEditorLayoutPreview,
} from './TemplateEditorLayout';
import { TemplateEditorToolbar } from './TemplateEditorToolbar';
import { TemplateEditorToolbarFileMenu } from './TemplateEditorToolbarFileMenu';
import {
TemplateOption,
TemplateEditorToolbarTemplatesMenu,
} from './TemplateEditorToolbarTemplatesMenu';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters:
@@ -83,14 +82,10 @@ steps:
name: \${{parameters.name}}
`;
type TemplateOption = {
label: string;
value: Entity;
};
/** @public */
export type ScaffolderTemplateFormPreviewerClassKey =
| 'root'
| 'toolbar'
| 'controls'
| 'textArea'
| 'preview';
@@ -102,36 +97,21 @@ const useStyles = makeStyles(
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"toolbar"
"textArea"
"preview"
`,
[theme.breakpoints.up('md')]: {
gridTemplateAreas: `
"toolbar toolbar"
"textArea preview"
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
},
},
toolbar: {
gridArea: 'toolbar',
},
textArea: {
files: {
gridArea: 'textArea',
height: '100%',
},
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),
},
formControl: {
minWidth: 120,
maxWidth: 300,
},
}),
{ name: 'ScaffolderTemplateFormPreviewer' },
@@ -140,7 +120,6 @@ const useStyles = makeStyles(
export const TemplateFormPreviewer = ({
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
customFieldExtensions = [],
onClose,
layouts = [],
formProps,
}: {
@@ -151,16 +130,14 @@ export const TemplateFormPreviewer = ({
formProps?: FormProps;
}) => {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const [errorText, setErrorText] = useState<string>();
const [selectedTemplate, setSelectedTemplate] =
useState<TemplateOption | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<TemplateOption>();
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
const { loading } = useAsync(
useAsync(
() =>
catalogApi
.getEntities({
@@ -196,76 +173,42 @@ export const TemplateFormPreviewer = ({
const handleSelectChange = useCallback(
// TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types
(selected: any) => {
(selected: TemplateOption) => {
setSelectedTemplate(selected);
setTemplateYaml(yaml.stringify(selected.spec));
setTemplateYaml(yaml.stringify(selected.value.spec));
},
[setTemplateYaml],
[setSelectedTemplate, setTemplateYaml],
);
return (
<>
{loading && <LinearProgress />}
<Paper
className={classes.root}
component="main"
variant="outlined"
square
>
<div className={classes.toolbar}>
<TemplateEditorToolbar fieldExtensions={customFieldExtensions}>
<Tooltip title="Close editor">
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Tooltip>
<FormControl className={classes.formControl}>
<Select
displayEmpty
value={selectedTemplate}
onChange={e => handleSelectChange(e.target.value)}
input={<Input />}
renderValue={selected => {
if (!selected) {
return t('templateEditorPage.templateFormPreviewer.title');
}
return (selected as Entity).metadata.title;
}}
inputProps={{
'aria-label': t(
'templateEditorPage.templateFormPreviewer.title',
),
}}
>
{templateOptions.map((option, index) => (
<MenuItem key={index} value={option.value as any}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</TemplateEditorToolbar>
</div>
<div className={classes.textArea}>
<TemplateEditorTextArea
content={templateYaml}
onUpdate={setTemplateYaml}
errorText={errorText}
<TemplateEditorLayout classes={{ root: classes.root }}>
<TemplateEditorLayoutToolbar>
<TemplateEditorToolbar fieldExtensions={customFieldExtensions}>
<TemplateEditorToolbarFileMenu />
<TemplateEditorToolbarTemplatesMenu
options={templateOptions}
selectedOption={selectedTemplate}
onSelectOption={handleSelectChange}
/>
</div>
<div className={classes.preview}>
<div className={classes.scroll}>
<TemplateEditorForm
content={templateYaml}
contentIsSpec
fieldExtensions={customFieldExtensions}
setErrorText={setErrorText}
layouts={layouts}
formProps={formProps}
/>
</div>
</div>
</Paper>
</>
</TemplateEditorToolbar>
</TemplateEditorLayoutToolbar>
<TemplateEditorLayoutFiles classes={{ root: classes.files }}>
<TemplateEditorTextArea
content={templateYaml}
onUpdate={setTemplateYaml}
errorText={errorText}
/>
</TemplateEditorLayoutFiles>
<TemplateEditorLayoutPreview>
<TemplateEditorForm
content={templateYaml}
contentIsSpec
fieldExtensions={customFieldExtensions}
setErrorText={setErrorText}
layouts={layouts}
formProps={formProps}
/>
</TemplateEditorLayoutPreview>
</TemplateEditorLayout>
);
};
@@ -0,0 +1,78 @@
/*
* 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 { Content, Header, Page } from '@backstage/core-components';
import { WebFileSystemAccess } from '../../../lib/filesystem';
import { TemplateEditorIntro } from './TemplateEditorIntro';
import { useNavigate } from 'react-router-dom';
import { useRouteRef } from '@backstage/core-plugin-api';
import {
rootRouteRef,
editorRouteRef,
templateFormRouteRef,
customFieldsRouteRef,
} from '../../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemAccess';
import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate';
export function TemplateIntroPage() {
const navigate = useNavigate();
const createLink = useRouteRef(rootRouteRef);
const editorLink = useRouteRef(editorRouteRef);
const templateFormLink = useRouteRef(templateFormRouteRef);
const customFieldsLink = useRouteRef(customFieldsRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
return (
<Page themeId="home">
<Header
title={t('templateIntroPage.title')}
type="Scaffolder"
typeLink={createLink()}
subtitle={t('templateIntroPage.subtitle')}
/>
<Content>
<TemplateEditorIntro
onSelect={option => {
if (option === 'local') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => WebFileSystemStore.setDirectory(directory))
.then(() => navigate(editorLink()))
.catch(() => {});
} else if (option === 'create-template') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => {
createExampleTemplate(directory).then(() => {
WebFileSystemStore.setDirectory(directory);
navigate(editorLink());
});
})
.catch(() => {});
} else if (option === 'form') {
navigate(templateFormLink());
} else if (option === 'field-explorer') {
navigate(customFieldsLink());
}
}}
/>
</Content>
</Page>
);
}
@@ -1,95 +0,0 @@
/*
* 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, { useCallback } from 'react';
import useAsyncRetry from 'react-use/esm/useAsyncRetry';
import { Page, Header, Content, Progress } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
FormProps,
FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { scaffolderTranslationRef } from '../../../translation';
import {
WebFileSystemAccess,
WebFileSystemStore,
} from '../../../lib/filesystem';
import { TemplateEditor } from './TemplateEditor';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(
{
content: {
padding: 0,
},
},
{ name: 'ScaffolderTemplateEditorToolbar' },
);
interface TemplatePageProps {
defaultPreviewTemplate?: string;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
formProps?: FormProps;
}
export function TemplatePage(props: TemplatePageProps) {
const classes = useStyles();
const { t } = useTranslationRef(scaffolderTranslationRef);
const { value, loading, retry } = useAsyncRetry(async () => {
const directory = await WebFileSystemStore.getDirectory();
if (!directory) return undefined;
return WebFileSystemAccess.fromHandle(directory);
}, []);
const handleLoadDirectory = useCallback(() => {
WebFileSystemAccess.requestDirectoryAccess()
.then(WebFileSystemStore.setDirectory)
.then(retry);
}, [retry]);
const handleCloseDirectory = useCallback(() => {
WebFileSystemStore.setDirectory(undefined).then(retry);
}, [retry]);
return (
<Page themeId="home">
<Header
title={t('templateEditorPage.title')}
subtitle={t('templateEditorPage.subtitle')}
/>
<Content className={classes.content}>
{loading ? (
<Progress />
) : (
<TemplateEditor
directory={value}
layouts={props.layouts}
formProps={props.formProps}
fieldExtensions={props.fieldExtensions}
onClose={handleCloseDirectory}
onLoad={handleLoadDirectory}
/>
)}
</Content>
</Page>
);
}
@@ -14,9 +14,9 @@
* limitations under the License.
*/
export { TemplatePage } from './TemplatePage';
export { TemplateFormPage } from './TemplateFormPage';
export { TemplateEditorPage } from './TemplateEditorPage';
export { TemplateFormPage } from './TemplateFormPage';
export { TemplateIntroPage } from './TemplateIntroPage';
export { CustomFieldsPage } from './CustomFieldsPage';
export type { ScaffolderCustomFieldExplorerClassKey } from './CustomFieldExplorer';
export type { ScaffolderTemplateEditorClassKey } from './TemplateEditor';
@@ -0,0 +1,66 @@
/*
* 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 { useCallback } from 'react';
import useAsyncRetry from 'react-use/esm/useAsyncRetry';
import {
WebFileSystemAccess,
WebFileSystemStore,
WebDirectoryAccess,
} from '../../../lib/filesystem';
import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate';
export function useTemplateDirectory(): {
directory?: WebDirectoryAccess;
loading: boolean;
error?: Error;
handleOpenDirectory: () => void;
handleCreateDirectory: () => void;
handleCloseDirectory: () => void;
} {
const { value, loading, error, retry } = useAsyncRetry(async () => {
const directory = await WebFileSystemStore.getDirectory();
if (!directory) return undefined;
return WebFileSystemAccess.fromHandle(directory);
}, []);
const handleOpenDirectory = useCallback(() => {
WebFileSystemAccess.requestDirectoryAccess()
.then(WebFileSystemStore.setDirectory)
.then(retry);
}, [retry]);
const handleCreateDirectory = useCallback(() => {
WebFileSystemAccess.requestDirectoryAccess()
.then(createExampleTemplate)
.then(WebFileSystemStore.setDirectory)
.then(retry);
}, [retry]);
const handleCloseDirectory = useCallback(() => {
WebFileSystemStore.setDirectory(undefined).then(retry);
}, [retry]);
return {
directory: value,
loading,
error,
handleOpenDirectory,
handleCreateDirectory,
handleCloseDirectory,
};
}
@@ -54,8 +54,8 @@ import {
import { TemplateListPage, TemplateWizardPage } from '../../alpha/components';
import { OngoingTask } from '../OngoingTask';
import {
TemplatePage,
TemplateFormPage,
TemplateIntroPage,
TemplateEditorPage,
CustomFieldsPage,
} from '../../alpha/components/TemplateEditorPage';
@@ -171,7 +171,7 @@ export const Router = (props: PropsWithChildren<RouterProps>) => {
path={editRouteRef.path}
element={
<SecretsContextProvider>
<TemplateEditorPage />
<TemplateIntroPage />
</SecretsContextProvider>
}
/>
@@ -205,7 +205,7 @@ export const Router = (props: PropsWithChildren<RouterProps>) => {
path={editorRouteRef.path}
element={
<SecretsContextProvider>
<TemplatePage
<TemplateEditorPage
layouts={customLayouts}
formProps={props.formProps}
fieldExtensions={fieldExtensions}
@@ -53,7 +53,8 @@ class WebFileAccess implements TemplateFileAccess {
}
}
class WebDirectoryAccess implements TemplateDirectoryAccess {
/** @internal */
export class WebDirectoryAccess implements TemplateDirectoryAccess {
constructor(private readonly handle: IterableDirectoryHandle) {}
async listFiles(): Promise<TemplateFileAccess[]> {
@@ -91,4 +91,5 @@ export async function createExampleTemplate(
for (const [name, data] of Object.entries(files)) {
await directory.createFile({ name, data });
}
return directory;
}
@@ -16,4 +16,8 @@
export type { TemplateFileAccess, TemplateDirectoryAccess } from './types';
export { blobToBase64 } from './helpers';
export { WebFileSystemAccess, WebFileSystemStore } from './WebFileSystemAccess';
export {
WebDirectoryAccess,
WebFileSystemAccess,
WebFileSystemStore,
} from './WebFileSystemAccess';
+25 -4
View File
@@ -195,12 +195,21 @@ export const scaffolderTranslationRef = createTranslationRef({
templateTypePicker: {
title: 'Categories',
},
templateIntroPage: {
title: 'Manage Templates',
subtitle:
'Edit, preview, and try out templates, forms, and custom fields',
},
templateFormPage: {
title: 'Template Form Playground',
subtitle: 'Edit, preview, and try out templates and template forms',
title: 'Template Editor',
subtitle: 'Edit, preview, and try out templates forms',
},
templateCustomFieldPage: {
title: 'Custom Field Explorer',
subtitle: 'Edit, preview, and try out custom fields',
},
templateEditorPage: {
title: 'Manage Templates',
title: 'Template Editor',
subtitle: 'Edit, preview, and try out templates and template forms',
dryRunResults: {
title: 'Dry-run results',
@@ -254,7 +263,7 @@ export const scaffolderTranslationRef = createTranslationRef({
unsupportedTooltip: 'Only supported in some Chromium-based browsers',
},
formEditor: {
title: 'Template playground',
title: 'Template Form Playground',
description:
'Preview and edit a template form, either using a sample template or by loading a template from the catalog.',
},
@@ -267,6 +276,7 @@ export const scaffolderTranslationRef = createTranslationRef({
templateEditorTextArea: {
saveIconTooltip: 'Save file',
refreshIconTooltip: 'Reload file',
emptyStateParagraph: 'Please select an action on the file menu.',
},
templateFormPreviewer: {
title: 'Load Existing Template',
@@ -316,5 +326,16 @@ export const scaffolderTranslationRef = createTranslationRef({
'https://backstage.io/docs/features/software-templates/adding-templates/',
},
},
templateEditorToolbarFileMenu: {
button: 'File',
options: {
openDirectory: 'Open template directory',
createDirectory: 'Create template directory',
closeEditor: 'Close template editor',
},
},
templateEditorToolbarTemplatesMenu: {
button: 'Templates',
},
},
});