Merge pull request #26777 from backstage/mob/scaffolder-template-editor-route

[Scaffolder] Create a separate editor route
This commit is contained in:
Camila Belo
2024-09-23 13:31:57 +02:00
committed by GitHub
17 changed files with 214 additions and 39 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Create a separate route for the Scaffolder template editor and add the ability to refresh the page without closing the directory. Also, when the directory is closed, the user will stay on the editor page and can load a template folder from there.
+1
View File
@@ -612,6 +612,7 @@ export const scaffolderPlugin: BackstagePlugin<
actions: SubRouteRef<undefined>;
listTasks: SubRouteRef<undefined>;
edit: SubRouteRef<undefined>;
editor: SubRouteRef<undefined>;
},
{
registerComponent: ExternalRouteRef<undefined, true>;
+1
View File
@@ -90,6 +90,7 @@
"classnames": "^2.2.6",
"git-url-parse": "^14.0.0",
"humanize-duration": "^3.25.1",
"idb-keyval": "5.0.2",
"json-schema": "^0.4.0",
"json-schema-library": "^9.0.0",
"jszip": "^3.10.1",
@@ -34,6 +34,7 @@ import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
import {
actionsRouteRef,
editorRouteRef,
editRouteRef,
scaffolderListTaskRouteRef,
scaffolderTaskRouteRef,
@@ -51,6 +52,7 @@ import {
import { TemplateListPage, TemplateWizardPage } from '../../next';
import { OngoingTask } from '../OngoingTask';
import { TemplateEditorPage } from '../../next/TemplateEditorPage';
import { TemplatePage } from '../../next/TemplateEditorPage/TemplatePage';
/**
* The Props for the Scaffolder Router
@@ -177,6 +179,18 @@ export const Router = (props: PropsWithChildren<RouterProps>) => {
path={scaffolderListTaskRouteRef.path}
element={<ListTasksPage />}
/>
<Route
path={editorRouteRef.path}
element={
<SecretsContextProvider>
<TemplatePage
layouts={customLayouts}
formProps={props.formProps}
fieldExtensions={fieldExtensions}
/>
</SecretsContextProvider>
}
/>
<Route
path="*"
element={<ErrorPage status="404" statusMessage="Page not found" />}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { get, set } from 'idb-keyval';
import { TemplateDirectoryAccess, TemplateFileAccess } from './types';
type WritableFileHandle = FileSystemFileHandle & {
@@ -87,6 +88,10 @@ export class WebFileSystemAccess {
return Boolean(showDirectoryPicker);
}
static fromHandle(handle: IterableDirectoryHandle) {
return new WebDirectoryAccess(handle);
}
static async requestDirectoryAccess(): Promise<TemplateDirectoryAccess> {
if (!showDirectoryPicker) {
throw new Error('File system access is not supported');
@@ -97,3 +102,16 @@ export class WebFileSystemAccess {
private constructor() {}
}
export class WebFileSystemStore {
private static readonly key = 'scalfolder-template-editor-directory';
static async getDirectory(): Promise<IterableDirectoryHandle | undefined> {
const directory = await get(WebFileSystemStore.key);
return directory.handle;
}
static async setDirectory(directory: TemplateDirectoryAccess | undefined) {
return set(WebFileSystemStore.key, directory);
}
}
@@ -16,4 +16,4 @@
export type { TemplateFileAccess, TemplateDirectoryAccess } from './types';
export { blobToBase64 } from './helpers';
export { WebFileSystemAccess } from './WebFileSystemAccess';
export { WebFileSystemAccess, WebFileSystemStore } from './WebFileSystemAccess';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ErrorPanel, Progress } from '@backstage/core-components';
import { ErrorPanel } from '@backstage/core-components';
import { useAsync, useRerender } from '@react-hookz/web';
import React, { createContext, ReactNode, useContext, useEffect } from 'react';
import {
@@ -191,20 +191,17 @@ const DirectoryEditorContext = createContext<DirectoryEditor | undefined>(
undefined,
);
export function useDirectoryEditor(): DirectoryEditor {
export function useDirectoryEditor(): DirectoryEditor | undefined {
const value = useContext(DirectoryEditorContext);
const rerender = useRerender();
useEffect(() => value?.subscribe(rerender), [value, rerender]);
if (!value) {
throw new Error('must be used within a DirectoryEditorProvider');
}
return value;
}
interface DirectoryEditorProviderProps {
directory: TemplateDirectoryAccess;
directory?: TemplateDirectoryAccess;
children?: ReactNode;
}
@@ -226,13 +223,13 @@ export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
);
useEffect(() => {
execute(directory);
if (directory) {
execute(directory);
}
}, [execute, directory]);
if (error) {
return <ErrorPanel error={error} />;
} else if (!result) {
return <Progress />;
}
return (
@@ -88,11 +88,12 @@ const useStyles = makeStyles(
);
export const TemplateEditor = (props: {
directory: TemplateDirectoryAccess;
directory?: TemplateDirectoryAccess;
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
onClose?: () => void;
formProps?: FormProps;
onLoad?: () => void;
}) => {
const classes = useStyles();
const [errorText, setErrorText] = useState<string>();
@@ -113,7 +114,10 @@ export const TemplateEditor = (props: {
<TemplateEditorBrowser onClose={props.onClose} />
</section>
<section className={classes.editor}>
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
<TemplateEditorTextArea.DirectoryEditor
errorText={errorText}
onLoad={props.onLoad}
/>
</section>
<section className={classes.preview}>
<div className={classes.scroll}>
@@ -45,14 +45,14 @@ const useStyles = makeStyles(
export function TemplateEditorBrowser(props: { onClose?: () => void }) {
const classes = useStyles();
const directoryEditor = useDirectoryEditor();
const changedFiles = directoryEditor.files.filter(file => file.dirty);
const changedFiles = directoryEditor?.files.filter(file => file.dirty);
const { t } = useTranslationRef(scaffolderTranslationRef);
const handleClose = () => {
if (!props.onClose) {
return;
}
if (changedFiles.length > 0) {
if (changedFiles?.length) {
// eslint-disable-next-line no-alert
const accepted = window.confirm(
t('templateEditorPage.templateEditorBrowser.closeConfirmMessage'),
@@ -72,8 +72,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 +83,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 +101,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) ?? []}
/>
</>
);
@@ -213,7 +213,7 @@ export function TemplateEditorFormDirectoryEditorDryRun(
const dryRun = useDryRun();
const directoryEditor = useDirectoryEditor();
const { selectedFile } = directoryEditor;
const { selectedFile } = directoryEditor ?? {};
const handleDryRun = async (data: JsonObject) => {
if (!selectedFile) {
@@ -224,7 +224,7 @@ export function TemplateEditorFormDirectoryEditorDryRun(
await dryRun.execute({
templateContent: selectedFile.content,
values: data,
files: directoryEditor.files,
files: directoryEditor?.files ?? [],
});
setErrorText();
} catch (e) {
@@ -238,7 +238,7 @@ export function TemplateEditorFormDirectoryEditorDryRun(
? selectedFile.content
: undefined;
return (
return directoryEditor ? (
<TemplateEditorForm
onDryRun={handleDryRun}
fieldExtensions={fieldExtensions}
@@ -247,7 +247,7 @@ export function TemplateEditorFormDirectoryEditorDryRun(
layouts={layouts}
formProps={props.formProps}
/>
);
) : null;
}
TemplateEditorForm.DirectoryEditorDryRun =
@@ -20,7 +20,6 @@ import {
WebFileSystemAccess,
} from '../../lib/filesystem';
import { CustomFieldExplorer } from './CustomFieldExplorer';
import { TemplateEditor } from './TemplateEditor';
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
import {
FieldExtensionOptions,
@@ -33,11 +32,13 @@ import { useNavigate } from 'react-router-dom';
import { useRouteRef } from '@backstage/core-plugin-api';
import {
actionsRouteRef,
editorRouteRef,
rootRouteRef,
scaffolderListTaskRouteRef,
} from '../../routes';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
import { WebFileSystemStore } from '../../lib/filesystem/WebFileSystemAccess';
type Selection =
| {
@@ -64,6 +65,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
const actionsLink = useRouteRef(actionsRouteRef);
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
const createLink = useRouteRef(rootRouteRef);
const editorLink = useRouteRef(editorRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const scaffolderPageContextMenuProps = {
@@ -74,17 +76,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
};
let content: JSX.Element | null = null;
if (selection?.type === 'local') {
content = (
<TemplateEditor
directory={selection.directory}
fieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
layouts={props.layouts}
formProps={props.formProps}
/>
);
} else if (selection?.type === 'form') {
if (selection?.type === 'form') {
content = (
<TemplateFormPreviewer
defaultPreviewTemplate={props.defaultPreviewTemplate}
@@ -108,7 +100,8 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) {
onSelect={option => {
if (option === 'local') {
WebFileSystemAccess.requestDirectoryAccess()
.then(directory => setSelection({ type: 'local', directory }))
.then(directory => WebFileSystemStore.setDirectory(directory))
.then(() => navigate(editorLink()))
.catch(() => {});
} else if (option === 'form') {
setSelection({ type: 'form' });
@@ -20,6 +20,8 @@ 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';
import SaveIcon from '@material-ui/icons/Save';
@@ -36,6 +38,12 @@ const useStyles = makeStyles(theme => ({
width: '100%',
height: '100%',
},
typography: {
padding: theme.spacing(1.5),
},
button: {
verticalAlign: 'top',
},
codeMirror: {
position: 'absolute',
top: 0,
@@ -142,10 +150,33 @@ 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 actions = directoryEditor.selectedFile?.dirty
if (!directoryEditor) {
return (
<Typography
className={classes.typography}
color="textSecondary"
align="center"
>
Please{' '}
<Link
className={classes.button}
component="button"
variant="body1"
onClick={props.onLoad}
>
load
</Link>{' '}
a template directory.
</Typography>
);
}
const actions = directoryEditor?.selectedFile?.dirty
? {
onSave: () => directoryEditor.save(),
onReload: () => directoryEditor.reload(),
@@ -158,7 +189,9 @@ export function TemplateEditorDirectoryEditorTextArea(props: {
<TemplateEditorTextArea
errorText={props.errorText}
content={directoryEditor.selectedFile?.content}
onUpdate={content => directoryEditor.selectedFile?.updateContent(content)}
onUpdate={content =>
directoryEditor?.selectedFile?.updateContent(content)
}
{...actions}
/>
);
@@ -0,0 +1,92 @@
/*
* 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,6 +14,7 @@
* limitations under the License.
*/
export { TemplatePage } from './TemplatePage';
export { TemplateEditorPage } from './TemplateEditorPage';
export type { ScaffolderCustomFieldExplorerClassKey } from './CustomFieldExplorer';
export type { ScaffolderTemplateEditorClassKey } from './TemplateEditor';
+2
View File
@@ -68,6 +68,7 @@ import {
scaffolderListTaskRouteRef,
actionsRouteRef,
editRouteRef,
editorRouteRef,
} from './routes';
import {
MyGroupsPicker,
@@ -107,6 +108,7 @@ export const scaffolderPlugin = createPlugin({
actions: actionsRouteRef,
listTasks: scaffolderListTaskRouteRef,
edit: editRouteRef,
editor: editorRouteRef,
},
externalRoutes: {
registerComponent: registerComponentRouteRef,
+6
View File
@@ -78,3 +78,9 @@ export const editRouteRef = createSubRouteRef({
parent: rootRouteRef,
path: '/edit',
});
export const editorRouteRef = createSubRouteRef({
id: 'scaffolder/editor',
parent: rootRouteRef,
path: '/template',
});
+8
View File
@@ -7286,6 +7286,7 @@ __metadata:
classnames: ^2.2.6
git-url-parse: ^14.0.0
humanize-duration: ^3.25.1
idb-keyval: 5.0.2
json-schema: ^0.4.0
json-schema-library: ^9.0.0
jszip: ^3.10.1
@@ -29006,6 +29007,13 @@ __metadata:
languageName: node
linkType: hard
"idb-keyval@npm:5.0.2":
version: 5.0.2
resolution: "idb-keyval@npm:5.0.2"
checksum: 64ce4049fcfa9ffc8ddd23897a59278bd60420bcaff88ca653c63b725f8d6228a956bd7f474cea21befedd5abdb1bc8058ca1db5a12c4145605291dadadedbf1
languageName: node
linkType: hard
"identity-obj-proxy@npm:3.0.0":
version: 3.0.0
resolution: "identity-obj-proxy@npm:3.0.0"