feat: create a separate template editor route
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -612,6 +612,7 @@ export const scaffolderPlugin: BackstagePlugin<
|
||||
actions: SubRouteRef<undefined>;
|
||||
listTasks: SubRouteRef<undefined>;
|
||||
edit: SubRouteRef<undefined>;
|
||||
editor: SubRouteRef<undefined>;
|
||||
},
|
||||
{
|
||||
registerComponent: ExternalRouteRef<undefined, true>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,3 +78,9 @@ export const editRouteRef = createSubRouteRef({
|
||||
parent: rootRouteRef,
|
||||
path: '/edit',
|
||||
});
|
||||
|
||||
export const editorRouteRef = createSubRouteRef({
|
||||
id: 'scaffolder/editor',
|
||||
parent: rootRouteRef,
|
||||
path: '/template',
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -28996,6 +28997,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"
|
||||
|
||||
Reference in New Issue
Block a user