diff --git a/.changeset/tiny-pugs-kick.md b/.changeset/tiny-pugs-kick.md new file mode 100644 index 0000000000..2d6f83b325 --- /dev/null +++ b/.changeset/tiny-pugs-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Update the Scaffolder template editor to quickly access installed custom fields and actions when editing a template. diff --git a/plugins/scaffolder/api-report-alpha.md b/plugins/scaffolder/api-report-alpha.md index 9e4f1265c8..6a15507858 100644 --- a/plugins/scaffolder/api-report-alpha.md +++ b/plugins/scaffolder/api-report-alpha.md @@ -227,6 +227,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'ongoingTask.startOverButtonTitle': 'Start Over'; readonly 'ongoingTask.hideLogsButtonTitle': 'Hide Logs'; readonly 'ongoingTask.showLogsButtonTitle': 'Show Logs'; + readonly 'templateEditorForm.stepper.emptyText': 'There are no spec parameters in the template to preview.'; readonly 'templateTypePicker.title': 'Categories'; readonly 'templateEditorPage.title': 'Template Editor'; readonly 'templateEditorPage.subtitle': 'Edit, preview, and try out templates and template forms'; @@ -238,10 +239,11 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'templateEditorPage.dryRunResultsView.tab.log': 'Log'; readonly 'templateEditorPage.dryRunResultsView.tab.files': 'Files'; readonly 'templateEditorPage.taskStatusStepper.skippedStepTitle': 'Skipped'; - readonly 'templateEditorPage.customFieldExplorer.preview.title': 'Example Template Spec'; + readonly 'templateEditorPage.customFieldExplorer.preview.title': 'Template Spec'; readonly 'templateEditorPage.customFieldExplorer.fieldForm.title': 'Field Options'; readonly 'templateEditorPage.customFieldExplorer.fieldForm.applyButtonTitle': 'Apply'; readonly 'templateEditorPage.customFieldExplorer.selectFieldLabel': 'Choose Custom Field Extension'; + readonly 'templateEditorPage.customFieldExplorer.fieldPreview.title': 'Field Preview'; readonly 'templateEditorPage.templateEditorBrowser.closeConfirmMessage': 'Are you sure? Unsaved changes will be lost'; readonly 'templateEditorPage.templateEditorBrowser.saveIconTooltip': 'Save all files'; readonly 'templateEditorPage.templateEditorBrowser.reloadIconTooltip': 'Reload directory'; diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 240f2f8707..0949d948fc 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -120,7 +120,7 @@ const ExamplesTable = (props: { examples: ActionExample[] }) => { ); }; -const ActionPageContent = () => { +export const ActionPageContent = () => { const api = useApi(scaffolderApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); diff --git a/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx index 1273b8ad09..9c5dc84249 100644 --- a/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx +++ b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx @@ -21,12 +21,13 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import TreeItem from '@material-ui/lab/TreeItem'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ root: { whiteSpace: 'nowrap', overflowY: 'auto', + padding: theme.spacing(1), }, -}); +})); export type FileEntry = | { diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldPlaygroud.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldPlaygroud.tsx new file mode 100644 index 0000000000..6964fe3364 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldPlaygroud.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2022 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, useMemo, useState } from 'react'; +import yaml from 'yaml'; +import validator from '@rjsf/validator-ajv8'; +import CodeMirror from '@uiw/react-codemirror'; +import { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; + +import { makeStyles } from '@material-ui/core/styles'; +import FormControl from '@material-ui/core/FormControl'; +import InputLabel from '@material-ui/core/InputLabel'; +import MenuItem from '@material-ui/core/MenuItem'; +import Select from '@material-ui/core/Select'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import Button from '@material-ui/core/Button'; +import Typography from '@material-ui/core/Typography'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; + +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { Form } from '@backstage/plugin-scaffolder-react/alpha'; +import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; + +import { scaffolderTranslationRef } from '../../translation'; +import { TemplateEditorForm } from './TemplateEditorForm'; + +const useStyles = makeStyles( + theme => ({ + root: { + gridArea: 'pageContent', + display: 'grid', + gridTemplateRows: 'auto 1fr', + }, + controls: { + marginBottom: theme.spacing(2), + }, + code: { + width: '100%', + }, + }), + { name: 'ScaffolderCustomFieldExtensionsPlaygroud' }, +); + +export const CustomFieldPlaygroud = ({ + fieldExtensions = [], +}: { + fieldExtensions?: FieldExtensionOptions[]; +}) => { + const classes = useStyles(); + const { t } = useTranslationRef(scaffolderTranslationRef); + const fieldOptions = fieldExtensions.filter(field => !!field.schema); + const [refreshKey, setRefreshKey] = useState(Date.now()); + const [fieldFormState, setFieldFormState] = useState({}); + const [selectedField, setSelectedField] = useState(fieldOptions[0]); + const sampleFieldTemplate = useMemo( + () => + yaml.stringify({ + parameters: [ + { + title: `${selectedField.name} Example`, + properties: { + [selectedField.name]: { + type: selectedField.schema?.returnValue?.type, + 'ui:field': selectedField.name, + 'ui:options': fieldFormState, + }, + }, + }, + ], + }), + [fieldFormState, selectedField], + ); + + const fieldComponents = useMemo(() => { + return Object.fromEntries( + fieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [fieldExtensions]); + + const handleSelectionChange = useCallback( + (selection: FieldExtensionOptions) => { + setSelectedField(selection); + setFieldFormState({}); + }, + [setFieldFormState, setSelectedField], + ); + + const handleFieldConfigChange = useCallback( + (state: {}) => { + setFieldFormState(state); + // Force TemplateEditorForm to re-render since some fields + // may not be responsive to ui:option changes + setRefreshKey(Date.now()); + }, + [setFieldFormState, setRefreshKey], + ); + + return ( +
+
+ + + {t('templateEditorPage.customFieldExplorer.selectFieldLabel')} + + + +
+
+ + } + aria-controls="panel-code-content" + id="panel-code-header" + > + + {t('templateEditorPage.customFieldExplorer.preview.title')} + + + +
+ +
+
+
+ + } + aria-controls="panel-preview-content" + id="panel-preview-header" + > + + {t('templateEditorPage.customFieldExplorer.fieldPreview.title')} + + + + null} + /> + + + + } + aria-controls="panel-options-content" + id="panel-options-header" + > + + {t('templateEditorPage.customFieldExplorer.fieldForm.title')} + + + +
handleFieldConfigChange(e.formData)} + validator={validator} + schema={selectedField.schema?.uiOptions || {}} + experimental_defaultFormStateBehavior={{ + allOf: 'populateDefaults', + }} + > + +
+
+
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx index a4d5472699..7a0f18c03c 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx @@ -15,6 +15,7 @@ */ import { makeStyles } from '@material-ui/core/styles'; import React, { useState } from 'react'; +import Paper from '@material-ui/core/Paper'; import type { FormProps, LayoutOptions, @@ -22,6 +23,7 @@ import type { import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { TemplateDirectoryAccess } from '../../lib/filesystem'; import { DirectoryEditorProvider } from './DirectoryEditorContext'; +import { TemplateEditorToolbar } from './TemplateEditorToolbar'; import { TemplateEditorBrowser } from './TemplateEditorBrowser'; import { DryRunProvider } from './DryRunContext'; import { TemplateEditorTextArea } from './TemplateEditorTextArea'; @@ -37,17 +39,22 @@ export type ScaffolderTemplateEditorClassKey = | 'results'; const useStyles = makeStyles( - { + theme => ({ // Reset and fix sizing to make sure scrolling behaves correctly root: { + height: '100%', gridArea: 'pageContent', display: 'grid', gridTemplateAreas: ` + "toolbar toolbar toolbar" "browser editor preview" "results results results" `, gridTemplateColumns: '1fr 3fr 2fr', - gridTemplateRows: '1fr auto', + gridTemplateRows: 'auto 1fr auto', + }, + toolbar: { + gridArea: 'toolbar', }, browser: { gridArea: 'browser', @@ -56,15 +63,27 @@ const useStyles = makeStyles( editor: { gridArea: 'editor', overflow: 'auto', + borderLeft: `1px solid ${theme.palette.divider}`, }, preview: { gridArea: 'preview', + position: 'relative', + borderLeft: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.default, + }, + scroll: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + padding: theme.spacing(1), overflow: 'auto', }, results: { gridArea: 'results', }, - }, + }), { name: 'ScaffolderTemplateEditor' }, ); @@ -76,13 +95,20 @@ export const TemplateEditor = (props: { formProps?: FormProps; }) => { const classes = useStyles(); - const [errorText, setErrorText] = useState(); return ( -
+ +
+ +
@@ -90,17 +116,19 @@ export const TemplateEditor = (props: {
- +
+ +
-
+
); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx index 8bea86b074..604e338812 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import Grid from '@material-ui/core/Grid'; import Divider from '@material-ui/core/Divider'; import IconButton from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; @@ -26,23 +27,19 @@ import { FileBrowser } from '../../components/FileBrowser'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../translation'; -const useStyles = makeStyles(theme => ({ - button: { - padding: theme.spacing(1), - }, - buttons: { - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - justifyContent: 'flex-start', - }, - buttonsGap: { - flex: '1 1 auto', - }, - buttonsDivider: { - marginBottom: theme.spacing(1), - }, -})); +const useStyles = makeStyles( + theme => ({ + grid: { + '& svg': { + margin: theme.spacing(1), + }, + }, + closeButton: { + marginLeft: 'auto', + }, + }), + { name: 'ScaffolderTemplateEditorBrowser' }, +); /** The local file browser for the template editor */ export function TemplateEditorBrowser(props: { onClose?: () => void }) { @@ -69,12 +66,12 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) { return ( <> -
+ !file.dirty)} onClick={() => directoryEditor.save()} > @@ -86,23 +83,23 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) { 'templateEditorPage.templateEditorBrowser.reloadIconTooltip', )} > - directoryEditor.reload()} - > + directoryEditor.reload()}> -
- + -
- +
+ (); @@ -181,26 +176,28 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { [contentIsSpec, content, apiHolder], ); - if (!steps) { - return null; - } - return (
-
- - { - await onDryRun?.(options); - }} - layouts={layouts} - formProps={props.formProps} - /> - -
+ {steps ? ( + + + { + await onDryRun?.(options); + }} + layouts={layouts} + formProps={props.formProps} + /> + + + ) : ( + + {t('templateEditorForm.stepper.emptyText')} + + )}
); } diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorToolbar.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorToolbar.tsx new file mode 100644 index 0000000000..ef4ac95c3c --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorToolbar.tsx @@ -0,0 +1,139 @@ +/* + * 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, { useState } from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import Tooltip from '@material-ui/core/Tooltip'; +import ButtonGroup from '@material-ui/core/ButtonGroup'; +import Button from '@material-ui/core/Button'; +import Drawer from '@material-ui/core/Drawer'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogActions from '@material-ui/core/DialogActions'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import DescriptionIcon from '@material-ui/icons/Description'; + +import { Link } from '@backstage/core-components'; +import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; + +import { ActionPageContent } from '../../components/ActionsPage/ActionsPage'; +import { CustomFieldPlaygroud } from './CustomFieldPlaygroud'; + +const useStyles = makeStyles( + theme => ({ + paper: { + width: '40%', + padding: theme.spacing(2), + backgroundColor: theme.palette.background.default, + }, + appbar: { + zIndex: 1, + }, + toolbar: { + display: 'grid', + justifyItems: 'flex-end', + padding: theme.spacing(0, 1), + backgroundColor: theme.palette.background.paper, + }, + }), + { name: 'ScaffolderTemplateEditorToolbar' }, +); + +export function TemplateEditorToolbar(props: { + fieldExtensions?: FieldExtensionOptions[]; +}) { + const { fieldExtensions } = props; + const classes = useStyles(); + const [showFieldsDrawer, setShowFieldsDrawer] = useState(false); + const [showActionsDrawer, setShowActionsDrawer] = useState(false); + const [showPublishModal, setShowPublishModal] = useState(false); + + return ( + + + + + + + + + + + + setShowFieldsDrawer(false)} + > + + + setShowActionsDrawer(false)} + > + + + setShowPublishModal(false)} + open={showPublishModal} + aria-labelledby="publish-dialog-title" + aria-describedby="publish-dialog-description" + > + Publish changes + + + Follow the instructions below to create or update a template: +
    +
  1. Save the template files in a local directory
  2. +
  3. + Create a pull request to a new or existing git repository +
  4. +
  5. + If the template already exists, the changes will be reflected + in the software catalog once the pull request gets merged +
  6. +
  7. + But if you are creating a new template, follow this{' '} + + documentation + {' '} + to register the new template repository in software catalog +
  8. +
+
+
+ + + +
+
+
+ ); +} diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index 3518ed2c53..5af634a44e 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -184,6 +184,11 @@ export const scaffolderTranslationRef = createTranslationRef({ cancel: 'Cancel', }, }, + templateEditorForm: { + stepper: { + emptyText: 'There are no spec parameters in the template to preview.', + }, + }, templateTypePicker: { title: 'Categories', }, @@ -214,8 +219,11 @@ export const scaffolderTranslationRef = createTranslationRef({ title: 'Field Options', applyButtonTitle: 'Apply', }, + fieldPreview: { + title: 'Field Preview', + }, preview: { - title: 'Example Template Spec', + title: 'Template Spec', }, }, templateEditorBrowser: {