scaffolder: add header with context menu to NFS task page

Adds a Header from @backstage/ui with a kebab menu to the individual task
sub-page in the new frontend system, restoring the ability to show/hide
logs and button bar, as well as start over, retry, and cancel actions.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-20 15:20:20 +01:00
parent 849f5995b5
commit 82bf368483
4 changed files with 249 additions and 1 deletions
+2
View File
@@ -74,6 +74,7 @@
"@backstage/plugin-techdocs-common": "workspace:^",
"@backstage/plugin-techdocs-react": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/ui": "workspace:^",
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.1.0",
"@codemirror/view": "^6.0.0",
@@ -81,6 +82,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@react-hookz/web": "^24.0.0",
"@remixicon/react": "^4.6.0",
"@rjsf/core": "5.24.13",
"@rjsf/material-ui": "5.24.13",
"@rjsf/utils": "5.24.13",
@@ -58,6 +58,7 @@ import { scaffolderTranslationRef } from '../../translation';
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
import { default as reactUseAsync } from 'react-use/esm/useAsync';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { OngoingTaskContextMenu } from './OngoingTaskContextMenu';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -132,9 +133,119 @@ export function OngoingTaskBody(props: {
output?: ScaffolderTaskOutput;
}>;
}) {
const { taskId } = useParams();
const taskStream = useTaskEventStream(taskId!);
const entityPresentationApi = useApi(entityPresentationApiRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const [logsVisible, setLogVisibleState] = useState(false);
const [buttonBarVisible, setButtonBarVisibleState] = useState(true);
const { allowed: canCancelTask } = usePermission({
permission: taskCancelPermission,
resourceRef: taskId,
});
const { allowed: canReadTask } = usePermission({
permission: taskReadPermission,
resourceRef: taskId,
});
const { allowed: canCreateTask } = usePermission({
permission: taskCreatePermission,
});
const isRetryableTask =
taskStream.task?.spec.EXPERIMENTAL_recovery?.EXPERIMENTAL_strategy ===
'startOver';
const canRetry = canReadTask && canCreateTask && isRetryableTask;
const cancelEnabled = !(taskStream.cancelled || taskStream.completed);
const isCancelButtonDisabled = !cancelEnabled || !canCancelTask;
const { value: presentation } = reactUseAsync(async () => {
const templateEntityRef = taskStream.task?.spec.templateInfo?.entityRef;
if (!templateEntityRef) {
return undefined;
}
return entityPresentationApi.forEntity(templateEntityRef).promise;
}, [entityPresentationApi, taskStream.task?.spec.templateInfo?.entityRef]);
const templateRouteRef = useRouteRef(selectedTemplateRouteRef);
const navigate = useNavigate();
const analytics = useAnalytics();
const scaffolderApi = useApi(scaffolderApiRef);
const startOver = useCallback(() => {
const { namespace, name } =
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
const formData = taskStream.task?.spec.parameters ?? {};
if (!namespace || !name) {
return;
}
analytics.captureEvent('click', `Task has been started over`);
navigate({
pathname: templateRouteRef({
namespace,
templateName: name,
}),
search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`,
});
}, [
analytics,
navigate,
taskStream.task?.spec.parameters,
taskStream.task?.spec.templateInfo?.entity?.metadata,
templateRouteRef,
]);
const [, { execute: triggerRetry }] = useAsync(async () => {
if (taskId) {
analytics.captureEvent('retried', 'Template has been retried');
await scaffolderApi.retry?.(taskId);
}
});
const [{ status: cancelStatus }, { execute: triggerCancel }] = useAsync(
async () => {
if (taskId) {
analytics.captureEvent('cancelled', 'Template has been cancelled');
await scaffolderApi.cancelTask(taskId);
}
},
);
return (
<OngoingTaskAnalyticsContext>
<OngoingTaskContent {...props} />
<OngoingTaskContextMenu
title={presentation?.primaryTitle ?? t('ongoingTask.title')}
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'
}
/>
<OngoingTaskContent
{...props}
logsVisibleOverride={logsVisible}
buttonBarVisibleOverride={buttonBarVisible}
onToggleLogs={setLogVisibleState}
onToggleButtonBar={setButtonBarVisibleState}
/>
</OngoingTaskAnalyticsContext>
);
}
@@ -0,0 +1,133 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Header, MenuTrigger, ButtonIcon, Menu, MenuItem } from '@backstage/ui';
import {
RiMore2Line,
RiFileListLine,
RiAddCircleLine,
RiRepeatLine,
RiReplay10Line,
RiCloseCircleLine,
} from '@remixicon/react';
import { usePermission } from '@backstage/plugin-permission-react';
import {
taskReadPermission,
taskCreatePermission,
} from '@backstage/plugin-scaffolder-common/alpha';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
type OngoingTaskContextMenuProps = {
title: string;
cancelEnabled?: boolean;
canRetry: boolean;
isRetryableTask: boolean;
logsVisible?: boolean;
buttonBarVisible?: boolean;
onRetry?: () => void;
onStartOver?: () => void;
onToggleLogs?: (state: boolean) => void;
onToggleButtonBar?: (state: boolean) => void;
taskId?: string;
isCancelButtonDisabled: boolean;
onCancel: () => void;
};
export function OngoingTaskContextMenu(props: OngoingTaskContextMenuProps) {
const {
title,
cancelEnabled,
canRetry,
isRetryableTask,
logsVisible,
buttonBarVisible,
onRetry,
onStartOver,
onToggleLogs,
onToggleButtonBar,
taskId,
} = props;
const { t } = useTranslationRef(scaffolderTranslationRef);
const { allowed: canReadTask } = usePermission({
permission: taskReadPermission,
resourceRef: taskId,
});
const { allowed: canCreateTask } = usePermission({
permission: taskCreatePermission,
});
const canStartOver = canReadTask && canCreateTask;
return (
<Header
title={title}
customActions={
<MenuTrigger>
<ButtonIcon
variant="tertiary"
icon={<RiMore2Line />}
aria-label="More options"
/>
<Menu placement="bottom end">
<MenuItem
onAction={() => onToggleLogs?.(!logsVisible)}
iconStart={<RiFileListLine size={16} />}
>
{logsVisible
? t('ongoingTask.contextMenu.hideLogs')
: t('ongoingTask.contextMenu.showLogs')}
</MenuItem>
<MenuItem
onAction={() => onToggleButtonBar?.(!buttonBarVisible)}
iconStart={<RiAddCircleLine size={16} />}
>
{buttonBarVisible
? t('ongoingTask.contextMenu.hideButtonBar')
: t('ongoingTask.contextMenu.showButtonBar')}
</MenuItem>
<MenuItem
onAction={onStartOver}
isDisabled={cancelEnabled || !canStartOver}
iconStart={<RiRepeatLine size={16} />}
>
{t('ongoingTask.contextMenu.startOver')}
</MenuItem>
{isRetryableTask && (
<MenuItem
onAction={onRetry}
isDisabled={cancelEnabled || !canRetry}
iconStart={<RiReplay10Line size={16} />}
>
{t('ongoingTask.contextMenu.retry')}
</MenuItem>
)}
<MenuItem
onAction={props.onCancel}
isDisabled={props.isCancelButtonDisabled}
iconStart={<RiCloseCircleLine size={16} />}
color="danger"
>
{t('ongoingTask.contextMenu.cancel')}
</MenuItem>
</Menu>
</MenuTrigger>
}
/>
);
}
+2
View File
@@ -7031,6 +7031,7 @@ __metadata:
"@backstage/plugin-techdocs-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/ui": "workspace:^"
"@codemirror/language": "npm:^6.0.0"
"@codemirror/legacy-modes": "npm:^6.1.0"
"@codemirror/view": "npm:^6.0.0"
@@ -7038,6 +7039,7 @@ __metadata:
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"
"@react-hookz/web": "npm:^24.0.0"
"@remixicon/react": "npm:^4.6.0"
"@rjsf/core": "npm:5.24.13"
"@rjsf/material-ui": "npm:5.24.13"
"@rjsf/utils": "npm:5.24.13"