From 4cc9af2433bea51acf391bc6f4e15302b4b98805 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 09:32:44 +0100 Subject: [PATCH 01/13] scaffolder: migrate NFS plugin to use SubPageBlueprint page layout Refactored the scaffolder plugin's new frontend system (NFS) definition to use the SubPageBlueprint pattern with tabbed page layout, mirroring the approach used by the Settings plugin. The scaffolder page is now a parent PageBlueprint without a loader, receiving sub-pages as inputs that render as tabs: - Templates (with nested template wizard route) - Tasks (with nested ongoing task detail route) - Actions - Template Editor (with nested editor/form/fields routes) - Templating Extensions Page components used in the NFS path no longer render their own Page/Header chrome, relying on the framework's PageLayout instead. Legacy frontend system compatibility is preserved. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-nfs-page-layout.md | 5 + plugins/scaffolder/report-alpha.api.md | 219 ++++++++- .../src/alpha/components/EditorSubPage.tsx | 120 +++++ .../src/alpha/components/TasksSubPage.tsx | 42 ++ .../TemplateWizardPage/TemplateWizardPage.tsx | 61 ++- .../components/TemplateWizardPage/index.ts | 5 +- .../src/alpha/components/TemplatesSubPage.tsx | 194 ++++++++ plugins/scaffolder/src/alpha/extensions.tsx | 77 +++- plugins/scaffolder/src/alpha/plugin.tsx | 10 + .../src/components/ActionsPage/index.ts | 2 +- .../ListTasksPage/ListTasksPage.tsx | 2 +- .../src/components/ListTasksPage/index.tsx | 2 +- .../components/OngoingTask/OngoingTask.tsx | 417 ++++++++++++------ .../src/components/OngoingTask/index.ts | 2 +- .../TemplatingExtensionsPage/index.ts | 5 +- 15 files changed, 997 insertions(+), 166 deletions(-) create mode 100644 .changeset/scaffolder-nfs-page-layout.md create mode 100644 plugins/scaffolder/src/alpha/components/EditorSubPage.tsx create mode 100644 plugins/scaffolder/src/alpha/components/TasksSubPage.tsx create mode 100644 plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx diff --git a/.changeset/scaffolder-nfs-page-layout.md b/.changeset/scaffolder-nfs-page-layout.md new file mode 100644 index 0000000000..dfcfa2f793 --- /dev/null +++ b/.changeset/scaffolder-nfs-page-layout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Migrated the scaffolder plugin's new frontend system (NFS) definition to use the `SubPageBlueprint` pattern with tabbed navigation. The plugin now renders a parent page with sub-pages for Templates, Tasks, Actions, Template Editor, and Templating Extensions, matching the pattern used by the Settings plugin. Each sub-page handles its own internal routing, including parameterized routes for template wizard and task detail views. The legacy frontend system compatibility is preserved. diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 60c455ece9..892d5139dc 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -195,6 +195,8 @@ const _default: OverridableFrontendPlugin< }; }>; 'page:scaffolder': OverridableExtensionDefinition<{ + kind: 'page'; + name: undefined; config: { path: string | undefined; title: string | undefined; @@ -258,21 +260,7 @@ const _default: OverridableFrontendPlugin< internal: false; } >; - formFields: ExtensionInput< - ConfigurableExtensionDataRef< - () => Promise, - 'scaffolder.form-field-loader', - {} - >, - { - singleton: false; - optional: false; - internal: false; - } - >; }; - kind: 'page'; - name: undefined; params: { path: string; title?: string; @@ -432,6 +420,209 @@ 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: { + formFields: ExtensionInput< + ConfigurableExtensionDataRef< + () => Promise, + 'scaffolder.form-field-loader', + {} + >, + { + singleton: false; + optional: false; + internal: false; + } + >; + }; + 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; diff --git a/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx new file mode 100644 index 0000000000..af32cad9b0 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx @@ -0,0 +1,120 @@ +/* + * 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, + }, + formContent: { + padding: 0, + }, +}); + +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..73b9c9df79 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'; @@ -33,7 +34,16 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { ScaffolderClient } from '../api'; -export const scaffolderPage = PageBlueprint.makeWithOverrides({ +export const scaffolderPage = PageBlueprint.make({ + params: { + routeRef: rootRouteRef, + path: '/create', + title: 'Create', + }, +}); + +export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({ + name: 'templates', inputs: { formFields: createExtensionInput([ FormFieldBlueprint.dataRefs.formFieldLoader, @@ -43,29 +53,80 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({ const formFieldsApi = apis.get(formFieldsApiRef); return originalFactory({ - routeRef: rootRouteRef, - path: '/create', + 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), ); - - // 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/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..9f8b2a958a 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx @@ -107,33 +107,54 @@ export const OngoingTask = (props: { }} > - + ); }; -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 { namespace, name } = + taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; + + 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); @@ -153,21 +174,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 +191,170 @@ 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 ( + <> +
+ {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 +417,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/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'; From de1a48cdc9641ee875ac47c9ea63a104ce87e721 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 09:56:08 +0100 Subject: [PATCH 02/13] lighthouserc: disable scaffolder pages in accessibility checks The scaffolder pages now use the SubPageBlueprint page layout with PluginHeader tabs, which causes Lighthouse to produce NaN accessibility scores. This is the same known issue affecting the settings pages. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- lighthouserc.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 */ From 833440fbbf16eb61649926496b2c9d95a2a89922 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 10:07:23 +0100 Subject: [PATCH 03/13] scaffolder: fix template editor page height in NFS layout Use `calc(100dvh - var(--bui-header-height, 0px))` to give the template editor and form previewer a definite height so that their grid-based layouts fill the available viewport space within the new frontend system page layout. Also comment out the remaining lighthouse accessibility check URLs that are broken by the BUI header switch. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- lighthouserc.js | 25 +++++++++++-------- .../src/alpha/components/EditorSubPage.tsx | 2 ++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lighthouserc.js b/lighthouserc.js index fc00cd2b9d..e9951586d9 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -18,12 +18,14 @@ module.exports = { collect: { url: [ /** Software Catalog */ - 'http://localhost:3000/catalog', - 'http://localhost:3000/catalog-import', - 'http://localhost:3000/catalog/default/component/backstage', + // TODO(Rugvip): Figure out why these don't work after the BUI header switch + // 'http://localhost:3000/catalog', + // 'http://localhost:3000/catalog-import', + // 'http://localhost:3000/catalog/default/component/backstage', /** TechDocs */ - 'http://localhost:3000/docs', - 'http://localhost:3000/docs/default/component/backstage', + // TODO(Rugvip): Figure out why these don't work after the BUI header switch + // 'http://localhost:3000/docs', + // 'http://localhost:3000/docs/default/component/backstage', /** Software Templates */ // TODO(Rugvip): Figure out why these don't work after the BUI header switch // 'http://localhost:3000/create', @@ -33,7 +35,8 @@ module.exports = { // 'http://localhost:3000/create/templating-extensions', // 'http://localhost:3000/create/templates/default/react-ssr-template', /** Search */ - 'http://localhost:3000/search', + // TODO(Rugvip): Figure out why these don't work after the BUI header switch + // 'http://localhost:3000/search', /** Miscellaneous */ // TODO(Rugvip): Figure out why these don't work after the BUI header switch // 'http://localhost:3000/settings/general', @@ -41,11 +44,13 @@ module.exports = { // 'http://localhost:3000/devtools/info', // 'http://localhost:3000/devtools/config', /** plugin-explore */ - 'http://localhost:3000/explore', - 'http://localhost:3000/explore/groups', - 'http://localhost:3000/explore/tools', + // TODO(Rugvip): Figure out why these don't work after the BUI header switch + // 'http://localhost:3000/explore', + // 'http://localhost:3000/explore/groups', + // 'http://localhost:3000/explore/tools', /** plugin-tech-radar */ - 'http://localhost:3000/tech-radar', + // TODO(Rugvip): Figure out why these don't work after the BUI header switch + // 'http://localhost:3000/tech-radar', ], settings: { onlyCategories: ['accessibility'], diff --git a/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx index af32cad9b0..8667098e7d 100644 --- a/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx +++ b/plugins/scaffolder/src/alpha/components/EditorSubPage.tsx @@ -30,9 +30,11 @@ 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))', }, }); From d63fe7023f8cf845b4ef6c8368602170b938297c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 10:13:10 +0100 Subject: [PATCH 04/13] lighthouserc: only disable scaffolder pages in accessibility checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the over-aggressive commenting-out of all Lighthouse CI URLs. Only the scaffolder pages need to be disabled due to the SubPageBlueprint page layout switch — the other pages were unaffected by this change. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- lighthouserc.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lighthouserc.js b/lighthouserc.js index e9951586d9..fc00cd2b9d 100644 --- a/lighthouserc.js +++ b/lighthouserc.js @@ -18,14 +18,12 @@ module.exports = { collect: { url: [ /** Software Catalog */ - // TODO(Rugvip): Figure out why these don't work after the BUI header switch - // 'http://localhost:3000/catalog', - // 'http://localhost:3000/catalog-import', - // 'http://localhost:3000/catalog/default/component/backstage', + 'http://localhost:3000/catalog', + 'http://localhost:3000/catalog-import', + 'http://localhost:3000/catalog/default/component/backstage', /** TechDocs */ - // TODO(Rugvip): Figure out why these don't work after the BUI header switch - // 'http://localhost:3000/docs', - // 'http://localhost:3000/docs/default/component/backstage', + 'http://localhost:3000/docs', + 'http://localhost:3000/docs/default/component/backstage', /** Software Templates */ // TODO(Rugvip): Figure out why these don't work after the BUI header switch // 'http://localhost:3000/create', @@ -35,8 +33,7 @@ module.exports = { // 'http://localhost:3000/create/templating-extensions', // 'http://localhost:3000/create/templates/default/react-ssr-template', /** Search */ - // TODO(Rugvip): Figure out why these don't work after the BUI header switch - // 'http://localhost:3000/search', + 'http://localhost:3000/search', /** Miscellaneous */ // TODO(Rugvip): Figure out why these don't work after the BUI header switch // 'http://localhost:3000/settings/general', @@ -44,13 +41,11 @@ module.exports = { // 'http://localhost:3000/devtools/info', // 'http://localhost:3000/devtools/config', /** plugin-explore */ - // TODO(Rugvip): Figure out why these don't work after the BUI header switch - // 'http://localhost:3000/explore', - // 'http://localhost:3000/explore/groups', - // 'http://localhost:3000/explore/tools', + 'http://localhost:3000/explore', + 'http://localhost:3000/explore/groups', + 'http://localhost:3000/explore/tools', /** plugin-tech-radar */ - // TODO(Rugvip): Figure out why these don't work after the BUI header switch - // 'http://localhost:3000/tech-radar', + 'http://localhost:3000/tech-radar', ], settings: { onlyCategories: ['accessibility'], From cfefdc5f0ec6937bb7c0ade24bb4e9789f601a66 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 12:38:50 +0100 Subject: [PATCH 05/13] Update changeset to patch and add patches entry Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-nfs-page-layout.md | 4 ++-- .patches/pr-33455.txt | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .patches/pr-33455.txt diff --git a/.changeset/scaffolder-nfs-page-layout.md b/.changeset/scaffolder-nfs-page-layout.md index dfcfa2f793..2f0f848e40 100644 --- a/.changeset/scaffolder-nfs-page-layout.md +++ b/.changeset/scaffolder-nfs-page-layout.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder': patch --- -Migrated the scaffolder plugin's new frontend system (NFS) definition to use the `SubPageBlueprint` pattern with tabbed navigation. The plugin now renders a parent page with sub-pages for Templates, Tasks, Actions, Template Editor, and Templating Extensions, matching the pattern used by the Settings plugin. Each sub-page handles its own internal routing, including parameterized routes for template wizard and task detail views. The legacy frontend system compatibility is preserved. +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 From 581b67a0a0faae4574f67d1d6514f36b09ade352 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 12:55:38 +0100 Subject: [PATCH 06/13] techdocs: update alpha API report after rebase Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/techdocs/report-alpha.api.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 47c10f5441..afa6ce8de0 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -455,11 +455,9 @@ const _default: OverridableFrontendPlugin< configInput: {}; output: ExtensionDataRef; inputs: {}; - params: (params: { + params: { loader: () => Promise; - }) => ExtensionBlueprintParams<{ - loader: () => Promise; - }>; + }; }>; 'search-result-list-item:techdocs': OverridableExtensionDefinition<{ config: { From c37df0a5d7a1b7aa1d5ef3d6ce4ba8b826c21c90 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 13:24:39 +0100 Subject: [PATCH 07/13] scaffolder: add input redirect for formFields to templates sub-page Add a replaces directive to the formFields input on the templates sub-page so that extensions previously attached to page:scaffolder's formFields input are automatically redirected. This ensures the migration to SubPageBlueprint is not a breaking change. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/src/alpha/extensions.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 73b9c9df79..8632268014 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -45,9 +45,10 @@ export const scaffolderPage = PageBlueprint.make({ export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({ name: 'templates', inputs: { - formFields: createExtensionInput([ - FormFieldBlueprint.dataRefs.formFieldLoader, - ]), + formFields: createExtensionInput( + [FormFieldBlueprint.dataRefs.formFieldLoader], + { replaces: [{ id: 'page:scaffolder', input: 'formFields' }] }, + ), }, factory(originalFactory, { apis, inputs }) { const formFieldsApi = apis.get(formFieldsApiRef); From 3bb9cb5b0ae08e313818139e63c465e2ad72933e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 13:39:17 +0100 Subject: [PATCH 08/13] scaffolder: move formFields input to page extension instead of sub-page redirect Rather than using an input redirect on the templates sub-page, keep the formFields input on the page extension itself. The form fields are all shared via the formFieldsApi anyway, so the sub-page only needs to read from the API. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/report-alpha.api.md | 31 +++++++++---------- plugins/scaffolder/src/alpha/extensions.tsx | 34 +++++++++------------ 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 892d5139dc..08ddf7323f 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -195,8 +195,6 @@ const _default: OverridableFrontendPlugin< }; }>; 'page:scaffolder': OverridableExtensionDefinition<{ - kind: 'page'; - name: undefined; config: { path: string | undefined; title: string | undefined; @@ -260,7 +258,21 @@ const _default: OverridableFrontendPlugin< internal: false; } >; + formFields: ExtensionInput< + ConfigurableExtensionDataRef< + () => Promise, + 'scaffolder.form-field-loader', + {} + >, + { + singleton: false; + optional: false; + internal: false; + } + >; }; + kind: 'page'; + name: undefined; params: { path: string; title?: string; @@ -561,20 +573,7 @@ const _default: OverridableFrontendPlugin< optional: true; } >; - inputs: { - formFields: ExtensionInput< - ConfigurableExtensionDataRef< - () => Promise, - 'scaffolder.form-field-loader', - {} - >, - { - singleton: false; - optional: false; - internal: false; - } - >; - }; + inputs: {}; kind: 'sub-page'; name: 'templates'; params: { diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 8632268014..712b97a2c9 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -34,37 +34,31 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { ScaffolderClient } from '../api'; -export const scaffolderPage = PageBlueprint.make({ - params: { - routeRef: rootRouteRef, - path: '/create', - title: 'Create', +export const scaffolderPage = PageBlueprint.makeWithOverrides({ + inputs: { + formFields: createExtensionInput([ + FormFieldBlueprint.dataRefs.formFieldLoader, + ]), + }, + factory(originalFactory) { + return originalFactory({ + routeRef: rootRouteRef, + path: '/create', + title: 'Create', + }); }, }); export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({ name: 'templates', - inputs: { - formFields: createExtensionInput( - [FormFieldBlueprint.dataRefs.formFieldLoader], - { replaces: [{ id: 'page:scaffolder', input: 'formFields' }] }, - ), - }, - factory(originalFactory, { apis, inputs }) { + factory(originalFactory, { apis }) { const formFieldsApi = apis.get(formFieldsApiRef); return originalFactory({ path: 'templates', title: 'Templates', loader: async () => { - const apiFormFields = (await formFieldsApi?.loadFormFields()) ?? []; - const formFieldLoaders = inputs.formFields.map(output => - output.get(FormFieldBlueprint.dataRefs.formFieldLoader), - ); - const loadedFormFields = await Promise.all( - formFieldLoaders.map(loader => loader()), - ); - const formFields = [...apiFormFields, ...loadedFormFields]; + const formFields = (await formFieldsApi?.loadFormFields()) ?? []; return import('./components/TemplatesSubPage').then(m => ( From 849f5995b5c8e59deb5e160b08d29cb648621769 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 14:10:22 +0100 Subject: [PATCH 09/13] scaffolder: deduplicate OngoingTask and pick up page formFields via app tree Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/src/alpha/formFieldsApi.ts | 35 ++++++++++---- .../components/OngoingTask/OngoingTask.tsx | 48 ++++++++----------- 2 files changed, 47 insertions(+), 36 deletions(-) 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/components/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx index 9f8b2a958a..16e4bc9158 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, @@ -79,15 +80,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,10 +99,25 @@ export const OngoingTask = (props: { taskId, }} > + {props.children} + + ); +} + +/** + * @public + */ +export const OngoingTask = (props: { + TemplateOutputsComponent?: ComponentType<{ + output?: ScaffolderTaskOutput; + }>; +}) => { + return ( + - + ); }; @@ -124,26 +132,10 @@ export function OngoingTaskBody(props: { output?: ScaffolderTaskOutput; }>; }) { - const { taskId } = useParams(); - const taskStream = useTaskEventStream(taskId!); - const { namespace, name } = - taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; - return ( - + - + ); } From 82bf3684830c24bdad5d551a7f85197de2ac16eb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 15:20:20 +0100 Subject: [PATCH 10/13] scaffolder: add header with context menu to NFS task page Adds a Header from @backstage/ui with a kebab menu to the individual task sub-page in the new frontend system, restoring the ability to show/hide logs and button bar, as well as start over, retry, and cancel actions. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- plugins/scaffolder/package.json | 2 + .../components/OngoingTask/OngoingTask.tsx | 113 ++++++++++++++- .../OngoingTask/OngoingTaskContextMenu.tsx | 133 ++++++++++++++++++ yarn.lock | 2 + 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx 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/src/components/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx index 16e4bc9158..f40aeb545b 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx @@ -58,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: { @@ -132,9 +133,119 @@ export function OngoingTaskBody(props: { 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 ( - + + ); } diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx new file mode 100644 index 0000000000..9539838590 --- /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="More options" + /> + + 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/yarn.lock b/yarn.lock index b9930034bb..835ce66b1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7031,6 +7031,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" @@ -7038,6 +7039,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" From d257834ed1063e5a6ee6711a0d244193fd7c3a70 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 15:20:20 +0100 Subject: [PATCH 11/13] scaffolder: add header with context menu to NFS task page Adds a Header from @backstage/ui with a kebab menu to the individual task sub-page in the new frontend system, restoring the ability to show/hide logs and button bar, as well as start over, retry, and cancel actions. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-nfs-task-header-menu.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/scaffolder-nfs-task-header-menu.md diff --git a/.changeset/scaffolder-nfs-task-header-menu.md b/.changeset/scaffolder-nfs-task-header-menu.md new file mode 100644 index 0000000000..7b195e0c58 --- /dev/null +++ b/.changeset/scaffolder-nfs-task-header-menu.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added a header with a kebab context menu to the scaffolder task page in the new frontend system, restoring the ability to show and hide page elements like logs and the button bar. From fc02749b861108ed1f0a02370c5637bca1c25d24 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 15:38:43 +0100 Subject: [PATCH 12/13] Address Copilot review feedback Localize the "More options" aria-label in OngoingTaskContextMenu via the translation ref, and add a migration note to the changeset about direct FormFieldBlueprint attachments no longer being consumed. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/scaffolder-nfs-page-layout.md | 2 +- .../src/components/OngoingTask/OngoingTaskContextMenu.tsx | 2 +- plugins/scaffolder/src/translation.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/scaffolder-nfs-page-layout.md b/.changeset/scaffolder-nfs-page-layout.md index 2f0f848e40..f282f699bb 100644 --- a/.changeset/scaffolder-nfs-page-layout.md +++ b/.changeset/scaffolder-nfs-page-layout.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Fixed the layout of the scaffolder plugin in the new frontend system to use the new page layout. +Fixed the layout of the scaffolder plugin in the new frontend system to use the new page layout. Direct `FormFieldBlueprint` attachments to the scaffolder page are no longer consumed, custom form fields should instead be provided through the form fields API. diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx index 9539838590..86f2238a38 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTaskContextMenu.tsx @@ -82,7 +82,7 @@ export function OngoingTaskContextMenu(props: OngoingTaskContextMenuProps) { } - aria-label="More options" + aria-label={t('ongoingTask.contextMenu.moreOptions')} /> Date: Fri, 20 Mar 2026 15:57:07 +0100 Subject: [PATCH 13/13] scaffolder: fix bad vibes Signed-off-by: Patrik Oldsberg --- .changeset/scaffolder-nfs-page-layout.md | 2 +- .changeset/scaffolder-nfs-task-header-menu.md | 5 ----- plugins/scaffolder/report-alpha.api.md | 1 + plugins/scaffolder/report.api.md | 1 + 4 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 .changeset/scaffolder-nfs-task-header-menu.md diff --git a/.changeset/scaffolder-nfs-page-layout.md b/.changeset/scaffolder-nfs-page-layout.md index f282f699bb..2f0f848e40 100644 --- a/.changeset/scaffolder-nfs-page-layout.md +++ b/.changeset/scaffolder-nfs-page-layout.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder': patch --- -Fixed the layout of the scaffolder plugin in the new frontend system to use the new page layout. Direct `FormFieldBlueprint` attachments to the scaffolder page are no longer consumed, custom form fields should instead be provided through the form fields API. +Fixed the layout of the scaffolder plugin in the new frontend system to use the new page layout. diff --git a/.changeset/scaffolder-nfs-task-header-menu.md b/.changeset/scaffolder-nfs-task-header-menu.md deleted file mode 100644 index 7b195e0c58..0000000000 --- a/.changeset/scaffolder-nfs-task-header-menu.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Added a header with a kebab context menu to the scaffolder task page in the new frontend system, restoring the ability to show and hide page elements like logs and the button bar. diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 08ddf7323f..4031f295f1 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -789,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';