Skip to content

Commit cbde122

Browse files
committed
fix(FR-2622): add delete-folder option and trash notification on model card deletion
1 parent 4080041 commit cbde122

4 files changed

Lines changed: 196 additions & 50 deletions

File tree

packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.stories.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ const meta: Meta<typeof BAIDeleteConfirmModal> = {
2020
**BAIDeleteConfirmModal** is a unified delete confirmation modal for table row deletion.
2121
2222
## Behavior
23-
- **Single item**: Simple confirm dialog with item name displayed. OK button is immediately enabled.
24-
- **Multiple items (2+)**: Requires typing confirmation text (localized "Delete") before OK is enabled.
25-
- **\`requireConfirmInput\`**: Forces text-input confirmation even for a single item.
23+
- **Single item**: Simple confirm dialog. OK button is immediately enabled.
24+
- **Single item + \`requireConfirmInput\`**: Requires typing the item name. Item list is hidden — the name already appears in the description.
25+
- **Multiple items (2+)**: Shows scrollable item list followed by a confirmation input requiring "Delete" to be typed.
2626
2727
## Key Features
2828
- Accepts \`React.ReactNode\` for item labels (icons, tags, custom rendering)
29-
- Scrollable item list for large selections
29+
- Scrollable item list for multi-item selections
3030
- \`extraContent\` slot for domain-specific additions (checkboxes, warnings)
31-
- Built on \`BAIConfirmModalWithInput\` (multi) and \`BAIModal\` (single)
31+
- Built on \`BAIModal\`
3232
`,
3333
},
3434
},
@@ -74,7 +74,7 @@ export const SingleItemWithInput: Story = {
7474
docs: {
7575
description: {
7676
story:
77-
'Single item with `requireConfirmInput={true}`. User must type the item name to confirm.',
77+
'Single item with `requireConfirmInput={true}`. Item list is hidden (name already appears in description). User must type the item name into the confirmation input to enable the Delete button.',
7878
},
7979
},
8080
},
@@ -106,7 +106,7 @@ export const MultipleItems: Story = {
106106
docs: {
107107
description: {
108108
story:
109-
'Multiple items require typing "Delete" to confirm. Shows scrollable item list.',
109+
'Multiple items require typing "Delete" to confirm. Shows scrollable item list above the confirmation input.',
110110
},
111111
},
112112
},

packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.tsx

Lines changed: 62 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import BAIConfirmModalWithInput from './BAIConfirmModalWithInput';
21
import BAIFlex from './BAIFlex';
32
import BAIModal, { type BAIModalProps } from './BAIModal';
43
import BAIText from './BAIText';
54
import { ExclamationCircleFilled } from '@ant-design/icons';
6-
import { theme, Typography, type InputProps } from 'antd';
7-
import React from 'react';
5+
import { Form, Input, theme, Typography, type InputProps } from 'antd';
6+
import React, { useState } from 'react';
87
import { Trans, useTranslation } from 'react-i18next';
98

109
const { Text } = Typography;
@@ -39,7 +38,7 @@ export interface BAIDeleteConfirmModalProps extends Omit<
3938
inputLabel?: React.ReactNode;
4039
/** Additional props for the confirmation Input. */
4140
inputProps?: InputProps;
42-
/** Content rendered between the item list and the input field (e.g. checkboxes). */
41+
/** Content rendered between the input field and "cannot be undone" text (e.g. checkboxes). */
4342
extraContent?: React.ReactNode;
4443
/** Max height (px) of the scrollable item list. Default: 200. Set 0 for no limit. */
4544
itemListMaxHeight?: number;
@@ -71,6 +70,7 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
7170

7271
const { t } = useTranslation();
7372
const { token } = theme.useToken();
73+
const [typedText, setTypedText] = useState('');
7474

7575
const needsInput = items.length > 1 || requireConfirmInput;
7676

@@ -101,6 +101,17 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
101101
/>
102102
);
103103

104+
const modalTitle = (
105+
<BAIFlex direction="column" justify="start" align="start">
106+
<Text strong>
107+
<ExclamationCircleFilled
108+
style={{ color: token.colorWarning, marginRight: 5 }}
109+
/>
110+
{resolvedTitle}
111+
</Text>
112+
</BAIFlex>
113+
);
114+
104115
const itemListContent =
105116
items.length > 0 ? (
106117
<div
@@ -125,49 +136,56 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
125136
</div>
126137
) : null;
127138

128-
const bodyContent = (
129-
<BAIFlex direction="column" align="stretch" gap="xs">
130-
<Text>{resolvedDescription}</Text>
131-
{itemListContent}
132-
<Text type="danger">
133-
{t('comp:BAIDeleteConfirmModal.CannotBeUndone')}
134-
</Text>
135-
{extraContent}
136-
</BAIFlex>
137-
);
138-
139139
if (needsInput) {
140140
return (
141-
<BAIConfirmModalWithInput
142-
{...restModalProps}
141+
<BAIModal
143142
destroyOnHidden
144-
title={resolvedTitle}
145-
confirmText={resolvedConfirmText}
146-
content={bodyContent}
147-
inputLabel={resolvedInputLabel}
148-
inputProps={inputProps}
143+
{...restModalProps}
144+
title={modalTitle}
149145
okText={resolvedOkText}
150-
okButtonProps={okButtonProps}
151-
onOk={onOk}
152-
onCancel={onCancel}
153-
/>
146+
okButtonProps={{
147+
danger: true,
148+
disabled: typedText !== resolvedConfirmText,
149+
...okButtonProps,
150+
}}
151+
onOk={(e) => {
152+
setTypedText('');
153+
onOk?.(e);
154+
}}
155+
onCancel={(e) => {
156+
setTypedText('');
157+
onCancel?.(e);
158+
}}
159+
>
160+
<BAIFlex direction="column" align="stretch" gap="xs">
161+
<Text>{resolvedDescription}</Text>
162+
{items.length > 1 && itemListContent}
163+
<Form layout="vertical" preserve={false}>
164+
<Form.Item label={resolvedInputLabel} style={{ marginBottom: 0 }}>
165+
<Input
166+
autoFocus
167+
autoComplete="off"
168+
allowClear
169+
value={typedText}
170+
onChange={(e) => setTypedText(e.target.value)}
171+
{...inputProps}
172+
/>
173+
</Form.Item>
174+
</Form>
175+
<Text type="danger">
176+
{t('comp:BAIDeleteConfirmModal.CannotBeUndone')}
177+
</Text>
178+
{extraContent}
179+
</BAIFlex>
180+
</BAIModal>
154181
);
155182
}
156183

157184
return (
158185
<BAIModal
159186
{...restModalProps}
160187
destroyOnHidden
161-
title={
162-
<BAIFlex direction="column" justify="start" align="start">
163-
<Text strong>
164-
<ExclamationCircleFilled
165-
style={{ color: token.colorWarning, marginRight: 5 }}
166-
/>
167-
{resolvedTitle}
168-
</Text>
169-
</BAIFlex>
170-
}
188+
title={modalTitle}
171189
okText={resolvedOkText}
172190
okButtonProps={{
173191
danger: true,
@@ -177,7 +195,14 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
177195
onOk={onOk}
178196
onCancel={onCancel}
179197
>
180-
{bodyContent}
198+
<BAIFlex direction="column" align="stretch" gap="xs">
199+
<Text>{resolvedDescription}</Text>
200+
{itemListContent}
201+
<Text type="danger">
202+
{t('comp:BAIDeleteConfirmModal.CannotBeUndone')}
203+
</Text>
204+
{extraContent}
205+
</BAIFlex>
181206
</BAIModal>
182207
);
183208
};

react/src/pages/AdminModelCardListPage.tsx

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,26 @@ import type {
1111
ModelCardV2OrderBy,
1212
} from '../__generated__/AdminModelCardListPageQuery.graphql';
1313
import AdminModelCardSettingModal from '../components/AdminModelCardSettingModal';
14+
import { useFolderExplorerOpener } from '../components/FolderExplorerOpener';
1415
import { convertToOrderBy, handleRowSelectionChange } from '../helper';
16+
import { useSuspendedBackendaiClient } from '../hooks';
1517
import { useBAIPaginationOptionStateOnSearchParam } from '../hooks/reactPaginationQueryOptions';
18+
import { useTanMutation } from '../hooks/reactQueryAlias';
19+
import { useSetBAINotification } from '../hooks/useBAINotification';
1620
import { useBAISettingUserState } from '../hooks/useBAISetting';
1721
import { useCurrentProjectValue } from '../hooks/useCurrentProject';
1822
import { SettingOutlined } from '@ant-design/icons';
19-
import { App, Typography } from 'antd';
23+
import { shapes } from '@dicebear/collection';
24+
import { createAvatar } from '@dicebear/core';
25+
import { App, Checkbox, Tooltip, Typography, theme } from 'antd';
2026
import {
2127
BAIButton,
2228
BAIColumnType,
2329
BAIDeleteConfirmModal,
2430
BAIFetchKeyButton,
2531
BAIFlex,
2632
BAIGraphQLPropertyFilter,
33+
BAILink,
2734
BAINameActionCell,
2835
BAISelectionLabel,
2936
BAITable,
@@ -62,7 +69,10 @@ const AdminModelCardListPage: React.FC = () => {
6269

6370
const { t } = useTranslation();
6471
const { message } = App.useApp();
72+
const { token } = theme.useToken();
6573
const { logger } = useBAILogger();
74+
const { upsertNotification } = useSetBAINotification();
75+
const { generateFolderPath } = useFolderExplorerOpener();
6676
const currentProject = useCurrentProjectValue();
6777
const [columnOverrides, setColumnOverrides] = useBAISettingUserState(
6878
'table_column_overrides.AdminModelCardListPage',
@@ -77,6 +87,7 @@ const AdminModelCardListPage: React.FC = () => {
7787
);
7888
const [deletingModelCard, setDeletingModelCard] =
7989
useState<ModelCardNode | null>(null);
90+
const [alsoDeleteFolder, setAlsoDeleteFolder] = useState(false);
8091
const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false);
8192
const {
8293
baiPaginationOption,
@@ -144,6 +155,13 @@ const AdminModelCardListPage: React.FC = () => {
144155
node {
145156
id
146157
name
158+
vfolderId
159+
vfolder {
160+
id
161+
metadata {
162+
name
163+
}
164+
}
147165
domainName
148166
projectId
149167
accessLevel
@@ -185,6 +203,12 @@ const AdminModelCardListPage: React.FC = () => {
185203
}
186204
`);
187205

206+
const baiClient = useSuspendedBackendaiClient();
207+
const deleteVFolderMutation = useTanMutation({
208+
mutationFn: (vfolderId: string) =>
209+
baiClient.vfolder.delete_by_id(vfolderId),
210+
});
211+
188212
const [commitBulkDeleteModelCards, isBulkDeleteInFlight] =
189213
useMutation<AdminModelCardListPageBulkDeleteMutation>(graphql`
190214
mutation AdminModelCardListPageBulkDeleteMutation(
@@ -428,6 +452,49 @@ const AdminModelCardListPage: React.FC = () => {
428452
description={t('adminModelCard.ConfirmDelete', {
429453
name: deletingModelCard?.name,
430454
})}
455+
requireConfirmInput
456+
extraContent={
457+
<Tooltip title={t('adminModelCard.AlsoDeleteModelFolderTooltip')}>
458+
<Checkbox
459+
checked={alsoDeleteFolder}
460+
onChange={(e) => setAlsoDeleteFolder(e.target.checked)}
461+
>
462+
{t('adminModelCard.AlsoDeleteModelFolder')}
463+
{deletingModelCard?.vfolder && (
464+
<span style={{ marginLeft: token.marginXXS }}>
465+
{'('}
466+
<img
467+
draggable={false}
468+
onDragStart={(e) => e.preventDefault()}
469+
style={{
470+
borderRadius: '0.25em',
471+
width: '1em',
472+
height: '1em',
473+
borderWidth: 0.5,
474+
borderStyle: 'solid',
475+
borderColor: token.colorBorder,
476+
userSelect: 'none',
477+
verticalAlign: 'middle',
478+
marginInline: token.marginXXS,
479+
}}
480+
src={createAvatar(shapes, {
481+
seed: deletingModelCard.vfolderId,
482+
shape3: [],
483+
}).toDataUri()}
484+
alt="VFolder Identicon"
485+
/>
486+
<BAILink
487+
to={generateFolderPath(deletingModelCard.vfolderId)}
488+
onClick={(e) => e.stopPropagation()}
489+
>
490+
{deletingModelCard.vfolder.metadata.name}
491+
</BAILink>
492+
{')'}
493+
</span>
494+
)}
495+
</Checkbox>
496+
</Tooltip>
497+
}
431498
onOk={() => {
432499
if (deletingModelCard) {
433500
return new Promise<void>((resolve, reject) => {
@@ -442,10 +509,56 @@ const AdminModelCardListPage: React.FC = () => {
442509
reject();
443510
return;
444511
}
445-
message.success(t('adminModelCard.ModelCardDeleted'));
446-
setDeletingModelCard(null);
447-
updateFetchKey();
448-
resolve();
512+
513+
const vfolderId = deletingModelCard.vfolderId;
514+
const folderName = deletingModelCard.vfolder?.metadata.name;
515+
const folderTrashSearch = new URLSearchParams({
516+
statusCategory: 'deleted',
517+
filter: folderName
518+
? `name == "${folderName}"`
519+
: `id == "${vfolderId}"`,
520+
}).toString();
521+
522+
const finish = (folderMoved: boolean) => {
523+
if (folderMoved) {
524+
upsertNotification({
525+
type: 'success',
526+
message: t('adminModelCard.ModelCardAndFolderDeleted'),
527+
to: {
528+
pathname: '/admin-data',
529+
search: folderTrashSearch,
530+
},
531+
toText: t('adminModelCard.GoToTrash'),
532+
open: true,
533+
duration: 4,
534+
extraData: null,
535+
});
536+
} else {
537+
message.success(
538+
t('adminModelCard.ModelCardDeletedFolderKept'),
539+
);
540+
}
541+
setDeletingModelCard(null);
542+
setAlsoDeleteFolder(false);
543+
updateFetchKey();
544+
resolve();
545+
};
546+
547+
if (alsoDeleteFolder && vfolderId) {
548+
deleteVFolderMutation.mutate(vfolderId, {
549+
onSuccess: () => finish(true),
550+
onError: (error) => {
551+
logger.error(error);
552+
message.error(
553+
(error as Error)?.message ||
554+
t('general.ErrorOccurred'),
555+
);
556+
finish(false);
557+
},
558+
});
559+
} else {
560+
finish(false);
561+
}
449562
},
450563
onError: (error) => {
451564
logger.error(error);
@@ -456,7 +569,10 @@ const AdminModelCardListPage: React.FC = () => {
456569
});
457570
}
458571
}}
459-
onCancel={() => setDeletingModelCard(null)}
572+
onCancel={() => {
573+
setDeletingModelCard(null);
574+
setAlsoDeleteFolder(false);
575+
}}
460576
/>
461577
<BAIDeleteConfirmModal
462578
open={isBulkDeleteOpen}

0 commit comments

Comments
 (0)