Skip to content

Commit 8d4bf6b

Browse files
committed
feat(FR-2613): migrate VFolder trash lifecycle mutations to V2 GraphQL API
Migrates delete_by_id and delete_from_trash_bin from legacy REST to the Strawberry V2 Relay mutations (deleteVfolderV2, purgeVfolderV2) added in manager 26.4.2, per the FR-2572 epic guidance. Introduces new V2 component files alongside the legacy ones so the REST path remains intact for backward compatibility through the 26.4.x window. - Add VFolderNodesV2 and DeleteVFolderModalV2 with new fragments (VFolderNodesV2Fragment, DeleteVFolderModalV2Fragment) and V2 mutations. Preserves existing UX: occupied-session parsing for delete errors, notification upserts, onRemoveRow callbacks, and delete/restore permission checks. - Switch VFolderNodeListPage and AdminVFolderNodeListPage to the V2 components and fragment spreads. - Leave the restore path on the legacy REST client; restoreVfolderV2 is not yet available in the V2 schema. RestoreVFolderModal and its fragment stay untouched and the row-level restore action in VFolderNodesV2 reuses baiClient.vfolder.restore_from_trash_bin until the backend mutation lands (follow-up tracked under FR-2572). - Legacy VFolderNodes.tsx and DeleteVFolderModal.tsx remain unchanged for compatibility.
1 parent 52e0499 commit 8d4bf6b

29 files changed

Lines changed: 710 additions & 235 deletions

packages/backend.ai-ui/src/components/Table/BAINameActionCell.tsx

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import BAIButton from '../BAIButton';
33
import BAILink from '../BAILink';
44
import BAIText from '../BAIText';
55
import { MoreOutlined } from '@ant-design/icons';
6-
import { Dropdown, theme, Tooltip } from 'antd';
7-
import type { MenuProps } from 'antd';
6+
import { App, Dropdown, Popconfirm, theme, Tooltip } from 'antd';
7+
import type { MenuProps, PopconfirmProps } from 'antd';
88
import { createStyles } from 'antd-style';
99
import React, { useEffect, useRef, useState, useTransition } from 'react';
1010
import type { LinkProps } from 'react-router-dom';
@@ -38,6 +38,19 @@ export interface BAINameActionCellAction {
3838
* - 'always': always shown only in the more menu
3939
*/
4040
showInMenu?: 'auto' | 'always';
41+
/**
42+
* Ant Design Popconfirm props to gate the action behind a confirmation
43+
* popover. When set, the visible icon button is wrapped with `<Popconfirm>`
44+
* and the confirm action should be wired via `popConfirm.onConfirm`.
45+
*
46+
* When the action overflows into the more menu, the menu item falls back
47+
* to a `Modal.confirm` dialog that mirrors the popConfirm title,
48+
* description, okText, cancelText, and button props — so the
49+
* confirmation UI is preserved in both visible and overflow states.
50+
* If `onClick`/`action` is also set, those take precedence and the
51+
* popConfirm is ignored in the overflow menu.
52+
*/
53+
popConfirm?: Omit<PopconfirmProps, 'children'>;
4154
}
4255

4356
export interface BAINameActionCellProps {
@@ -156,6 +169,7 @@ const BAINameActionCell: React.FC<BAINameActionCellProps> = ({
156169
'use memo';
157170
const { styles, cx } = useStyles();
158171
const { token } = theme.useToken();
172+
const { modal } = App.useApp();
159173
const [, startTransition] = useTransition();
160174
const containerRef = useRef<HTMLDivElement>(null);
161175
const titleAreaRef = useRef<HTMLDivElement>(null);
@@ -255,10 +269,40 @@ const BAINameActionCell: React.FC<BAINameActionCellProps> = ({
255269
danger: action.type === 'danger',
256270
disabled: action.disabled,
257271
onClick: () => {
258-
action.onClick?.();
259-
if (action.action) {
260-
startTransition(async () => {
261-
await action.action!();
272+
if (action.onClick || action.action) {
273+
action.onClick?.();
274+
if (action.action) {
275+
startTransition(async () => {
276+
await action.action!();
277+
});
278+
}
279+
return;
280+
}
281+
if (action.popConfirm) {
282+
const {
283+
title: confirmTitle,
284+
description,
285+
okText,
286+
cancelText,
287+
okButtonProps,
288+
cancelButtonProps,
289+
onConfirm,
290+
onCancel,
291+
} = action.popConfirm;
292+
const resolveNode = (
293+
value: PopconfirmProps['title'] | PopconfirmProps['description'],
294+
): React.ReactNode =>
295+
typeof value === 'function' ? value() : (value ?? null);
296+
modal.confirm({
297+
title: resolveNode(confirmTitle),
298+
content: resolveNode(description),
299+
okText,
300+
cancelText,
301+
okButtonProps,
302+
cancelButtonProps,
303+
okType: okButtonProps?.danger ? 'danger' : 'primary',
304+
onOk: () => onConfirm?.(),
305+
onCancel: () => onCancel?.(),
262306
});
263307
}
264308
},
@@ -351,21 +395,29 @@ const BAINameActionCell: React.FC<BAINameActionCellProps> = ({
351395
? styles.actionButtonDanger
352396
: styles.actionButtonDefault;
353397

398+
const button = (
399+
<BAIButton
400+
type="text"
401+
size="small"
402+
icon={action.icon}
403+
disabled={action.disabled}
404+
className={buttonClassName}
405+
style={action.style}
406+
onClick={action.onClick}
407+
action={action.action}
408+
/>
409+
);
410+
354411
return (
355412
<Tooltip
356413
key={action.key}
357414
title={action.disabled ? action.disabledReason : action.title}
358415
>
359-
<BAIButton
360-
type="text"
361-
size="small"
362-
icon={action.icon}
363-
disabled={action.disabled}
364-
className={buttonClassName}
365-
style={action.style}
366-
onClick={action.onClick}
367-
action={action.action}
368-
/>
416+
{action.popConfirm && !action.disabled ? (
417+
<Popconfirm {...action.popConfirm}>{button}</Popconfirm>
418+
) : (
419+
button
420+
)}
369421
</Tooltip>
370422
);
371423
})}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
import { DeleteForeverVFolderModalV2Fragment$key } from '../__generated__/DeleteForeverVFolderModalV2Fragment.graphql';
6+
import { DeleteForeverVFolderModalV2Mutation } from '../__generated__/DeleteForeverVFolderModalV2Mutation.graphql';
7+
import { Alert, App, theme, Typography } from 'antd';
8+
import {
9+
BAIConfirmModalWithInput,
10+
BAIConfirmModalWithInputProps,
11+
BAIFlex,
12+
toLocalId,
13+
useErrorMessageResolver,
14+
} from 'backend.ai-ui';
15+
import * as _ from 'lodash-es';
16+
import React from 'react';
17+
import { useTranslation } from 'react-i18next';
18+
import { graphql, useFragment, useMutation } from 'react-relay';
19+
20+
interface DeleteForeverVFolderModalV2Props extends Omit<
21+
BAIConfirmModalWithInputProps,
22+
'confirmText' | 'content' | 'title' | 'onOk' | 'onCancel'
23+
> {
24+
vfolderFrgmts?: DeleteForeverVFolderModalV2Fragment$key;
25+
onRequestClose?: (success: boolean) => void;
26+
}
27+
28+
const DeleteForeverVFolderModalV2: React.FC<
29+
DeleteForeverVFolderModalV2Props
30+
> = ({ vfolderFrgmts, onRequestClose, ...modalProps }) => {
31+
'use memo';
32+
const { t } = useTranslation();
33+
const { message } = App.useApp();
34+
const { token } = theme.useToken();
35+
const { getErrorMessage } = useErrorMessageResolver();
36+
37+
const vfolders = useFragment(
38+
graphql`
39+
fragment DeleteForeverVFolderModalV2Fragment on VirtualFolderNode
40+
@relay(plural: true) {
41+
id
42+
name
43+
}
44+
`,
45+
vfolderFrgmts,
46+
);
47+
48+
const [commitBulkPurgeMutation] =
49+
useMutation<DeleteForeverVFolderModalV2Mutation>(graphql`
50+
mutation DeleteForeverVFolderModalV2Mutation(
51+
$input: BulkPurgeVFoldersV2Input!
52+
) {
53+
bulkPurgeVfoldersV2(input: $input) {
54+
purgedCount
55+
}
56+
}
57+
`);
58+
59+
const confirmText = t('data.folders.DeleteForeverConfirmText');
60+
const purgeable = vfolders ?? [];
61+
62+
return (
63+
<BAIConfirmModalWithInput
64+
{...modalProps}
65+
title={t('dialog.title.DeleteForever')}
66+
okText={t('data.folders.DeleteForever')}
67+
confirmText={confirmText}
68+
content={
69+
<BAIFlex
70+
direction="column"
71+
gap="md"
72+
align="stretch"
73+
style={{ marginBottom: token.marginXS, width: '100%' }}
74+
>
75+
<Alert
76+
type="warning"
77+
title={t('dialog.warning.DeleteForeverDesc')}
78+
style={{ width: '100%' }}
79+
/>
80+
<Typography.Text>
81+
{purgeable.length === 1
82+
? t('data.folders.DeleteForeverDescription', {
83+
folderName: purgeable[0]?.name,
84+
})
85+
: t('data.folders.DeleteForeverMultipleDescription', {
86+
folderLength: purgeable.length,
87+
})}
88+
</Typography.Text>
89+
<BAIFlex>
90+
<Typography.Text style={{ marginRight: token.marginXXS }}>
91+
{t('data.folders.TypeToConfirmDeleteForever')}
92+
</Typography.Text>
93+
(<Typography.Text code>{confirmText}</Typography.Text>)
94+
</BAIFlex>
95+
</BAIFlex>
96+
}
97+
onOk={() => {
98+
if (purgeable.length === 0) {
99+
onRequestClose?.(false);
100+
return;
101+
}
102+
const ids = _.map(purgeable, (vfolder) => toLocalId(vfolder.id));
103+
commitBulkPurgeMutation({
104+
variables: { input: { ids } },
105+
onCompleted: (data, errors) => {
106+
if (errors && errors.length > 0) {
107+
const firstError = errors[0];
108+
message.error(firstError?.message ?? getErrorMessage(firstError));
109+
return;
110+
}
111+
const purgedCount = data?.bulkPurgeVfoldersV2?.purgedCount ?? 0;
112+
if (purgedCount === 0) {
113+
message.error(
114+
t('data.folders.FailedToDeleteFolders', {
115+
folderNames: _.map(purgeable, 'name').join(', '),
116+
}),
117+
);
118+
return;
119+
}
120+
if (purgeable.length === 1) {
121+
message.success(
122+
t('data.folders.FolderDeletedForever', {
123+
folderName: purgeable[0]?.name,
124+
}),
125+
);
126+
} else {
127+
message.success(
128+
t('data.folders.MultipleFolderDeletedForever', {
129+
count: purgedCount,
130+
total: purgeable.length,
131+
}),
132+
);
133+
}
134+
onRequestClose?.(true);
135+
},
136+
onError: (error) => {
137+
message.error(getErrorMessage(error));
138+
},
139+
});
140+
}}
141+
onCancel={() => onRequestClose?.(false)}
142+
/>
143+
);
144+
};
145+
146+
export default DeleteForeverVFolderModalV2;

0 commit comments

Comments
 (0)