diff --git a/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.stories.tsx b/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.stories.tsx index 976a4e4b5c..1545fadd21 100644 --- a/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.stories.tsx +++ b/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.stories.tsx @@ -20,15 +20,15 @@ const meta: Meta = { **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\` `, }, }, @@ -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.', }, }, }, @@ -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.', }, }, }, diff --git a/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.tsx b/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.tsx index 3a35a30013..b15ee049e9 100644 --- a/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.tsx +++ b/packages/backend.ai-ui/src/components/BAIDeleteConfirmModal.tsx @@ -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'; @@ -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; @@ -71,6 +70,8 @@ const BAIDeleteConfirmModal: React.FC = ({ const { t } = useTranslation(); const { token } = theme.useToken(); + const [form] = Form.useForm(); + const typedText = Form.useWatch('confirmText', form) ?? ''; const needsInput = items.length > 1 || requireConfirmInput; @@ -101,6 +102,17 @@ const BAIDeleteConfirmModal: React.FC = ({ /> ); + const modalTitle = ( + + + + {resolvedTitle} + + + ); + const itemListContent = items.length > 0 ? (
= ({
) : null; - const bodyContent = ( - - {resolvedDescription} - {itemListContent} - - {t('comp:BAIDeleteConfirmModal.CannotBeUndone')} - - {extraContent} - - ); - if (needsInput) { return ( - + okButtonProps={{ + danger: true, + disabled: typedText !== resolvedConfirmText, + ...okButtonProps, + }} + onOk={(e) => { + form.resetFields(); + onOk?.(e); + }} + onCancel={(e) => { + form.resetFields(); + onCancel?.(e); + }} + > + + {resolvedDescription} + {items.length > 1 && itemListContent} +
+ + + +
+ + {t('comp:BAIDeleteConfirmModal.CannotBeUndone')} + + {extraContent} +
+ ); } @@ -158,16 +188,7 @@ const BAIDeleteConfirmModal: React.FC = ({ - - - {resolvedTitle} - - - } + title={modalTitle} okText={resolvedOkText} okButtonProps={{ danger: true, @@ -177,7 +198,14 @@ const BAIDeleteConfirmModal: React.FC = ({ onOk={onOk} onCancel={onCancel} > - {bodyContent} + + {resolvedDescription} + {itemListContent} + + {t('comp:BAIDeleteConfirmModal.CannotBeUndone')} + + {extraContent} + ); }; diff --git a/packages/backend.ai-ui/src/locale/ko.json b/packages/backend.ai-ui/src/locale/ko.json index b2d01d68bf..c8121a31df 100644 --- a/packages/backend.ai-ui/src/locale/ko.json +++ b/packages/backend.ai-ui/src/locale/ko.json @@ -96,7 +96,7 @@ "CannotBeUndone": "이 작업은 되돌릴 수 없습니다.", "DeleteItem": "삭제", "DeleteNItems": "{{count}}개 항목 삭제", - "TypeToConfirm": "{{confirmText}}을(를) 입력하여 확인하세요." + "TypeToConfirm": "삭제를 위해 {{confirmText}}을(를) 입력하세요." }, "comp:BAIDeploymentSelect": { "SelectDeployment": "배포를 선택해주세요" diff --git a/react/src/pages/AdminModelCardListPage.tsx b/react/src/pages/AdminModelCardListPage.tsx index be31cdd243..b5b4c8fc84 100644 --- a/react/src/pages/AdminModelCardListPage.tsx +++ b/react/src/pages/AdminModelCardListPage.tsx @@ -10,13 +10,19 @@ 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, @@ -24,6 +30,7 @@ import { BAIFetchKeyButton, BAIFlex, BAIGraphQLPropertyFilter, + BAILink, BAINameActionCell, BAISelectionLabel, BAITable, @@ -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', @@ -79,6 +89,7 @@ const AdminModelCardListPage: React.FC = () => { ); const [deletingModelCard, setDeletingModelCard] = useState(null); + const [alsoDeleteFolder, setAlsoDeleteFolder] = useState(false); const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false); const { baiPaginationOption, @@ -132,6 +143,13 @@ const AdminModelCardListPage: React.FC = () => { node { id name + vfolderId + vfolder { + id + metadata { + name + } + } domainName projectId accessLevel @@ -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(graphql` mutation AdminModelCardListPageBulkDeleteMutation( @@ -440,6 +464,49 @@ const AdminModelCardListPage: React.FC = () => { description={t('adminModelCard.ConfirmDelete', { name: deletingModelCard?.name, })} + requireConfirmInput + extraContent={ + + setAlsoDeleteFolder(e.target.checked)} + > + {t('adminModelCard.AlsoDeleteModelFolder')} + {deletingModelCard?.vfolder && ( + + {'('} + 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" + /> + e.stopPropagation()} + > + {deletingModelCard.vfolder.metadata.name} + + {')'} + + )} + + + } onOk={() => { if (deletingModelCard) { return new Promise((resolve, reject) => { @@ -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'), + ); + finish(false); + }, + }); + } else { + finish(false); + } }, onError: (error) => { logger.error(error); @@ -468,7 +581,10 @@ const AdminModelCardListPage: React.FC = () => { }); } }} - onCancel={() => setDeletingModelCard(null)} + onCancel={() => { + setDeletingModelCard(null); + setAlsoDeleteFolder(false); + }} /> Trash.", "Architecture": "Architecture", "ArchitectureTooltip": "The model architecture (e.g., Transformer, CNN, RNN).", "Author": "Author", @@ -146,12 +148,15 @@ "EnterProjectId": "Enter project ID", "Framework": "Framework", "FrameworkTooltip": "Frameworks used by the model (e.g., PyTorch, TensorFlow). Press Enter to add.", + "GoToTrash": "Go to Data > Trash", "Label": "Label", "LabelTooltip": "Custom tags for categorizing and filtering models. Press Enter to add.", "License": "License", "LicenseTooltip": "The license under which the model is distributed (e.g., MIT, Apache-2.0).", + "ModelCardAndFolderDeleted": "Model card and folder have been moved to trash.", "ModelCardCreated": "Model card has been created.", "ModelCardDeleted": "Model card has been deleted.", + "ModelCardDeletedFolderKept": "Model card has been deleted. The model folder was not deleted.", "ModelCardUpdated": "Model card has been updated.", "ModelCards": "Model Cards", "ModelStorageFolder": "Model Storage Folder",