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:
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user