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';