diff --git a/.changeset/breezy-bulldogs-smell.md b/.changeset/breezy-bulldogs-smell.md new file mode 100644 index 0000000000..69825142cb --- /dev/null +++ b/.changeset/breezy-bulldogs-smell.md @@ -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. diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index fb53761145..b0af10a55b 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -612,6 +612,7 @@ export const scaffolderPlugin: BackstagePlugin< actions: SubRouteRef; listTasks: SubRouteRef; edit: SubRouteRef; + editor: SubRouteRef; }, { registerComponent: ExternalRouteRef; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 83418e5da5..a777f742c4 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index 3ee4b26bf3..f06b0bcd39 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -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) => { path={scaffolderListTaskRouteRef.path} element={} /> + + + + } + /> } diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts index 8d325e4bfb..3342ab6744 100644 --- a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts @@ -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 { 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 { + const directory = await get(WebFileSystemStore.key); + return directory.handle; + } + + static async setDirectory(directory: TemplateDirectoryAccess | undefined) { + return set(WebFileSystemStore.key, directory); + } +} diff --git a/plugins/scaffolder/src/lib/filesystem/index.ts b/plugins/scaffolder/src/lib/filesystem/index.ts index 1f485d0743..29f72294ea 100644 --- a/plugins/scaffolder/src/lib/filesystem/index.ts +++ b/plugins/scaffolder/src/lib/filesystem/index.ts @@ -16,4 +16,4 @@ export type { TemplateFileAccess, TemplateDirectoryAccess } from './types'; export { blobToBase64 } from './helpers'; -export { WebFileSystemAccess } from './WebFileSystemAccess'; +export { WebFileSystemAccess, WebFileSystemStore } from './WebFileSystemAccess'; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx index 0edf390bfe..70c7d43e0b 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx @@ -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( 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 ; - } else if (!result) { - return ; } return ( diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx index 7a0f18c03c..573415c36d 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx @@ -88,11 +88,12 @@ const useStyles = makeStyles( ); export const TemplateEditor = (props: { - directory: TemplateDirectoryAccess; + directory?: TemplateDirectoryAccess; fieldExtensions?: FieldExtensionOptions[]; layouts?: LayoutOptions[]; onClose?: () => void; formProps?: FormProps; + onLoad?: () => void; }) => { const classes = useStyles(); const [errorText, setErrorText] = useState(); @@ -113,7 +114,10 @@ export const TemplateEditor = (props: {
- +
diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx index 604e338812..d725c93e65 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -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 }) { > !file.dirty)} - onClick={() => directoryEditor.save()} + disabled={directoryEditor?.files.every(file => !file.dirty)} + onClick={() => directoryEditor?.save()} > @@ -83,7 +83,7 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) { 'templateEditorPage.templateEditorBrowser.reloadIconTooltip', )} > - directoryEditor.reload()}> + directoryEditor?.reload()}> @@ -101,9 +101,9 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) { file.path)} + selected={directoryEditor?.selectedFile?.path ?? ''} + onSelect={directoryEditor?.setSelectedFile} + filePaths={directoryEditor?.files.map(file => file.path) ?? []} /> ); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx index 0f4ca09556..53bda6f286 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx @@ -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 ? ( - ); + ) : null; } TemplateEditorForm.DirectoryEditorDryRun = diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx index 03d656c98d..70d61b9e2c 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx @@ -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 = ( - setSelection(undefined)} - layouts={props.layouts} - formProps={props.formProps} - /> - ); - } else if (selection?.type === 'form') { + if (selection?.type === 'form') { content = ( { 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' }); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx index 696b21d1df..350543a866 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx @@ -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 ( + + Please{' '} + + load + {' '} + a template directory. + + ); + } + + const actions = directoryEditor?.selectedFile?.dirty ? { onSave: () => directoryEditor.save(), onReload: () => directoryEditor.reload(), @@ -158,7 +189,9 @@ export function TemplateEditorDirectoryEditorTextArea(props: { directoryEditor.selectedFile?.updateContent(content)} + onUpdate={content => + directoryEditor?.selectedFile?.updateContent(content) + } {...actions} /> ); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplatePage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplatePage.tsx new file mode 100644 index 0000000000..b34f1445f4 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplatePage.tsx @@ -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[]; + 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 ( + +
+ + {loading ? ( + + ) : ( + + )} + + + ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/index.ts b/plugins/scaffolder/src/next/TemplateEditorPage/index.ts index 7f70c55ed9..b7beab4896 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/index.ts +++ b/plugins/scaffolder/src/next/TemplateEditorPage/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index c6c858f6b1..6892cf7e43 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -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, diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 8ef77e91e3..f47d2bdab8 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -78,3 +78,9 @@ export const editRouteRef = createSubRouteRef({ parent: rootRouteRef, path: '/edit', }); + +export const editorRouteRef = createSubRouteRef({ + id: 'scaffolder/editor', + parent: rootRouteRef, + path: '/template', +}); diff --git a/yarn.lock b/yarn.lock index 1d0fb5792d..c046b6d31a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"