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 <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-20 09:32:44 +01:00
parent 41b05a9f19
commit 4cc9af2433
15 changed files with 997 additions and 166 deletions
+5
View File
@@ -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.
+205 -14
View File
@@ -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<FormField>,
'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<FormField>;
};
}>;
'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<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
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<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
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<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef_2;
};
}>;
'sub-page:scaffolder/templates': OverridableExtensionDefinition<{
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
formFields: ExtensionInput<
ConfigurableExtensionDataRef<
() => Promise<FormField>,
'scaffolder.form-field-loader',
{}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
kind: 'sub-page';
name: 'templates';
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
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<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef_2;
};
}>;
}
>;
export default _default;
@@ -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 (
<Content>
<TemplateEditorIntro onSelect={handleSelect} />
</Content>
);
}
function EditorContent() {
const classes = useEditorStyles();
return (
<Content className={classes.editorContent}>
<TemplateEditor />
</Content>
);
}
function FormPreviewContent() {
const classes = useEditorStyles();
const navigate = useNavigate();
const handleClose = useCallback(() => {
navigate('..');
}, [navigate]);
return (
<Content className={classes.formContent}>
<TemplateFormPreviewer onClose={handleClose} />
</Content>
);
}
function CustomFieldsContent() {
return (
<Content>
<CustomFieldExplorer />
</Content>
);
}
/**
* 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 (
<RequirePermission permission={templateManagementPermission}>
<SecretsContextProvider>
<Routes>
<Route index element={<EditorIntroContent />} />
<Route path="template" element={<EditorContent />} />
<Route path="template-form" element={<FormPreviewContent />} />
<Route path="custom-fields" element={<CustomFieldsContent />} />
</Routes>
</SecretsContextProvider>
</RequirePermission>
);
}
@@ -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 (
<Routes>
<Route
index
element={
<Content>
<ListTaskPageContent />
</Content>
}
/>
<Route path=":taskId" element={<OngoingTaskBody />} />
</Routes>
);
}
@@ -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(() => <Navigate to={rootRef()} />, [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 (
<AnalyticsContext attributes={{ entityRef: templateRef }}>
{isCreating && <Progress />}
<Workflow
namespace={namespace}
templateName={templateName}
onCreate={onCreate}
components={props.components}
onError={onError}
extensions={props.customFieldExtensions}
formProps={props.formProps}
layouts={props.layouts}
/>
</AnalyticsContext>
);
};
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
const {
templateRef,
templateName,
namespace,
manifest,
editUrl,
isCreating,
onCreate,
onError,
t,
} = useTemplateWizard(props);
return (
<AnalyticsContext attributes={{ entityRef: templateRef }}>
<Page themeId="website">
@@ -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';
@@ -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 (
<EntityListProvider>
<Content>
<ContentHeader>
<RegisterExistingButton
title={t(
'templateListPage.contentHeader.registerExistingButtonTitle',
)}
to={registerComponentLink && registerComponentLink()}
/>
<SupportButton>
{t('templateListPage.contentHeader.supportButtonTitle')}
</SupportButton>
</ContentHeader>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<EntitySearchBar />
<EntityKindPicker initialFilter="template" hidden />
<UserListPicker
initialFilter="all"
availableFilters={['all', 'starred']}
/>
<TemplateCategoryPicker />
<EntityTagPicker />
<EntityOwnerPicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<TemplateGroups
groups={groups}
onTemplateSelected={onTemplateSelected}
additionalLinksForEntity={additionalLinksForEntity}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
</Content>
</EntityListProvider>
);
}
/**
* 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<FormField> }) {
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 (
<Routes>
<Route index element={<TemplateListContent />} />
<Route
path=":namespace/:templateName"
element={
<SecretsContextProvider>
<TemplateWizardPageContent
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
/>
</SecretsContextProvider>
}
/>
</Routes>
);
}
+69 -8
View File
@@ -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 => (
<m.InternalRouter formFields={formFields} />
return import('./components/TemplatesSubPage').then(m => (
<m.TemplatesSubPage formFields={formFields} />
));
},
});
},
});
export const scaffolderTasksSubPage = SubPageBlueprint.make({
name: 'tasks',
params: {
path: 'tasks',
title: 'Tasks',
loader: () =>
import('./components/TasksSubPage').then(m => <m.TasksSubPage />),
},
});
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 }]) => (
<Content>
<m.ActionPageContent />
</Content>
)),
},
});
export const scaffolderEditorSubPage = SubPageBlueprint.make({
name: 'editor',
params: {
path: 'edit',
title: 'Template Editor',
loader: () =>
import('./components/EditorSubPage').then(m => <m.EditorSubPage />),
},
});
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 }]) => (
<Content>
<m.TemplatingExtensionsPageContent linkLocal />
</Content>
)),
},
});
export const scaffolderNavItem = NavItemBlueprint.make({
params: {
routeRef: rootRouteRef,
+10
View File
@@ -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,
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ActionsPage } from './ActionsPage';
export { ActionsPage, ActionPageContent } from './ActionsPage';
@@ -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);
@@ -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';
@@ -107,33 +107,54 @@ export const OngoingTask = (props: {
}}
>
<Page themeId="website">
<OngoingTaskContent {...props} />
<OngoingTaskChrome {...props} />
</Page>
</AnalyticsContext>
);
};
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 (
<AnalyticsContext
attributes={{
entityRef:
name &&
stringifyEntityRef({
kind: 'template',
namespace,
name,
}),
taskId,
}}
>
<OngoingTaskContent {...props} />
</AnalyticsContext>
);
}
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 (
<>
<Header
pageTitleOverride={
presentation
? t('ongoingTask.pageTitle.hasTemplateName', {
templateName: presentation.primaryTitle,
})
: t('ongoingTask.pageTitle.noTemplateName')
}
title={
<div>
{t('ongoingTask.title')}{' '}
<code>{presentation ? presentation.primaryTitle : ''}</code>
</div>
}
subtitle={t('ongoingTask.subtitle', { taskId: taskId as string })}
>
<ContextMenu
cancelEnabled={cancelEnabled}
canRetry={canRetry}
isRetryableTask={isRetryableTask}
logsVisible={logsVisible}
buttonBarVisible={buttonBarVisible}
onStartOver={startOver}
onRetry={triggerRetry}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
taskId={taskId}
onCancel={triggerCancel}
isCancelButtonDisabled={
isCancelButtonDisabled || cancelStatus !== 'not-executed'
}
/>
</Header>
<OngoingTaskContent
{...props}
logsVisibleOverride={logsVisible}
buttonBarVisibleOverride={buttonBarVisible}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
/>
</>
);
}
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 (
<>
<Header
pageTitleOverride={
presentation
? t('ongoingTask.pageTitle.hasTemplateName', {
templateName: presentation.primaryTitle,
})
: t('ongoingTask.pageTitle.noTemplateName')
}
title={
<div>
{t('ongoingTask.title')}{' '}
<code>{presentation ? presentation.primaryTitle : ''}</code>
</div>
}
subtitle={t('ongoingTask.subtitle', { taskId: taskId as string })}
>
<ContextMenu
cancelEnabled={cancelEnabled}
canRetry={canRetry}
isRetryableTask={isRetryableTask}
logsVisible={logsVisible}
buttonBarVisible={buttonBarVisible}
onStartOver={startOver}
onRetry={triggerRetry}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
taskId={taskId}
onCancel={triggerCancel}
isCancelButtonDisabled={isCancelButtonDisabled}
/>
</Header>
<Content className={classes.contentWrapper}>
{taskStream.error ? (
<Box paddingBottom={2}>
<ErrorPanel
error={taskStream.error}
titleFormat="markdown"
title={taskStream.error.message}
/>
</Box>
) : null}
<Content className={classes.contentWrapper}>
{taskStream.error ? (
<Box paddingBottom={2}>
<TaskSteps
steps={steps}
activeStep={activeStep}
isComplete={taskStream.completed}
isError={Boolean(taskStream.error)}
<ErrorPanel
error={taskStream.error}
titleFormat="markdown"
title={taskStream.error.message}
/>
</Box>
) : null}
<Outputs output={taskStream.output} />
<Box paddingBottom={2}>
<TaskSteps
steps={steps}
activeStep={activeStep}
isComplete={taskStream.completed}
isError={Boolean(taskStream.error)}
/>
</Box>
{buttonBarVisible ? (
<Box paddingBottom={2}>
<Paper>
<Box padding={2}>
<div className={classes.buttonBar}>
<Button
className={classes.cancelButton}
disabled={
!cancelEnabled ||
(cancelStatus !== 'not-executed' && !isRetryableTask) ||
!canCancelTask
}
onClick={triggerCancel}
data-testid="cancel-button"
>
{t('ongoingTask.cancelButtonTitle')}
</Button>
{isRetryableTask && (
<Button
className={classes.retryButton}
disabled={cancelEnabled || !canRetry}
onClick={triggerRetry}
data-testid="retry-button"
>
{t('ongoingTask.retryButtonTitle')}
</Button>
)}
<Button
className={classes.logsVisibilityButton}
color="primary"
variant="outlined"
onClick={() => setLogVisibleState(!logsVisible)}
>
{logsVisible
? t('ongoingTask.hideLogsButtonTitle')
: t('ongoingTask.showLogsButtonTitle')}
</Button>
<Button
variant="contained"
color="primary"
disabled={cancelEnabled || !canStartOver}
onClick={startOver}
data-testid="start-over-button"
>
{t('ongoingTask.startOverButtonTitle')}
</Button>
</div>
</Box>
</Paper>
</Box>
) : null}
<Outputs output={taskStream.output} />
{logsVisible ? (
<Paper style={{ height: '100%' }}>
<Box padding={2} height="100%">
<TaskLogStream logs={taskStream.stepLogs} />
{buttonBarVisible ? (
<Box paddingBottom={2}>
<Paper>
<Box padding={2}>
<div className={classes.buttonBar}>
<Button
className={classes.cancelButton}
disabled={
!cancelEnabled ||
(cancelStatus !== 'not-executed' && !isRetryableTask) ||
!canCancelTask
}
onClick={triggerCancel}
data-testid="cancel-button"
>
{t('ongoingTask.cancelButtonTitle')}
</Button>
{isRetryableTask && (
<Button
className={classes.retryButton}
disabled={cancelEnabled || !canRetry}
onClick={triggerRetry}
data-testid="retry-button"
>
{t('ongoingTask.retryButtonTitle')}
</Button>
)}
<Button
className={classes.logsVisibilityButton}
color="primary"
variant="outlined"
onClick={() => setLogVisibleState(!logsVisible)}
>
{logsVisible
? t('ongoingTask.hideLogsButtonTitle')
: t('ongoingTask.showLogsButtonTitle')}
</Button>
<Button
variant="contained"
color="primary"
disabled={cancelEnabled || !canStartOver}
onClick={startOver}
data-testid="start-over-button"
>
{t('ongoingTask.startOverButtonTitle')}
</Button>
</div>
</Box>
</Paper>
) : null}
</Content>
</>
</Box>
) : null}
{logsVisible ? (
<Paper style={{ height: '100%' }}>
<Box padding={2} height="100%">
<TaskLogStream logs={taskStream.stepLogs} />
</Box>
</Paper>
) : null}
</Content>
);
}
@@ -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';
@@ -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';