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..b38fc7fdfb 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/components/AdminModelCardSettingModal.tsx b/react/src/components/AdminModelCardSettingModal.tsx index 764418e8ac..0f247f3617 100644 --- a/react/src/components/AdminModelCardSettingModal.tsx +++ b/react/src/components/AdminModelCardSettingModal.tsx @@ -11,8 +11,7 @@ import { } from '../hooks/useCurrentProject'; import FolderCreateModalV2 from './FolderCreateModalV2'; import FolderLink from './FolderLink'; -import { shapes } from '@dicebear/collection'; -import { createAvatar } from '@dicebear/core'; +import VFolderNodeIdenticonV2 from './VFolderNodeIdenticonV2'; import { App, Button, @@ -23,7 +22,6 @@ import { Popconfirm, Select, Typography, - theme, } from 'antd'; import { BAIButton, @@ -83,7 +81,6 @@ const AdminModelCardSettingModal: React.FC = ({ const { t } = useTranslation(); const { message } = App.useApp(); const { logger } = useBAILogger(); - const { token } = theme.useToken(); const formRef = useRef>(null); const vfolderSelectRef = useRef(null); const [isOpenCreateFolderModal, setIsOpenCreateFolderModal] = useState(false); @@ -101,6 +98,7 @@ const AdminModelCardSettingModal: React.FC = ({ metadata { name } + ...VFolderNodeIdenticonV2Fragment } domainName projectId @@ -307,21 +305,11 @@ const AdminModelCardSettingModal: React.FC = ({ {isEditMode ? ( - VFolder Identicon + {modelCard.vfolder && ( + + )} = ({ 'use memo'; const { t } = useTranslation(); - const { token } = theme.useToken(); - const [imageMetaData] = useBackendAIImageMetaData(); const { generateFolderPath } = useFolderExplorerOpener(); const { deployInstantly, isDeploying, supportsQuickDeploy } = @@ -86,6 +82,7 @@ const ModelCardDrawer: React.FC = ({ metadata { name } + ...VFolderNodeIdenticonV2Fragment } availablePresets(orderBy: [{ field: RANK, direction: "ASC" }]) { count @@ -290,23 +287,8 @@ const ModelCardDrawer: React.FC = ({ )} > - e.preventDefault()} - style={{ - borderRadius: '0.25em', - width: '1em', - height: '1em', - borderWidth: 0.5, - borderStyle: 'solid', - borderColor: token.colorBorder, - userSelect: 'none', - }} - src={createAvatar(shapes, { - seed: modelCard.vfolder.id, - shape3: [], - })?.toDataUri()} - alt="VFolder Identicon" + {modelCard.vfolder.metadata?.name} diff --git a/react/src/pages/AdminModelCardListPage.tsx b/react/src/pages/AdminModelCardListPage.tsx index 255100e5f8..12c7df7bd6 100644 --- a/react/src/pages/AdminModelCardListPage.tsx +++ b/react/src/pages/AdminModelCardListPage.tsx @@ -10,13 +10,16 @@ import type { ModelCardV2OrderBy, } from '../__generated__/AdminModelCardListPageQuery.graphql'; import AdminModelCardSettingModal from '../components/AdminModelCardSettingModal'; +import { useFolderExplorerOpener } from '../components/FolderExplorerOpener'; import StorageHostFilterInput from '../components/StorageHostFilterInput'; +import VFolderNodeIdenticonV2 from '../components/VFolderNodeIdenticonV2'; import { convertToOrderBy, handleRowSelectionChange } from '../helper'; import { useBAIPaginationOptionStateOnSearchParam } from '../hooks/reactPaginationQueryOptions'; +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 { App, Checkbox, Tooltip, Typography, theme } from 'antd'; import { BAIButton, BAIColumnType, @@ -24,6 +27,7 @@ import { BAIFetchKeyButton, BAIFlex, BAIGraphQLPropertyFilter, + BAILink, BAINameActionCell, BAISelectionLabel, BAITable, @@ -64,7 +68,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 +86,7 @@ const AdminModelCardListPage: React.FC = () => { ); const [deletingModelCard, setDeletingModelCard] = useState(null); + const [alsoDeleteFolder, setAlsoDeleteFolder] = useState(false); const [isBulkDeleteOpen, setIsBulkDeleteOpen] = useState(false); const { baiPaginationOption, @@ -132,6 +140,14 @@ const AdminModelCardListPage: React.FC = () => { node { id name + vfolderId + vfolder { + id + metadata { + name + } + ...VFolderNodeIdenticonV2Fragment + } domainName projectId accessLevel @@ -166,8 +182,11 @@ const AdminModelCardListPage: React.FC = () => { const [commitDeleteModelCard] = useMutation(graphql` - mutation AdminModelCardListPageDeleteMutation($id: UUID!) { - adminDeleteModelCardV2(id: $id) { + mutation AdminModelCardListPageDeleteMutation( + $id: UUID! + $options: DeleteModelCardV2Options + ) { + adminDeleteModelCardV2(id: $id, options: $options) { id } } @@ -180,6 +199,10 @@ const AdminModelCardListPage: React.FC = () => { ) { adminBulkDeleteModelCardsV2(input: $input) { successes + failed { + cardId + message + } } } `); @@ -440,11 +463,44 @@ const AdminModelCardListPage: React.FC = () => { description={t('adminModelCard.ConfirmDelete', { name: deletingModelCard?.name, })} + requireConfirmInput + extraContent={ + + setAlsoDeleteFolder(e.target.checked)} + > + {t('adminModelCard.AlsoDeleteModelFolder')} + {deletingModelCard?.vfolder && ( + + {'('} + + e.stopPropagation()} + > + {deletingModelCard.vfolder.metadata.name} + + {')'} + + )} + + + } onOk={() => { if (deletingModelCard) { return new Promise((resolve, reject) => { commitDeleteModelCard({ - variables: { id: toLocalId(deletingModelCard.id) }, + variables: { + id: toLocalId(deletingModelCard.id), + options: { deleteAssociatedVfolder: alsoDeleteFolder }, + }, onCompleted: (_data, errors) => { if (errors && errors.length > 0) { logger.error(errors[0]); @@ -454,8 +510,36 @@ const AdminModelCardListPage: React.FC = () => { reject(); return; } - message.success(t('adminModelCard.ModelCardDeleted')); + + if (alsoDeleteFolder) { + const vfolderId = deletingModelCard.vfolderId; + const folderName = deletingModelCard.vfolder?.metadata.name; + const folderTrashSearch = new URLSearchParams({ + statusCategory: 'deleted', + filter: folderName + ? `name == "${folderName}"` + : `id == "${vfolderId}"`, + }).toString(); + 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(); }, @@ -468,7 +552,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",