diff --git a/.changeset/scaffolder-nfs-page-layout.md b/.changeset/scaffolder-nfs-page-layout.md new file mode 100644 index 0000000000..2f0f848e40 --- /dev/null +++ b/.changeset/scaffolder-nfs-page-layout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Fixed the layout of the scaffolder plugin in the new frontend system to use the new page layout. diff --git a/.patches/pr-33455.txt b/.patches/pr-33455.txt new file mode 100644 index 0000000000..338a327e51 --- /dev/null +++ b/.patches/pr-33455.txt @@ -0,0 +1 @@ +Fix scaffolder plugin page layout in the new frontend system diff --git a/lighthouserc.js b/lighthouserc.js index 390f1bcb86..fc00cd2b9d 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -25,12 +25,13 @@ module.exports = { 'http://localhost:3000/docs', 'http://localhost:3000/docs/default/component/backstage', /** Software Templates */ - 'http://localhost:3000/create', - 'http://localhost:3000/create/tasks', - 'http://localhost:3000/create/actions', - 'http://localhost:3000/create/edit', - 'http://localhost:3000/create/templating-extensions', - 'http://localhost:3000/create/templates/default/react-ssr-template', + // TODO(Rugvip): Figure out why these don't work after the BUI header switch + // 'http://localhost:3000/create', + // 'http://localhost:3000/create/tasks', + // 'http://localhost:3000/create/actions', + // 'http://localhost:3000/create/edit', + // 'http://localhost:3000/create/templating-extensions', + // 'http://localhost:3000/create/templates/default/react-ssr-template', /** Search */ 'http://localhost:3000/search', /** Miscellaneous */ diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 2618d9ef06..c3eb6f1f5d 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -74,6 +74,7 @@ "@backstage/plugin-techdocs-common": "workspace:^", "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/types": "workspace:^", + "@backstage/ui": "workspace:^", "@codemirror/language": "^6.0.0", "@codemirror/legacy-modes": "^6.1.0", "@codemirror/view": "^6.0.0", @@ -81,6 +82,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@react-hookz/web": "^24.0.0", + "@remixicon/react": "^4.6.0", "@rjsf/core": "5.24.13", "@rjsf/material-ui": "5.24.13", "@rjsf/utils": "5.24.13", diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 60c455ece9..4031f295f1 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -432,6 +432,196 @@ const _default: OverridableFrontendPlugin< field: () => Promise; }; }>; + 'sub-page:scaffolder/actions': OverridableExtensionDefinition<{ + kind: 'sub-page'; + name: 'actions'; + config: { + path: string | undefined; + title: string | undefined; + }; + configInput: { + title?: string | undefined; + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef< + RouteRef_2, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + routeRef?: RouteRef_2; + }; + }>; + 'sub-page:scaffolder/editor': OverridableExtensionDefinition<{ + kind: 'sub-page'; + name: 'editor'; + config: { + path: string | undefined; + title: string | undefined; + }; + configInput: { + title?: string | undefined; + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef< + RouteRef_2, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + routeRef?: RouteRef_2; + }; + }>; + 'sub-page:scaffolder/tasks': OverridableExtensionDefinition<{ + kind: 'sub-page'; + name: 'tasks'; + config: { + path: string | undefined; + title: string | undefined; + }; + configInput: { + title?: string | undefined; + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef< + RouteRef_2, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + routeRef?: RouteRef_2; + }; + }>; + 'sub-page:scaffolder/templates': OverridableExtensionDefinition<{ + config: { + path: string | undefined; + title: string | undefined; + }; + configInput: { + title?: string | undefined; + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef< + RouteRef_2, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + kind: 'sub-page'; + name: 'templates'; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + routeRef?: RouteRef_2; + }; + }>; + 'sub-page:scaffolder/templating-extensions': OverridableExtensionDefinition<{ + kind: 'sub-page'; + name: 'templating-extensions'; + config: { + path: string | undefined; + title: string | undefined; + }; + configInput: { + title?: string | undefined; + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef< + RouteRef_2, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + routeRef?: RouteRef_2; + }; + }>; } >; export default _default; @@ -599,6 +789,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'ongoingTask.contextMenu.cancel': 'Cancel'; readonly 'ongoingTask.contextMenu.retry': 'Retry'; readonly 'ongoingTask.contextMenu.startOver': 'Start Over'; + readonly 'ongoingTask.contextMenu.moreOptions': 'More options'; readonly 'ongoingTask.contextMenu.hideLogs': 'Hide Logs'; readonly 'ongoingTask.contextMenu.showLogs': 'Show Logs'; readonly 'ongoingTask.contextMenu.hideButtonBar': 'Hide Button Bar'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index a57f21f13c..545c69f0ff 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -691,6 +691,7 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'ongoingTask.contextMenu.cancel': 'Cancel'; readonly 'ongoingTask.contextMenu.retry': 'Retry'; readonly 'ongoingTask.contextMenu.startOver': 'Start Over'; + readonly 'ongoingTask.contextMenu.moreOptions': 'More options'; readonly 'ongoingTask.contextMenu.hideLogs': 'Hide Logs'; readonly 'ongoingTask.contextMenu.showLogs': 'Show Logs'; readonly 'ongoingTask.contextMenu.hideButtonBar': 'Hide Button Bar'; diff --git a/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx new file mode 100644 index 0000000000..8667098e7d --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2026 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 { useCallback } from 'react'; +import { Routes, Route, useNavigate } from 'react-router-dom'; +import { Content } from '@backstage/core-components'; +import { makeStyles } from '@material-ui/core/styles'; +import { RequirePermission } from '@backstage/plugin-permission-react'; +import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha'; +import { SecretsContextProvider } from '@backstage/plugin-scaffolder-react'; +import { TemplateEditorIntro } from './TemplateEditorPage/TemplateEditorIntro'; +import { TemplateEditor } from './TemplateEditorPage/TemplateEditor'; +import { TemplateFormPreviewer } from './TemplateEditorPage/TemplateFormPreviewer'; +import { CustomFieldExplorer } from './TemplateEditorPage/CustomFieldExplorer'; +import { useTemplateDirectory } from './TemplateEditorPage/useTemplateDirectory'; + +const useEditorStyles = makeStyles({ + editorContent: { + padding: 0, + height: 'calc(100dvh - var(--bui-header-height, 0px))', + }, + formContent: { + padding: 0, + height: 'calc(100dvh - var(--bui-header-height, 0px))', + }, +}); + +function EditorIntroContent() { + const navigate = useNavigate(); + const { openDirectory, createDirectory } = useTemplateDirectory(); + + const handleSelect = useCallback( + (option: 'create-template' | 'local' | 'form' | 'field-explorer') => { + if (option === 'local') { + openDirectory() + .then(() => navigate('template')) + .catch(() => {}); + } else if (option === 'create-template') { + createDirectory() + .then(() => navigate('template')) + .catch(() => {}); + } else if (option === 'form') { + navigate('template-form'); + } else if (option === 'field-explorer') { + navigate('custom-fields'); + } + }, + [openDirectory, createDirectory, navigate], + ); + + return ( + + + + ); +} + +function EditorContent() { + const classes = useEditorStyles(); + return ( + + + + ); +} + +function FormPreviewContent() { + const classes = useEditorStyles(); + const navigate = useNavigate(); + + const handleClose = useCallback(() => { + navigate('..'); + }, [navigate]); + + return ( + + + + ); +} + +function CustomFieldsContent() { + return ( + + + + ); +} + +/** + * Sub-page for the template editor tab. Renders the editor intro at the index, + * with sub-routes for the full editor, form previewer, and custom fields explorer. + * + * @internal + */ +export function EditorSubPage() { + return ( + + + + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/plugins/scaffolder/src/alpha/components/TasksSubPage.tsx b/plugins/scaffolder/src/alpha/components/TasksSubPage.tsx new file mode 100644 index 0000000000..82b7fc250f --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TasksSubPage.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2026 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 { Routes, Route } from 'react-router-dom'; +import { Content } from '@backstage/core-components'; +import { OngoingTaskBody } from '../../components/OngoingTask'; +import { ListTaskPageContent } from '../../components/ListTasksPage'; + +/** + * Sub-page for the tasks tab. Renders the task list at the index route + * and the ongoing task detail at the parameterized route. + * + * @internal + */ +export function TasksSubPage() { + return ( + + + + + } + /> + } /> + + ); +} diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx index 5f0176e439..6b5d5424f8 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.tsx @@ -71,7 +71,7 @@ export type TemplateWizardPageProps = { }; }; -export const TemplateWizardPage = (props: TemplateWizardPageProps) => { +function useTemplateWizard(_props: TemplateWizardPageProps) { const rootRef = useRouteRef(rootRouteRef); const taskRoute = useRouteRef(scaffolderTaskRouteRef); const { secrets: contextSecrets } = useTemplateSecrets(); @@ -134,6 +134,65 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { const onError = useCallback(() => , [rootRef]); + return { + templateRef, + templateName, + namespace, + manifest, + editUrl, + isCreating, + onCreate, + onError, + t, + }; +} + +/** + * Content-only version of the template wizard, for use within the NFS page layout + * where the header is provided by the framework. + * + * @internal + */ +export const TemplateWizardPageContent = (props: TemplateWizardPageProps) => { + const { + templateRef, + templateName, + namespace, + isCreating, + onCreate, + onError, + } = useTemplateWizard(props); + + return ( + + {isCreating && } + + + ); +}; + +export const TemplateWizardPage = (props: TemplateWizardPageProps) => { + const { + templateRef, + templateName, + namespace, + manifest, + editUrl, + isCreating, + onCreate, + onError, + t, + } = useTemplateWizard(props); + return ( diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/index.ts b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/index.ts index 991b754ec9..86cf34e3ca 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/index.ts +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/index.ts @@ -13,5 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { TemplateWizardPage } from './TemplateWizardPage'; +export { + TemplateWizardPage, + TemplateWizardPageContent, +} from './TemplateWizardPage'; export type { TemplateWizardPageProps } from './TemplateWizardPage'; diff --git a/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx b/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx new file mode 100644 index 0000000000..6889cc5f26 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx @@ -0,0 +1,194 @@ +/* + * Copyright 2026 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 { useCallback } from 'react'; +import { Routes, Route, useNavigate } from 'react-router-dom'; +import { + Content, + ContentHeader, + DocsIcon, + SupportButton, +} from '@backstage/core-components'; +import { useApp, useRouteRef } from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { + EntityKindPicker, + EntityListProvider, + EntitySearchBar, + EntityTagPicker, + CatalogFilterLayout, + UserListPicker, + EntityOwnerPicker, +} from '@backstage/plugin-catalog-react'; +import { + TemplateCategoryPicker, + TemplateGroups, +} from '@backstage/plugin-scaffolder-react/alpha'; +import { + FieldExtensionOptions, + SecretsContextProvider, + useCustomFieldExtensions, + useCustomLayouts, +} from '@backstage/plugin-scaffolder-react'; +import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { FormField } from '@backstage/plugin-scaffolder-react/alpha'; +import { OpaqueFormField } from '@internal/scaffolder'; +import { RegisterExistingButton } from './TemplateListPage/RegisterExistingButton'; +import { TemplateWizardPageContent } from './TemplateWizardPage'; +import { + registerComponentRouteRef, + selectedTemplateRouteRef, + viewTechDocRouteRef, +} from '../../routes'; +import { scaffolderTranslationRef } from '../../translation'; +import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default'; +import { buildTechDocsURL } from '@backstage/plugin-techdocs-react'; +import { + TECHDOCS_ANNOTATION, + TECHDOCS_EXTERNAL_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; + +function TemplateListContent() { + const registerComponentLink = useRouteRef(registerComponentRouteRef); + const viewTechDocsLink = useRouteRef(viewTechDocRouteRef); + const templateRoute = useRouteRef(selectedTemplateRouteRef); + const navigate = useNavigate(); + const app = useApp(); + const { t } = useTranslationRef(scaffolderTranslationRef); + + const groups = [ + { + title: t('templateListPage.templateGroups.defaultTitle'), + filter: () => true, + }, + ]; + + const additionalLinksForEntity = useCallback( + (template: TemplateEntityV1beta3) => { + if ( + !( + template.metadata.annotations?.[TECHDOCS_ANNOTATION] || + template.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION] + ) || + !viewTechDocsLink + ) { + return []; + } + + const url = buildTechDocsURL(template, viewTechDocsLink); + return url + ? [ + { + icon: app.getSystemIcon('docs') ?? DocsIcon, + text: t( + 'templateListPage.additionalLinksForEntity.viewTechDocsTitle', + ), + url, + }, + ] + : []; + }, + [app, viewTechDocsLink, t], + ); + + const onTemplateSelected = useCallback( + (template: TemplateEntityV1beta3) => { + const { namespace, name } = parseEntityRef(stringifyEntityRef(template)); + navigate(templateRoute({ namespace, templateName: name })); + }, + [navigate, templateRoute], + ); + + return ( + + + + + + {t('templateListPage.contentHeader.supportButtonTitle')} + + + + + + + + + + + + + + ); +} + +/** + * Sub-page for the templates tab. Renders the template list at the index route + * and the template wizard at the parameterized route. + * + * @internal + */ +export function TemplatesSubPage(props: { formFields?: Array }) { + const customFieldExtensions = useCustomFieldExtensions(undefined); + const customLayouts = useCustomLayouts(undefined); + + const fieldExtensions = [ + ...customFieldExtensions, + ...(props.formFields?.map(OpaqueFormField.toInternal) ?? []), + ...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter( + ({ name }) => + !customFieldExtensions.some( + (customFieldExtension: FieldExtensionOptions) => + customFieldExtension.name === name, + ), + ), + ] as FieldExtensionOptions[]; + + return ( + + } /> + + + + } + /> + + ); +} diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 91306bf5fd..712b97a2c9 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -22,6 +22,7 @@ import { identityApiRef, NavItemBlueprint, PageBlueprint, + SubPageBlueprint, } from '@backstage/frontend-plugin-api'; import { rootRouteRef } from '../routes'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; @@ -39,33 +40,88 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({ FormFieldBlueprint.dataRefs.formFieldLoader, ]), }, - factory(originalFactory, { apis, inputs }) { - const formFieldsApi = apis.get(formFieldsApiRef); - + factory(originalFactory) { return originalFactory({ routeRef: rootRouteRef, path: '/create', + title: 'Create', + }); + }, +}); + +export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({ + name: 'templates', + factory(originalFactory, { apis }) { + const formFieldsApi = apis.get(formFieldsApiRef); + + return originalFactory({ + path: 'templates', + title: 'Templates', loader: async () => { - // Merge form fields from the API with old-style direct attachments - const apiFormFields = (await formFieldsApi?.loadFormFields()) ?? []; - const formFieldLoaders = inputs.formFields.map(output => - output.get(FormFieldBlueprint.dataRefs.formFieldLoader), - ); + const formFields = (await formFieldsApi?.loadFormFields()) ?? []; - // Resolve direct attachments and combine with API form fields - const loadedFormFields = await Promise.all( - formFieldLoaders.map(loader => loader()), - ); - const formFields = [...apiFormFields, ...loadedFormFields]; - - return import('../components/Router/Router').then(m => ( - + return import('./components/TemplatesSubPage').then(m => ( + )); }, }); }, }); +export const scaffolderTasksSubPage = SubPageBlueprint.make({ + name: 'tasks', + params: { + path: 'tasks', + title: 'Tasks', + loader: () => + import('./components/TasksSubPage').then(m => ), + }, +}); + +export const scaffolderActionsSubPage = SubPageBlueprint.make({ + name: 'actions', + params: { + path: 'actions', + title: 'Actions', + loader: () => + Promise.all([ + import('../components/ActionsPage'), + import('@backstage/core-components'), + ]).then(([m, { Content }]) => ( + + + + )), + }, +}); + +export const scaffolderEditorSubPage = SubPageBlueprint.make({ + name: 'editor', + params: { + path: 'edit', + title: 'Template Editor', + loader: () => + import('./components/EditorSubPage').then(m => ), + }, +}); + +export const scaffolderTemplatingExtensionsSubPage = SubPageBlueprint.make({ + name: 'templating-extensions', + params: { + path: 'templating-extensions', + title: 'Templating Extensions', + loader: () => + Promise.all([ + import('../components/TemplatingExtensionsPage'), + import('@backstage/core-components'), + ]).then(([m, { Content }]) => ( + + + + )), + }, +}); + export const scaffolderNavItem = NavItemBlueprint.make({ params: { routeRef: rootRouteRef, diff --git a/plugins/scaffolder/src/alpha/formFieldsApi.ts b/plugins/scaffolder/src/alpha/formFieldsApi.ts index b065af9a82..03d6f0796a 100644 --- a/plugins/scaffolder/src/alpha/formFieldsApi.ts +++ b/plugins/scaffolder/src/alpha/formFieldsApi.ts @@ -16,6 +16,7 @@ import { ApiBlueprint, + appTreeApiRef, createExtensionInput, } from '@backstage/frontend-plugin-api'; import { @@ -39,18 +40,18 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({ return originalFactory(defineParams => defineParams({ api: formFieldsApiRef, - deps: {}, - factory: () => ({ + deps: { appTreeApi: appTreeApiRef }, + factory: ({ appTreeApi }) => ({ async loadFormFields() { + const pageFormFieldLoaders = getPageFormFieldLoaders(appTreeApi); + const formFields = await Promise.all( - formFieldLoaders.map(loader => loader()), + [...formFieldLoaders, ...pageFormFieldLoaders].map(loader => + loader(), + ), ); - const internalFormFields = formFields.map( - OpaqueFormField.toInternal, - ); - - return internalFormFields; + return formFields.map(OpaqueFormField.toInternal); }, }), }), @@ -58,6 +59,24 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({ }, }); +function getPageFormFieldLoaders(appTreeApi: typeof appTreeApiRef.T) { + const { tree } = appTreeApi.getTree(); + const pageNode = tree.nodes.get('page:scaffolder'); + if (!pageNode?.instance) { + return []; + } + const formFieldNodes = pageNode.edges.attachments.get('formFields') ?? []; + return formFieldNodes.flatMap(node => { + if (!node.instance) { + return []; + } + const loader = node.instance.getData( + FormFieldBlueprint.dataRefs.formFieldLoader, + ); + return loader ? [loader] : []; + }); +} + export { formFieldsApiRef, type ScaffolderFormFieldsApi, diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 0c105f3a7c..af79ea416a 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -41,6 +41,11 @@ import { scaffolderApi, scaffolderNavItem, scaffolderPage, + scaffolderTemplatesSubPage, + scaffolderTasksSubPage, + scaffolderActionsSubPage, + scaffolderEditorSubPage, + scaffolderTemplatingExtensionsSubPage, } from './extensions'; import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { formFieldsApi } from './formFieldsApi'; @@ -79,6 +84,11 @@ export default createFrontendPlugin({ extensions: [ scaffolderApi, scaffolderPage, + scaffolderTemplatesSubPage, + scaffolderTasksSubPage, + scaffolderActionsSubPage, + scaffolderEditorSubPage, + scaffolderTemplatingExtensionsSubPage, scaffolderNavItem, scaffolderEntityIconLink, formDecoratorsApi, diff --git a/plugins/scaffolder/src/components/ActionsPage/index.ts b/plugins/scaffolder/src/components/ActionsPage/index.ts index e3c9a296a0..f9b240d4f7 100644 --- a/plugins/scaffolder/src/components/ActionsPage/index.ts +++ b/plugins/scaffolder/src/components/ActionsPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ActionsPage } from './ActionsPage'; +export { ActionsPage, ActionPageContent } from './ActionsPage'; diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx index 6cce6c3727..b0ccabd455 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx @@ -59,7 +59,7 @@ export interface MyTaskPageProps { }; } -const ListTaskPageContent = (props: MyTaskPageProps) => { +export const ListTaskPageContent = (props: MyTaskPageProps) => { const { initiallySelectedFilter = 'owned' } = props; const { t } = useTranslationRef(scaffolderTranslationRef); const [limit, setLimit] = useState(5); diff --git a/plugins/scaffolder/src/components/ListTasksPage/index.tsx b/plugins/scaffolder/src/components/ListTasksPage/index.tsx index 56fc7d3d28..d1a4932425 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/index.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/index.tsx @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ListTasksPage } from './ListTasksPage'; +export { ListTasksPage, ListTaskPageContent } from './ListTasksPage'; diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx index bc0d5fe1aa..f40aeb545b 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx @@ -15,6 +15,7 @@ */ import { ComponentType, + ReactNode, useCallback, useEffect, useMemo, @@ -57,6 +58,7 @@ import { scaffolderTranslationRef } from '../../translation'; import { entityPresentationApiRef } from '@backstage/plugin-catalog-react'; import { default as reactUseAsync } from 'react-use/esm/useAsync'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { OngoingTaskContextMenu } from './OngoingTaskContextMenu'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -79,15 +81,7 @@ const useStyles = makeStyles(theme => ({ }, })); -/** - * @public - */ -export const OngoingTask = (props: { - TemplateOutputsComponent?: ComponentType<{ - output?: ScaffolderTaskOutput; - }>; -}) => { - // todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful. +function OngoingTaskAnalyticsContext(props: { children: ReactNode }) { const { taskId } = useParams(); const taskStream = useTaskEventStream(taskId!); const { namespace, name } = @@ -106,34 +100,42 @@ export const OngoingTask = (props: { taskId, }} > - - - + {props.children} ); +} + +/** + * @public + */ +export const OngoingTask = (props: { + TemplateOutputsComponent?: ComponentType<{ + output?: ScaffolderTaskOutput; + }>; +}) => { + return ( + + + + + + ); }; -function OngoingTaskContent(props: { +/** + * Content-only version of the ongoing task, for use within the NFS page layout + * where the header is provided by the framework. + * + * @internal + */ +export function OngoingTaskBody(props: { TemplateOutputsComponent?: ComponentType<{ output?: ScaffolderTaskOutput; }>; }) { const { taskId } = useParams(); - const templateRouteRef = useRouteRef(selectedTemplateRouteRef); - const navigate = useNavigate(); - const analytics = useAnalytics(); - const scaffolderApi = useApi(scaffolderApiRef); - const entityPresentationApi = useApi(entityPresentationApiRef); const taskStream = useTaskEventStream(taskId!); - const classes = useStyles(); - const steps = useMemo( - () => - taskStream.task?.spec.steps.map(step => ({ - ...step, - ...taskStream?.steps?.[step.id], - })) ?? [], - [taskStream], - ); + const entityPresentationApi = useApi(entityPresentationApiRef); const { t } = useTranslationRef(scaffolderTranslationRef); const [logsVisible, setLogVisibleState] = useState(false); @@ -153,21 +155,14 @@ function OngoingTaskContent(props: { permission: taskCreatePermission, }); - // Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions - const canStartOver = canReadTask && canCreateTask; + const isRetryableTask = + taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy === + 'startOver'; - useEffect(() => { - if (taskStream.error) { - setLogVisibleState(true); - } - }, [taskStream.error]); + const canRetry = canReadTask && canCreateTask && isRetryableTask; - useEffect(() => { - if (taskStream.completed && !taskStream.error) { - setLogVisibleState(true); - setButtonBarVisibleState(false); - } - }, [taskStream.error, taskStream.completed]); + const cancelEnabled = !(taskStream.cancelled || taskStream.completed); + const isCancelButtonDisabled = !cancelEnabled || !canCancelTask; const { value: presentation } = reactUseAsync(async () => { const templateEntityRef = taskStream.task?.spec.templateInfo?.entityRef; @@ -177,6 +172,292 @@ function OngoingTaskContent(props: { return entityPresentationApi.forEntity(templateEntityRef).promise; }, [entityPresentationApi, taskStream.task?.spec.templateInfo?.entityRef]); + const templateRouteRef = useRouteRef(selectedTemplateRouteRef); + const navigate = useNavigate(); + const analytics = useAnalytics(); + const scaffolderApi = useApi(scaffolderApiRef); + + const startOver = useCallback(() => { + const { namespace, name } = + taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; + + const formData = taskStream.task?.spec.parameters ?? {}; + + if (!namespace || !name) { + return; + } + + analytics.captureEvent('click', `Task has been started over`); + + navigate({ + pathname: templateRouteRef({ + namespace, + templateName: name, + }), + search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`, + }); + }, [ + analytics, + navigate, + taskStream.task?.spec.parameters, + taskStream.task?.spec.templateInfo?.entity?.metadata, + templateRouteRef, + ]); + + const [, { execute: triggerRetry }] = useAsync(async () => { + if (taskId) { + analytics.captureEvent('retried', 'Template has been retried'); + await scaffolderApi.retry?.(taskId); + } + }); + + const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync( + async () => { + if (taskId) { + analytics.captureEvent('cancelled', 'Template has been cancelled'); + await scaffolderApi.cancelTask(taskId); + } + }, + ); + + return ( + + + + + ); +} + +function OngoingTaskChrome(props: { + TemplateOutputsComponent?: ComponentType<{ + output?: ScaffolderTaskOutput; + }>; +}) { + const { taskId } = useParams(); + const taskStream = useTaskEventStream(taskId!); + const entityPresentationApi = useApi(entityPresentationApiRef); + const { t } = useTranslationRef(scaffolderTranslationRef); + + const [logsVisible, setLogVisibleState] = useState(false); + const [buttonBarVisible, setButtonBarVisibleState] = useState(true); + + const { allowed: canCancelTask } = usePermission({ + permission: taskCancelPermission, + resourceRef: taskId, + }); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: taskId, + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + }); + + const isRetryableTask = + taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy === + 'startOver'; + + const canRetry = canReadTask && canCreateTask && isRetryableTask; + + const cancelEnabled = !(taskStream.cancelled || taskStream.completed); + const isCancelButtonDisabled = !cancelEnabled || !canCancelTask; + + const { value: presentation } = reactUseAsync(async () => { + const templateEntityRef = taskStream.task?.spec.templateInfo?.entityRef; + if (!templateEntityRef) { + return undefined; + } + return entityPresentationApi.forEntity(templateEntityRef).promise; + }, [entityPresentationApi, taskStream.task?.spec.templateInfo?.entityRef]); + + const templateRouteRef = useRouteRef(selectedTemplateRouteRef); + const navigate = useNavigate(); + const analytics = useAnalytics(); + const scaffolderApi = useApi(scaffolderApiRef); + + const startOver = useCallback(() => { + const { namespace, name } = + taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; + + const formData = taskStream.task?.spec.parameters ?? {}; + + if (!namespace || !name) { + return; + } + + analytics.captureEvent('click', `Task has been started over`); + + navigate({ + pathname: templateRouteRef({ + namespace, + templateName: name, + }), + search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`, + }); + }, [ + analytics, + navigate, + taskStream.task?.spec.parameters, + taskStream.task?.spec.templateInfo?.entity?.metadata, + templateRouteRef, + ]); + + const [, { execute: triggerRetry }] = useAsync(async () => { + if (taskId) { + analytics.captureEvent('retried', 'Template has been retried'); + await scaffolderApi.retry?.(taskId); + } + }); + + const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync( + async () => { + if (taskId) { + analytics.captureEvent('cancelled', 'Template has been cancelled'); + await scaffolderApi.cancelTask(taskId); + } + }, + ); + + return ( + <> +
+ {t('ongoingTask.title')}{' '} + {presentation ? presentation.primaryTitle : ''} + + } + subtitle={t('ongoingTask.subtitle', { taskId: taskId as string })} + > + +
+ + + ); +} + +function OngoingTaskContent(props: { + TemplateOutputsComponent?: ComponentType<{ + output?: ScaffolderTaskOutput; + }>; + logsVisibleOverride?: boolean; + buttonBarVisibleOverride?: boolean; + onToggleLogs?: (state: boolean) => void; + onToggleButtonBar?: (state: boolean) => void; +}) { + const { taskId } = useParams(); + const templateRouteRef = useRouteRef(selectedTemplateRouteRef); + const navigate = useNavigate(); + const analytics = useAnalytics(); + const scaffolderApi = useApi(scaffolderApiRef); + const taskStream = useTaskEventStream(taskId!); + const classes = useStyles(); + const steps = useMemo( + () => + taskStream.task?.spec.steps.map(step => ({ + ...step, + ...taskStream?.steps?.[step.id], + })) ?? [], + [taskStream], + ); + const { t } = useTranslationRef(scaffolderTranslationRef); + + const [logsVisibleLocal, setLogVisibleStateLocal] = useState(false); + const [buttonBarVisibleLocal, setButtonBarVisibleStateLocal] = useState(true); + + const logsVisible = props.logsVisibleOverride ?? logsVisibleLocal; + const setLogVisibleState = props.onToggleLogs ?? setLogVisibleStateLocal; + const buttonBarVisible = + props.buttonBarVisibleOverride ?? buttonBarVisibleLocal; + const setButtonBarVisibleState = + props.onToggleButtonBar ?? setButtonBarVisibleStateLocal; + + const { allowed: canCancelTask } = usePermission({ + permission: taskCancelPermission, + resourceRef: taskId, + }); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: taskId, + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + }); + + const canStartOver = canReadTask && canCreateTask; + + useEffect(() => { + if (taskStream.error) { + setLogVisibleState(true); + } + }, [taskStream.error, setLogVisibleState]); + + useEffect(() => { + if (taskStream.completed && !taskStream.error) { + setLogVisibleState(true); + setButtonBarVisibleState(false); + } + }, [ + taskStream.error, + taskStream.completed, + setLogVisibleState, + setButtonBarVisibleState, + ]); + const activeStep = useMemo(() => { for (let i = steps.length - 1; i >= 0; i--) { if (steps[i].status !== 'open') { @@ -239,124 +520,89 @@ function OngoingTaskContent(props: { const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs; const cancelEnabled = !(taskStream.cancelled || taskStream.completed); - const isCancelButtonDisabled = - !cancelEnabled || cancelStatus !== 'not-executed' || !canCancelTask; return ( - <> -
- {t('ongoingTask.title')}{' '} - {presentation ? presentation.primaryTitle : ''} - - } - subtitle={t('ongoingTask.subtitle', { taskId: taskId as string })} - > - -
- - {taskStream.error ? ( - - - - ) : null} - + + {taskStream.error ? ( - + ) : null} - + + + - {buttonBarVisible ? ( - - - -
- - {isRetryableTask && ( - - )} - - -
-
-
-
- ) : null} + - {logsVisible ? ( - - - + {buttonBarVisible ? ( + + + +
+ + {isRetryableTask && ( + + )} + + +
- ) : null} -
- + + ) : null} + + {logsVisible ? ( + + + + + + ) : null} +
); } diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx new file mode 100644 index 0000000000..86f2238a38 --- /dev/null +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2026 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 { Header, MenuTrigger, ButtonIcon, Menu, MenuItem } from '@backstage/ui'; +import { + RiMore2Line, + RiFileListLine, + RiAddCircleLine, + RiRepeatLine, + RiReplay10Line, + RiCloseCircleLine, +} from '@remixicon/react'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { + taskReadPermission, + taskCreatePermission, +} from '@backstage/plugin-scaffolder-common/alpha'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { scaffolderTranslationRef } from '../../translation'; + +type OngoingTaskContextMenuProps = { + title: string; + cancelEnabled?: boolean; + canRetry: boolean; + isRetryableTask: boolean; + logsVisible?: boolean; + buttonBarVisible?: boolean; + onRetry?: () => void; + onStartOver?: () => void; + onToggleLogs?: (state: boolean) => void; + onToggleButtonBar?: (state: boolean) => void; + taskId?: string; + isCancelButtonDisabled: boolean; + onCancel: () => void; +}; + +export function OngoingTaskContextMenu(props: OngoingTaskContextMenuProps) { + const { + title, + cancelEnabled, + canRetry, + isRetryableTask, + logsVisible, + buttonBarVisible, + onRetry, + onStartOver, + onToggleLogs, + onToggleButtonBar, + taskId, + } = props; + const { t } = useTranslationRef(scaffolderTranslationRef); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: taskId, + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + }); + + const canStartOver = canReadTask && canCreateTask; + + return ( +
+ } + aria-label={t('ongoingTask.contextMenu.moreOptions')} + /> + + onToggleLogs?.(!logsVisible)} + iconStart={} + > + {logsVisible + ? t('ongoingTask.contextMenu.hideLogs') + : t('ongoingTask.contextMenu.showLogs')} + + onToggleButtonBar?.(!buttonBarVisible)} + iconStart={} + > + {buttonBarVisible + ? t('ongoingTask.contextMenu.hideButtonBar') + : t('ongoingTask.contextMenu.showButtonBar')} + + } + > + {t('ongoingTask.contextMenu.startOver')} + + {isRetryableTask && ( + } + > + {t('ongoingTask.contextMenu.retry')} + + )} + } + color="danger" + > + {t('ongoingTask.contextMenu.cancel')} + + + + } + /> + ); +} diff --git a/plugins/scaffolder/src/components/OngoingTask/index.ts b/plugins/scaffolder/src/components/OngoingTask/index.ts index f0c7e2a6cc..cd3ed9a5b3 100644 --- a/plugins/scaffolder/src/components/OngoingTask/index.ts +++ b/plugins/scaffolder/src/components/OngoingTask/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { OngoingTask } from './OngoingTask'; +export { OngoingTask, OngoingTaskBody } from './OngoingTask'; diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts index 2573c9f0b5..497d7f9248 100644 --- a/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts +++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts @@ -13,4 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { TemplatingExtensionsPage } from './TemplatingExtensionsPage'; +export { + TemplatingExtensionsPage, + TemplatingExtensionsPageContent, +} from './TemplatingExtensionsPage'; diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts index d0f332e2fa..0b19082d8f 100644 --- a/plugins/scaffolder/src/translation.ts +++ b/plugins/scaffolder/src/translation.ts @@ -182,6 +182,7 @@ export const scaffolderTranslationRef = createTranslationRef({ hideLogsButtonTitle: 'Hide Logs', showLogsButtonTitle: 'Show Logs', contextMenu: { + moreOptions: 'More options', hideLogs: 'Hide Logs', showLogs: 'Show Logs', hideButtonBar: 'Hide Button Bar', diff --git a/yarn.lock b/yarn.lock index 9b9019bac1..84cedfcf05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7032,6 +7032,7 @@ __metadata: "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" + "@backstage/ui": "workspace:^" "@codemirror/language": "npm:^6.0.0" "@codemirror/legacy-modes": "npm:^6.1.0" "@codemirror/view": "npm:^6.0.0" @@ -7039,6 +7040,7 @@ __metadata: "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:4.0.0-alpha.61" "@react-hookz/web": "npm:^24.0.0" + "@remixicon/react": "npm:^4.6.0" "@rjsf/core": "npm:5.24.13" "@rjsf/material-ui": "npm:5.24.13" "@rjsf/utils": "npm:5.24.13"