From 4cc9af2433bea51acf391bc6f4e15302b4b98805 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 20 Mar 2026 09:32:44 +0100 Subject: [PATCH] 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';