Merge pull request #26777 from backstage/mob/scaffolder-template-editor-route
[Scaffolder] Create a separate editor route
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user