Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ const meta: Meta<typeof BAIDeleteConfirmModal> = {
**BAIDeleteConfirmModal** is a unified delete confirmation modal for table row deletion.

## Behavior
- **Single item**: Simple confirm dialog with item name displayed. OK button is immediately enabled.
- **Multiple items (2+)**: Requires typing confirmation text (localized "Delete") before OK is enabled.
- **\`requireConfirmInput\`**: Forces text-input confirmation even for a single item.
- **Single item**: Simple confirm dialog. OK button is immediately enabled.
- **Single item + \`requireConfirmInput\`**: Requires typing the item name. Item list is hidden — the name already appears in the description.
- **Multiple items (2+)**: Shows scrollable item list followed by a confirmation input requiring "Delete" to be typed.

## Key Features
- Accepts \`React.ReactNode\` for item labels (icons, tags, custom rendering)
- Scrollable item list for large selections
- Scrollable item list for multi-item selections
- \`extraContent\` slot for domain-specific additions (checkboxes, warnings)
- Built on \`BAIConfirmModalWithInput\` (multi) and \`BAIModal\` (single)
- Built on \`BAIModal\`
`,
},
},
Expand Down Expand Up @@ -74,7 +74,7 @@ export const SingleItemWithInput: Story = {
docs: {
description: {
story:
'Single item with `requireConfirmInput={true}`. User must type the item name to confirm.',
'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.',
},
},
},
Expand Down Expand Up @@ -106,7 +106,7 @@ export const MultipleItems: Story = {
docs: {
description: {
story:
'Multiple items require typing "Delete" to confirm. Shows scrollable item list.',
'Multiple items require typing "Delete" to confirm. Shows scrollable item list above the confirmation input.',
},
},
},
Expand Down
100 changes: 64 additions & 36 deletions packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import BAIConfirmModalWithInput from './BAIConfirmModalWithInput';
import BAIFlex from './BAIFlex';
import BAIModal, { type BAIModalProps } from './BAIModal';
import BAIText from './BAIText';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { theme, Typography, type InputProps } from 'antd';
import { Form, Input, theme, Typography, type InputProps } from 'antd';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';

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

const { t } = useTranslation();
const { token } = theme.useToken();
const [form] = Form.useForm();
const typedText = Form.useWatch('confirmText', form) ?? '';

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

Expand Down Expand Up @@ -101,6 +102,17 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
/>
);

const modalTitle = (
<BAIFlex direction="column" justify="start" align="start">
<Text strong>
<ExclamationCircleFilled
style={{ color: token.colorWarning, marginRight: 5 }}
/>
{resolvedTitle}
</Text>
</BAIFlex>
);

const itemListContent =
items.length > 0 ? (
<div
Expand All @@ -125,49 +137,58 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
</div>
) : null;

const bodyContent = (
<BAIFlex direction="column" align="stretch" gap="xs">
<Text>{resolvedDescription}</Text>
{itemListContent}
<Text type="danger">
{t('comp:BAIDeleteConfirmModal.CannotBeUndone')}
</Text>
{extraContent}
</BAIFlex>
);

if (needsInput) {
return (
<BAIConfirmModalWithInput
{...restModalProps}
<BAIModal
destroyOnHidden
title={resolvedTitle}
confirmText={resolvedConfirmText}
content={bodyContent}
inputLabel={resolvedInputLabel}
inputProps={inputProps}
{...restModalProps}
title={modalTitle}
okText={resolvedOkText}
okButtonProps={okButtonProps}
onOk={onOk}
onCancel={onCancel}
/>
okButtonProps={{
danger: true,
disabled: typedText !== resolvedConfirmText,
...okButtonProps,
}}
onOk={(e) => {
form.resetFields();
onOk?.(e);
}}
onCancel={(e) => {
form.resetFields();
onCancel?.(e);
}}
>
<BAIFlex direction="column" align="stretch" gap="xs">
<Text>{resolvedDescription}</Text>
{items.length > 1 && itemListContent}
<Form
form={form}
layout="vertical"
requiredMark={false}
preserve={false}
>
<Form.Item
name="confirmText"
label={resolvedInputLabel}
style={{ marginBottom: 0 }}
>
<Input autoFocus autoComplete="off" allowClear {...inputProps} />
</Form.Item>
</Form>
<Text type="danger">
{t('comp:BAIDeleteConfirmModal.CannotBeUndone')}
</Text>
{extraContent}
</BAIFlex>
</BAIModal>
);
}

return (
<BAIModal
{...restModalProps}
destroyOnHidden
title={
<BAIFlex direction="column" justify="start" align="start">
<Text strong>
<ExclamationCircleFilled
style={{ color: token.colorWarning, marginRight: 5 }}
/>
{resolvedTitle}
</Text>
</BAIFlex>
}
title={modalTitle}
okText={resolvedOkText}
okButtonProps={{
danger: true,
Expand All @@ -177,7 +198,14 @@ const BAIDeleteConfirmModal: React.FC<BAIDeleteConfirmModalProps> = ({
onOk={onOk}
onCancel={onCancel}
>
{bodyContent}
<BAIFlex direction="column" align="stretch" gap="xs">
<Text>{resolvedDescription}</Text>
{itemListContent}
<Text type="danger">
{t('comp:BAIDeleteConfirmModal.CannotBeUndone')}
</Text>
{extraContent}
</BAIFlex>
</BAIModal>
);
};
Expand Down
2 changes: 1 addition & 1 deletion packages/backend.ai-ui/src/locale/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"CannotBeUndone": "이 작업은 되돌릴 수 없습니다.",
"DeleteItem": "삭제",
"DeleteNItems": "{{count}}개 항목 삭제",
"TypeToConfirm": "<code>{{confirmText}}</code>을(를) 입력하여 확인하세요."
"TypeToConfirm": "삭제를 위해 <code>{{confirmText}}</code>을(를) 입력하세요."
},
"comp:BAIDeploymentSelect": {
"SelectDeployment": "배포를 선택해주세요"
Expand Down
128 changes: 122 additions & 6 deletions react/src/pages/AdminModelCardListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,27 @@ import type {
ModelCardV2OrderBy,
} from '../__generated__/AdminModelCardListPageQuery.graphql';
import AdminModelCardSettingModal from '../components/AdminModelCardSettingModal';
import { useFolderExplorerOpener } from '../components/FolderExplorerOpener';
import StorageHostFilterInput from '../components/StorageHostFilterInput';
import { convertToOrderBy, handleRowSelectionChange } from '../helper';
import { useSuspendedBackendaiClient } from '../hooks';
import { useBAIPaginationOptionStateOnSearchParam } from '../hooks/reactPaginationQueryOptions';
import { useTanMutation } from '../hooks/reactQueryAlias';
import { useSetBAINotification } from '../hooks/useBAINotification';
import { useBAISettingUserState } from '../hooks/useBAISetting';
import { useCurrentProjectValue } from '../hooks/useCurrentProject';
import { SettingOutlined } from '@ant-design/icons';
import { App, Typography } from 'antd';
import { shapes } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core';
import { App, Checkbox, Tooltip, Typography, theme } from 'antd';
import {
BAIButton,
BAIColumnType,
BAIDeleteConfirmModal,
BAIFetchKeyButton,
BAIFlex,
BAIGraphQLPropertyFilter,
BAILink,
BAINameActionCell,
BAISelectionLabel,
BAITable,
Expand Down Expand Up @@ -64,7 +71,10 @@ const AdminModelCardListPage: React.FC = () => {

const { t } = useTranslation();
const { message } = App.useApp();
const { token } = theme.useToken();
const { logger } = useBAILogger();
const { upsertNotification } = useSetBAINotification();
const { generateFolderPath } = useFolderExplorerOpener();
const currentProject = useCurrentProjectValue();
const [columnOverrides, setColumnOverrides] = useBAISettingUserState(
'table_column_overrides.AdminModelCardListPage',
Expand All @@ -79,6 +89,7 @@ const AdminModelCardListPage: React.FC = () => {
);
const [deletingModelCard, setDeletingModelCard] =
useState<ModelCardNode | null>(null);
const [alsoDeleteFolder, setAlsoDeleteFolder] = useState(false);
const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false);
const {
baiPaginationOption,
Expand Down Expand Up @@ -132,6 +143,13 @@ const AdminModelCardListPage: React.FC = () => {
node {
id
name
vfolderId
vfolder {
id
metadata {
name
}
}
domainName
projectId
accessLevel
Expand Down Expand Up @@ -173,6 +191,12 @@ const AdminModelCardListPage: React.FC = () => {
}
`);

const baiClient = useSuspendedBackendaiClient();
const deleteVFolderMutation = useTanMutation({
mutationFn: (vfolderId: string) =>
baiClient.vfolder.delete_by_id(vfolderId),
});

const [commitBulkDeleteModelCards, isBulkDeleteInFlight] =
useMutation<AdminModelCardListPageBulkDeleteMutation>(graphql`
mutation AdminModelCardListPageBulkDeleteMutation(
Expand Down Expand Up @@ -440,6 +464,49 @@ const AdminModelCardListPage: React.FC = () => {
description={t('adminModelCard.ConfirmDelete', {
Comment thread
agatha197 marked this conversation as resolved.
name: deletingModelCard?.name,
})}
requireConfirmInput
extraContent={
<Tooltip title={t('adminModelCard.AlsoDeleteModelFolderTooltip')}>
<Checkbox
checked={alsoDeleteFolder}
onChange={(e) => setAlsoDeleteFolder(e.target.checked)}
>
{t('adminModelCard.AlsoDeleteModelFolder')}
{deletingModelCard?.vfolder && (
<span style={{ marginLeft: token.marginXXS }}>
{'('}
<img
draggable={false}
onDragStart={(e) => e.preventDefault()}
style={{
borderRadius: '0.25em',
width: '1em',
height: '1em',
borderWidth: 0.5,
borderStyle: 'solid',
borderColor: token.colorBorder,
userSelect: 'none',
verticalAlign: 'middle',
marginInline: token.marginXXS,
}}
src={createAvatar(shapes, {
seed: deletingModelCard.vfolderId,
shape3: [],
}).toDataUri()}
alt="VFolder Identicon"
/>
<BAILink
to={generateFolderPath(deletingModelCard.vfolderId)}
onClick={(e) => e.stopPropagation()}
>
{deletingModelCard.vfolder.metadata.name}
</BAILink>
{')'}
</span>
)}
</Checkbox>
</Tooltip>
}
onOk={() => {
if (deletingModelCard) {
return new Promise<void>((resolve, reject) => {
Expand All @@ -454,10 +521,56 @@ const AdminModelCardListPage: React.FC = () => {
reject();
return;
}
message.success(t('adminModelCard.ModelCardDeleted'));
setDeletingModelCard(null);
updateFetchKey();
resolve();

const vfolderId = deletingModelCard.vfolderId;
const folderName = deletingModelCard.vfolder?.metadata.name;
const folderTrashSearch = new URLSearchParams({
statusCategory: 'deleted',
filter: folderName
? `name == "${folderName}"`
: `id == "${vfolderId}"`,
}).toString();

const finish = (folderMoved: boolean) => {
if (folderMoved) {
upsertNotification({
type: 'success',
message: t('adminModelCard.ModelCardAndFolderDeleted'),
to: {
pathname: '/admin-data',
search: folderTrashSearch,
},
toText: t('adminModelCard.GoToTrash'),
open: true,
duration: 4,
extraData: null,
});
} else {
message.success(
t('adminModelCard.ModelCardDeletedFolderKept'),
);
}
setDeletingModelCard(null);
setAlsoDeleteFolder(false);
updateFetchKey();
resolve();
};

if (alsoDeleteFolder && vfolderId) {
deleteVFolderMutation.mutate(vfolderId, {
onSuccess: () => finish(true),
onError: (error) => {
logger.error(error);
message.error(
(error as Error)?.message ||
t('general.ErrorOccurred'),
);
Comment thread
agatha197 marked this conversation as resolved.
finish(false);
},
});
} else {
finish(false);
}
},
onError: (error) => {
logger.error(error);
Expand All @@ -468,7 +581,10 @@ const AdminModelCardListPage: React.FC = () => {
});
}
}}
onCancel={() => setDeletingModelCard(null)}
onCancel={() => {
setDeletingModelCard(null);
setAlsoDeleteFolder(false);
}}
/>
<BAIDeleteConfirmModal
open={isBulkDeleteOpen}
Expand Down
Loading
Loading