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
+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',
});