diff --git a/react/src/components/AdminModelCardSettingModal.tsx b/react/src/components/AdminModelCardSettingModal.tsx index 6d43000381..764418e8ac 100644 --- a/react/src/components/AdminModelCardSettingModal.tsx +++ b/react/src/components/AdminModelCardSettingModal.tsx @@ -9,7 +9,7 @@ import { useCurrentProjectValue, useSetCurrentProject, } from '../hooks/useCurrentProject'; -import FolderCreateModal from './FolderCreateModal'; +import FolderCreateModalV2 from './FolderCreateModalV2'; import FolderLink from './FolderLink'; import { shapes } from '@dicebear/collection'; import { createAvatar } from '@dicebear/core'; @@ -538,21 +538,10 @@ const AdminModelCardSettingModal: React.FC = ({ - { setIsOpenCreateFolderModal(false); if (result) { diff --git a/react/src/components/FolderCreateModalV2.tsx b/react/src/components/FolderCreateModalV2.tsx new file mode 100644 index 0000000000..9a33df1bb7 --- /dev/null +++ b/react/src/components/FolderCreateModalV2.tsx @@ -0,0 +1,638 @@ +/** + @license + Copyright (c) 2015-2026 Lablup Inc. All rights reserved. + */ +import { FolderCreateModalV2Mutation } from '../__generated__/FolderCreateModalV2Mutation.graphql'; +import { useSuspendedBackendaiClient } from '../hooks'; +import { useCurrentUserRole } from '../hooks/backendai'; +import { useTanQuery } from '../hooks/reactQueryAlias'; +import { useSetBAINotification } from '../hooks/useBAINotification'; +import { useCurrentProjectValue } from '../hooks/useCurrentProject'; +import QuestionIconWithTooltip from './QuestionIconWithTooltip'; +import StorageSelect from './StorageSelect'; +import { + App, + Divider, + Form, + Input, + Radio, + Skeleton, + Switch, + theme, + Tooltip, +} from 'antd'; +import { createStyles } from 'antd-style'; +import { FormInstance } from 'antd/lib'; +import { + BAIButton, + BAIFlex, + BAIModal, + BAIModalProps, + toLocalId, + useBAILogger, + useErrorMessageResolver, + useMutationWithPromise, +} from 'backend.ai-ui'; +import * as _ from 'lodash-es'; +import { TriangleAlertIcon } from 'lucide-react'; +import { Suspense, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql } from 'react-relay'; + +// Constants +const MODEL_STORE_PROJECT_NAME = 'model-store'; +const FOLDER_NAME_MAX_LENGTH = 64; +const MODAL_WIDTH = 650; + +const useStyles = createStyles(({ css }) => ({ + modal: css` + .ant-modal-body { + padding-top: 24px !important; + padding-bottom: 0 !important; + } + `, + form: css` + .ant-form-item-label { + display: flex; + align-items: start; + padding-left: var(--token-paddingSM); + } + .ant-form-item-control { + padding-right: var(--token-paddingSM); + } + .ant-form-item-label > label::after { + display: none !important; + } + `, +})); + +interface FolderCreateFormItemsType { + name: string; + host: string | undefined; + group: string | undefined; + usage_mode: 'general' | 'model' | 'automount'; + type: 'user' | 'project'; + permission: 'rw' | 'ro'; + cloneable: boolean; +} + +export interface FolderCreateModalProps extends BAIModalProps { + onRequestClose: (response?: FolderCreationResponse) => void; + initialValidate?: boolean; + initialValues?: Partial; + /** + * Whether to allow creating project-type folders. Should be enabled only on + * admin pages — leaving it off ensures project folder creation UI is not + * exposed when the same modal is used from user-facing pages, even if the + * current user has admin privileges. + */ + allowCreateProjectFolder?: boolean; + /** + * When set, locks the form to a specific folder shape by pre-selecting and + * disabling the structural fields (usage_mode, type, permission). The user + * can still edit name, host, and cloneable. Used when a caller needs a folder + * with a fixed shape — e.g. 'model_project' for the Model Store admin flow. + */ + folderType?: 'model_project'; +} + +export interface FolderCreationResponse { + id: string; + name: string; + quota_scope_id: string; + host: string; + usage_mode: 'general' | 'model' | 'automount'; + permission: 'rw' | 'ro'; + creator: string; + ownership_type: 'user' | 'project'; + user: string; + group: string | null; + cloneable: boolean; + status: string; +} + +const FolderCreateModalV2: React.FC = ({ + onRequestClose, + initialValidate = false, + initialValues: initialValuesFromProps = {}, + allowCreateProjectFolder = false, + folderType, + ...modalProps +}) => { + 'use memo'; + const { t } = useTranslation(); + const { styles } = useStyles(); + const { token } = theme.useToken(); + const { message } = App.useApp(); + const { logger } = useBAILogger(); + const { getErrorMessage } = useErrorMessageResolver(); + + const formRef = useRef(null); + const baiClient = useSuspendedBackendaiClient(); + const userRole = useCurrentUserRole(); + const currentProject = useCurrentProjectValue(); + + const { upsertNotification } = useSetBAINotification(); + + const INITIAL_FORM_VALUES: FolderCreateFormItemsType = { + name: '', + host: undefined, + group: currentProject.id || undefined, + usage_mode: 'general', + type: 'user', + permission: 'rw', + cloneable: false, + }; + + const isFolderTypeLocked = folderType !== undefined; + + // When folderType locks the form to a specific shape, these preset values + // override any user-passed initialValues for the locked fields. + const folderTypePreset: Partial | undefined = + folderType === 'model_project' + ? { + usage_mode: 'model', + type: 'project', + permission: 'ro', + cloneable: true, + } + : undefined; + + const mergedInitialValues: FolderCreateFormItemsType = { + ...INITIAL_FORM_VALUES, + ...initialValuesFromProps, + ...folderTypePreset, + }; + + // No V2 equivalent for allowed types — keep using existing REST API approach + const { data: allowedTypes, isFetching: isFetchingAllowedTypes } = + useTanQuery({ + queryKey: ['allowedTypes', modalProps.open], + enabled: modalProps.open, + queryFn: () => + modalProps.open ? baiClient.vfolder.list_allowed_types() : undefined, + }); + + const commitCreateMutation = + useMutationWithPromise(graphql` + mutation FolderCreateModalV2Mutation($input: CreateVFolderV2Input!) { + createVfolderV2(input: $input) { + vfolder { + id + status + host + metadata { + name + usageMode + quotaScopeId + cloneable + } + accessControl { + permission + ownershipType + } + ownership { + userId + projectId + creatorEmail + } + } + } + } + `); + + const handleOk = async () => { + try { + const values = await formRef.current?.validateFields(); + if (!values) return; + + const isAutomount = values.usage_mode === 'automount'; + const folderName = + isAutomount && !_.startsWith(values.name, '.') + ? `.${values.name}` + : values.name; + + const input = { + name: folderName, + host: values.host ?? null, + usageMode: isAutomount ? 'general' : values.usage_mode, + permission: values.permission, + cloneable: !!values.cloneable, + // If type is 'project', send the current project ID; otherwise null for user-owned + projectId: values.type === 'project' ? values.group : null, + }; + + const data = await commitCreateMutation({ input }); + const vfolder = data.createVfolderV2.vfolder; + const rawId = toLocalId(vfolder.id); + + // Map V2 response to FolderCreationResponse for backward compatibility + const result: FolderCreationResponse = { + id: rawId, + name: vfolder.metadata.name, + quota_scope_id: vfolder.metadata.quotaScopeId ?? '', + host: vfolder.host, + usage_mode: isAutomount + ? 'automount' + : vfolder.metadata.usageMode === 'MODEL' + ? 'model' + : 'general', + permission: + vfolder.accessControl.permission === 'READ_ONLY' ? 'ro' : 'rw', + creator: vfolder.ownership.creatorEmail ?? '', + ownership_type: + vfolder.accessControl.ownershipType === 'USER' ? 'user' : 'project', + user: vfolder.ownership.userId ?? '', + group: vfolder.ownership.projectId ?? null, + cloneable: vfolder.metadata.cloneable, + status: vfolder.status, + }; + + upsertNotification({ + key: `folder-create-success-${result.id}`, + icon: 'folder', + message: `${result.name}: ${t('data.folders.FolderCreated')}`, + toText: t('data.folders.OpenAFolder'), + to: { + search: new URLSearchParams({ + folder: result.id, + }).toString(), + }, + open: true, + }); + document.dispatchEvent(new CustomEvent('backend-ai-folder-list-changed')); + document.dispatchEvent(new CustomEvent('backend-ai-folder-created')); + onRequestClose(result); + } catch (error) { + if (Array.isArray(error)) { + for (const err of error) { + message.error(err.message); + } + } else if (error instanceof Error) { + message.error(getErrorMessage(error)); + } + logger.error(error); + } + }; + + return ( + + { + formRef.current?.resetFields(); + }} + > + {t('button.Reset')} + + + { + onRequestClose(); + }} + > + {t('button.Cancel')} + + { + await handleOk(); + }} + > + {t('data.Create')} + + + + } + width={MODAL_WIDTH} + onCancel={() => { + onRequestClose(); + }} + destroyOnHidden + {...modalProps} + afterOpenChange={(open) => { + if (open && initialValidate) { + formRef.current?.validateFields(); + } + }} + > +
+ + { + // Only validate name field if it has a value to prevent excessive validation + if (formRef.current?.getFieldValue('name')) { + formRef.current.validateFields(['name']); + } + if (formRef.current?.getFieldValue('type')) { + formRef.current.validateFields(['type']); + } + }} + > + + {t('data.General')} + + {(baiClient._config.enableModelFolders || + folderType === 'model_project') && ( + + {t('data.Models')} + + )} + + + {t('data.AutoMount')} + + + + + + + + ({ + validator(_rule, value) { + if (_.isEmpty(value)) { + return Promise.reject( + new Error(t('data.FolderNameRequired')), + ); + } + if ( + getFieldValue('usage_mode') === 'automount' && + !_.startsWith(value, '.') + ) { + return Promise.reject( + new Error(t('data.AutomountFolderNameMustStartWithDot')), + ); + } + if ( + getFieldValue('usage_mode') !== 'automount' && + _.startsWith(value, '.') + ) { + return Promise.reject( + new Error(t('data.DotPrefixReservedForAutomount')), + ); + } + return Promise.resolve(); + }, + }), + ]} + > + + + + + + }> + { + formRef.current?.setFieldValue('host', value); + }} + showUsageStatus + autoSelectType="usage" + showSearch + /> + + + + + {({ getFieldValue }) => { + const usageMode = getFieldValue('usage_mode'); + const shouldDisableProject = + (usageMode === 'model' && + currentProject?.name !== MODEL_STORE_PROJECT_NAME) || + usageMode === 'automount'; + + return ( + ({ + validator(__, value) { + const currentUsageMode = getFieldValue('usage_mode'); + const isInvalidModelProjectFolder = + value === 'project' && + currentUsageMode === 'model' && + currentProject?.name !== MODEL_STORE_PROJECT_NAME; + const isInvalidAutoMountFolder = + value === 'project' && currentUsageMode === 'automount'; + + if (isInvalidModelProjectFolder) { + return Promise.reject( + new Error( + t( + 'data.folders.CreateModelFolderOnlyInExclusiveProject', + ), + ), + ); + } else if (isInvalidAutoMountFolder) { + return Promise.reject( + new Error( + t( + 'data.folders.ChangeTheVFolderTypeToCreateAutoMountFolder', + ), + ), + ); + } else { + return Promise.resolve(); + } + }, + }), + { + warningOnly: true, + validator: async (__, value) => { + if (!shouldDisableProject && value === 'project') { + return Promise.reject( + new Error( + t('data.folders.ProjectFolderCreationHelp', { + projectName: currentProject?.name, + }), + ), + ); + } + return Promise.resolve(); + }, + }, + ]} + > + + {/* Visibility rules: + * - 'user' option: requires the 'user' type registered in ETCD. + * - 'project' option: requires either an admin context that + * opts in via allowCreateProjectFolder, or a folderType that + * inherently requires project ownership (e.g. 'model_project'). + * Both paths additionally require admin role (defense-in-depth + * against route-level permission misconfiguration) and the + * 'group' type registered in ETCD. + * When isFolderTypeLocked, the entire group is disabled and + * tooltips/warning icons are suppressed for a clean read-only + * appearance. + */} + {_.includes(allowedTypes, 'user') && ( + + {t('data.User')} + + )} + {(allowCreateProjectFolder || + folderType === 'model_project') && + (userRole === 'admin' || userRole === 'superadmin') && + _.includes(allowedTypes, 'group') && ( + + + + {t('data.Project')} + {!isFolderTypeLocked && shouldDisableProject && ( + + )} + + + + )} + + + ); + }} + + + +
+ ); +}; + +export default FolderCreateModalV2; diff --git a/react/src/components/ImportArtifactRevisionToFolderModal.tsx b/react/src/components/ImportArtifactRevisionToFolderModal.tsx index 1835d94725..ec46d707bf 100644 --- a/react/src/components/ImportArtifactRevisionToFolderModal.tsx +++ b/react/src/components/ImportArtifactRevisionToFolderModal.tsx @@ -2,7 +2,7 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ -import FolderCreateModal from './FolderCreateModal'; +import FolderCreateModalV2 from './FolderCreateModalV2'; import { useToggle } from 'ahooks'; import { Alert, @@ -317,21 +317,10 @@ const ImportArtifactRevisionToFolderModal = ({ - { toggleIsOpenCreateModal(); if (result) { diff --git a/react/src/components/VFolderMountFormItem.tsx b/react/src/components/VFolderMountFormItem.tsx index 488f953ec7..46f1c397b5 100644 --- a/react/src/components/VFolderMountFormItem.tsx +++ b/react/src/components/VFolderMountFormItem.tsx @@ -3,7 +3,7 @@ Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ import { VFolderMountFormItemAutoMountQuery } from '../__generated__/VFolderMountFormItemAutoMountQuery.graphql'; -import FolderCreateModal from './FolderCreateModal'; +import FolderCreateModalV2 from './FolderCreateModalV2'; import { useFolderExplorerOpener } from './FolderExplorerOpener'; import { vFolderAliasNameRegExp, @@ -302,13 +302,15 @@ const VFolderMountFormItem: React.FC = ({ )} - { setIsFolderCreateModalOpen(false); diff --git a/react/src/components/VFolderSelect.tsx b/react/src/components/VFolderSelect.tsx index a3821b5e27..4868972e75 100644 --- a/react/src/components/VFolderSelect.tsx +++ b/react/src/components/VFolderSelect.tsx @@ -6,7 +6,7 @@ import { useBaiSignedRequestWithPromise } from '../helper'; import { useSuspenseTanQuery } from '../hooks/reactQueryAlias'; import useControllableState_deprecated from '../hooks/useControllableState'; import { useCurrentProjectValue } from '../hooks/useCurrentProject'; -import FolderCreateModal from './FolderCreateModal'; +import FolderCreateModalV2 from './FolderCreateModalV2'; import { useFolderExplorerOpener } from './FolderExplorerOpener'; import { ReloadOutlined } from '@ant-design/icons'; import { Button, Select, type SelectProps, Space, Tooltip } from 'antd'; @@ -186,7 +186,7 @@ const VFolderSelect: React.FC = ({ ) : null} - { diff --git a/react/src/components/VFolderTable.tsx b/react/src/components/VFolderTable.tsx index cdc37710c6..86c8585473 100644 --- a/react/src/components/VFolderTable.tsx +++ b/react/src/components/VFolderTable.tsx @@ -9,7 +9,7 @@ import { useKeyPairLazyLoadQuery } from '../hooks/hooksUsingRelay'; import { useSuspenseTanQuery } from '../hooks/reactQueryAlias'; import useControllableState_deprecated from '../hooks/useControllableState'; import { useCurrentProjectValue } from '../hooks/useCurrentProject'; -import FolderCreateModal from './FolderCreateModal'; +import FolderCreateModalV2 from './FolderCreateModalV2'; import { useFolderExplorerOpener } from './FolderExplorerOpener'; import TextHighlighter from './TextHighlighter'; import VFolderPermissionTag from './VFolderPermissionTag'; @@ -707,7 +707,7 @@ const VFolderTable: React.FC = ({ ) : null} - { setIsOpenCreateModal(false); diff --git a/react/src/pages/AdminVFolderNodeListPage.tsx b/react/src/pages/AdminVFolderNodeListPage.tsx index 84ca0aa03b..72c3704b6b 100644 --- a/react/src/pages/AdminVFolderNodeListPage.tsx +++ b/react/src/pages/AdminVFolderNodeListPage.tsx @@ -10,14 +10,16 @@ import type { import BAIRadioGroup from '../components/BAIRadioGroup'; import BAITabs from '../components/BAITabs'; import DeleteVFolderModal from '../components/DeleteVFolderModal'; +import FolderCreateModalV2 from '../components/FolderCreateModalV2'; import RestoreVFolderModal from '../components/RestoreVFolderModal'; import VFolderNodes, { VFolderNodeInList } from '../components/VFolderNodes'; import { handleRowSelectionChange } from '../helper'; import { useSuspendedBackendaiClient } from '../hooks'; import { isDeletedCategory } from './VFolderNodeListPage'; import { useToggle } from 'ahooks'; -import { Badge, Button, theme, Tooltip } from 'antd'; +import { Badge, theme, Tooltip } from 'antd'; import { + BAIButton, BAICard, BAIFetchKeyButton, BAIFlex, @@ -31,6 +33,7 @@ import { useUpdatableState, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; +import { PlusIcon } from 'lucide-react'; import React, { useDeferredValue, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useLazyLoadQuery } from 'react-relay'; @@ -77,6 +80,7 @@ const AdminVFolderNodeListPage: React.FC = (props) => { const [isOpenDeleteModal, { toggle: toggleDeleteModal }] = useToggle(false); const [isOpenRestoreModal, { toggle: toggleRestoreModal }] = useToggle(false); + const [isOpenCreateModal, { toggle: toggleCreateModal }] = useToggle(false); const { baiPaginationOption, @@ -415,7 +419,7 @@ const AdminVFolderNodeListPage: React.FC = (props) => { onClearSelection={() => setSelectedFolderList([])} /> -