diff --git a/react/src/components/BAIGeneralNotificationItem.tsx b/react/src/components/BAIGeneralNotificationItem.tsx index ce88565821..e19cf8da35 100644 --- a/react/src/components/BAIGeneralNotificationItem.tsx +++ b/react/src/components/BAIGeneralNotificationItem.tsx @@ -70,7 +70,9 @@ const BAIGeneralNotificationItem: React.FC<{ - + {_.isString(notification.description) ? _.truncate(notification.description, { length: 300, @@ -78,8 +80,9 @@ const BAIGeneralNotificationItem: React.FC<{ : notification.description} {notification.to ? ( - + { onClickAction && onClickAction(e, notification); }} @@ -91,15 +94,16 @@ const BAIGeneralNotificationItem: React.FC<{ ) : null} {notification?.onCancel ? ( - + ) : null} {notification.extraDescription && !notification?.onCancel ? ( - + { // onClickAction && onClickAction(e, notification); setShowExtraDescription(!showExtraDescription); diff --git a/react/src/components/BAINodeNotificationItem.tsx b/react/src/components/BAINodeNotificationItem.tsx index e4904ec161..03dc188976 100644 --- a/react/src/components/BAINodeNotificationItem.tsx +++ b/react/src/components/BAINodeNotificationItem.tsx @@ -4,11 +4,17 @@ */ import { NotificationState } from '../hooks/useBAINotification'; import BAIComputeSessionNodeNotificationItem from './BAIComputeSessionNodeNotificationItem'; +import BAIVFolderNotificationItem from './BAIVFolderNotificationItem'; import BAIVirtualFolderNodeNotificationItem from './BAIVirtualFolderNodeNotificationItem'; import React from 'react'; import { graphql, useRefetchableFragment } from 'react-relay'; import { BAINodeNotificationItemFragment$key } from 'src/__generated__/BAINodeNotificationItemFragment.graphql'; +// `... on VFolder` is the V2 (Strawberry GraphQL, FR-2573) branch and the +// preferred path for new VFolder list/mutation flows. +// `... on VirtualFolderNode` is the legacy V1 branch and is **deprecated** — +// kept here only so callers that still hold V1 fragments keep rendering. It +// will be removed once all VFolder callers migrate to V2 `VFolder`. const nodeFragmentOperation = graphql` fragment BAINodeNotificationItemFragment on Node @refetchable(queryName: "BAINodeNotificationItemRefetchQuery") { @@ -20,6 +26,10 @@ const nodeFragmentOperation = graphql` ...BAIComputeSessionNodeNotificationItemFragment @alias(as: "sessionFrgmt") } + ... on VFolder { + __typename + ...BAIVFolderNotificationItemFragment @alias(as: "vfolderFrgmt") + } ... on VirtualFolderNode { __typename status @@ -45,7 +55,18 @@ const BAINodeNotificationItem: React.FC<{ primaryAppOption={notification.extraData} /> ); + } else if (node?.__typename === 'VFolder') { + return ( + + ); } else if (node?.__typename === 'VirtualFolderNode') { + // @deprecated Renders the legacy V1 VFolder notification. Will be removed + // once all V1 callers are gone — see the matching note on the V2 branch + // above. return ( = ({ + notification, + vfolderFrgmt, + showDate, +}) => { + 'use memo'; + + const navigate = useNavigate(); + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { closeNotification } = useSetBAINotification(); + const [showExtraDescription, { toggle: toggleShowExtraDescription }] = + useToggle(false); + + const node = useFragment( + graphql` + fragment BAIVFolderNotificationItemFragment on VFolder { + id + metadata { + name + } + } + `, + vfolderFrgmt, + ); + + if (!node) return null; + + const localId = toLocalId(node.id); + const folderName = node.metadata?.name; + + return ( + + {t('general.Folder')}:  + { + navigate( + `/data${localId ? `?${new URLSearchParams({ folder: localId }).toString()}` : ''}`, + ); + closeNotification(notification.key); + }} + > + {folderName} + + + } + description={ + + + + {_.isString(notification.description) ? ( + + {_.truncate(notification.description, { length: 300 })} + + ) : ( + notification.description + )} + + {notification.extraDescription && !notification?.onCancel ? ( + + { + toggleShowExtraDescription(); + }} + > + {showExtraDescription + ? t('notification.SeeSummary') + : t('notification.SeeDetail')} + + + ) : null} + + + {notification.extraDescription && showExtraDescription ? ( + + {_.isString(notification.extraDescription) ? ( + + {notification.extraDescription} + + ) : ( + notification.extraDescription + )} + + ) : null} + + {notification.backgroundTask && ( + + )} + + + } + footer={showDate ? dayjs(notification.created).format('lll') : undefined} + /> + ); +}; + +export default BAIVFolderNotificationItem; diff --git a/react/src/components/BAIVirtualFolderNodeNotificationItem.tsx b/react/src/components/BAIVirtualFolderNodeNotificationItem.tsx index c50b011c40..6aeec28c2a 100644 --- a/react/src/components/BAIVirtualFolderNodeNotificationItem.tsx +++ b/react/src/components/BAIVirtualFolderNodeNotificationItem.tsx @@ -23,6 +23,12 @@ interface BAIVirtualFolderNodeNotificationItemProps { showDate?: boolean; } +/** + * @deprecated Renders V1 `VirtualFolderNode` notifications. The V2 counterpart + * `BAIVFolderNotificationItem` (operating on `VFolder implements Node` from + * the Strawberry GraphQL API, FR-2573) is the preferred path going forward. + * This component will be removed once all V1 callers migrate. + */ const BAIVirtualFolderNodeNotificationItem: React.FC< BAIVirtualFolderNodeNotificationItemProps > = ({ notification, virtualFolderNodeFrgmt, showDate }) => { @@ -79,7 +85,7 @@ const BAIVirtualFolderNodeNotificationItem: React.FC< justify="between" > {_.isString(notification.description) ? ( - + {_.truncate(notification.description, { length: 300 })} ) : ( @@ -87,8 +93,9 @@ const BAIVirtualFolderNodeNotificationItem: React.FC< )} {notification.extraDescription && !notification?.onCancel ? ( - + { toggleShowExtraDescription(); }} diff --git a/react/src/components/VFolderNodesV2.tsx b/react/src/components/VFolderNodesV2.tsx index fb0516f9e1..54c4cb17d5 100644 --- a/react/src/components/VFolderNodesV2.tsx +++ b/react/src/components/VFolderNodesV2.tsx @@ -11,6 +11,7 @@ import { VFolderNodesV2RestoreMutation } from '../__generated__/VFolderNodesV2Re import { useSuspendedBackendaiClient } from '../hooks'; import { useCurrentUserInfo } from '../hooks/backendai'; import { useSuspenseTanQuery, useTanQuery } from '../hooks/reactQueryAlias'; +import { useSetBAINotification } from '../hooks/useBAINotification'; import { isDeletedCategory } from '../pages/VFolderNodeListPage'; import DeleteForeverVFolderModalV2 from './DeleteForeverVFolderModalV2'; import { useFolderExplorerOpener } from './FolderExplorerOpener'; @@ -28,6 +29,7 @@ import { filterOutNullAndUndefined, BAIAlertIconWithTooltip, BAIEndpointsIcon, + BAILink, BAIRestoreIcon, BAIShareAltIcon, BAITrashBinIcon, @@ -48,6 +50,7 @@ import * as _ from 'lodash-es'; import React, { Suspense, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useFragment, useMutation } from 'react-relay'; +import { useNavigate } from 'react-router-dom'; export const statusTagColor = { // V2 UPPERCASE enum values (VFolderOperationStatus) @@ -130,7 +133,7 @@ const VFolderNameCell: React.FC = ({ const isPipelineFolder = vfolder?.metadata?.usageMode === 'DATA'; const isModelFolder = vfolder?.metadata?.usageMode === 'MODEL'; - const isDeleted = isDeletedCategory(vfolder?.status); + const isDeleted = isDeletedCategory(vfolder?.vfolderStatus); const vfolderId = toLocalId(vfolder.id ?? ''); @@ -186,7 +189,8 @@ const VFolderNameCell: React.FC = ({ key: 'restore', title: t('data.folders.Restore'), icon: , - disabled: vfolder?.status !== 'DELETE_PENDING' || isPipelineFolder, + disabled: + vfolder?.vfolderStatus !== 'DELETE_PENDING' || isPipelineFolder, disabledReason: isPipelineFolder ? t('data.folders.CannotRestorePipelineFolder') : undefined, @@ -200,7 +204,7 @@ const VFolderNameCell: React.FC = ({ title: t('data.folders.Delete'), icon: , type: 'danger' as const, - disabled: vfolder?.status !== 'DELETE_PENDING', + disabled: vfolder?.vfolderStatus !== 'DELETE_PENDING', onClick: onDeleteForever, } : null, @@ -416,6 +420,8 @@ const VFolderNodesV2: React.FC = ({ const [currentUser] = useCurrentUserInfo(); const [inviteFolderId, setInviteFolderId] = useState(null); const { getErrorMessage } = useErrorMessageResolver(); + const navigate = useNavigate(); + const { upsertNotification } = useSetBAINotification(); // Row-level hard-delete reuses the same modal as the bulk toolbar // (typed-input confirmation is required for irreversible deletion). Soft @@ -432,11 +438,16 @@ const VFolderNodesV2: React.FC = ({ >(null); const [isHostQuotaModalOpen, setIsHostQuotaModalOpen] = useState(false); + // `vfolderStatus: status` aliases the V2 `VFolder.status` + // (`VFolderOperationStatus!`) so it doesn't collide with V1 + // `VirtualFolderNode.status` (`String`) and `ComputeSessionNode.status` + // (`String`) — both reachable from here via `BAINodeNotificationItemFragment`. + // The Status column key + V2 OrderField sort value stay `status`. const vfolders = useFragment( graphql` fragment VFolderNodesV2Fragment on VFolder @relay(plural: true) { id @required(action: NONE) - status + vfolderStatus: status host unmanagedPath metadata { @@ -470,6 +481,7 @@ const VFolderNodesV2: React.FC = ({ ...VFolderNodeIdenticonV2Fragment ...SharedFolderPermissionInfoModalV2Fragment ...DeleteForeverVFolderModalV2Fragment + ...BAINodeNotificationItemFragment @alias(as: "notificationFrgmt") } `, vfoldersFrgmt, @@ -497,45 +509,43 @@ const VFolderNodesV2: React.FC = ({ `, ); - // TODO(FR-XXXX): Re-enable rich notification (folder header link + clickable - // session IDs via `upsertNotification`) once `BAINodeNotificationItemFragment` - // gains a `... on VFolder` branch. For now the V2 `VFolder` type is not - // recognized by the notification dispatcher, so we fall back to a plain - // error toast. Kept the parsing/link-rendering code below commented for - // easy restoration. - const handleDeleteError = (_vfolder: VFolderNodeInList, error: Error) => { - message.error(getErrorMessage(error)); - // const matchString = error?.message.match(/sessions\(ids: (\[.*?\])\)/)?.[1]; - // const occupiedSession = JSON.parse(matchString?.replace(/'/g, '"') || '[]'); - // upsertNotification({ - // open: true, - // key: `vfolder-error-${_vfolder?.id}`, - // node: _vfolder, - // description: getErrorMessage(error).replace(/\(ids[\s\S]*$/, ''), - // extraDescription: !_.isEmpty(occupiedSession) ? ( - // - // - // {t('data.folders.MountedSessions')} - // - // {_.map(occupiedSession, (sessionId) => ( - // { - // navigate({ - // pathname: '/session', - // search: new URLSearchParams({ - // sessionDetail: sessionId, - // }).toString(), - // }); - // }} - // > - // {sessionId} - // - // ))} - // - // ) : null, - // }); + // V2 rich-notification path: now that `BAINodeNotificationItemFragment` + // gained a `... on VFolder` branch (FR-2573 follow-up), we can render the + // same folder-link + clickable session-IDs UX the V1 `VFolderNodes` flow + // uses. Falls back gracefully to a plain description if the error message + // doesn't carry an occupied-sessions list. + const handleDeleteError = (vfolder: VFolderNodeInList, error: Error) => { + const matchString = error?.message.match(/sessions\(ids: (\[.*?\])\)/)?.[1]; + const occupiedSession = JSON.parse(matchString?.replace(/'/g, '"') || '[]'); + upsertNotification({ + open: true, + key: `vfolder-error-${vfolder?.id}`, + node: vfolder?.notificationFrgmt ?? null, + description: getErrorMessage(error).replace(/\(ids[\s\S]*$/, ''), + extraDescription: !_.isEmpty(occupiedSession) ? ( + + + {t('data.folders.MountedSessions')} + + {_.map(occupiedSession, (sessionId) => ( + { + navigate({ + pathname: '/session', + search: new URLSearchParams({ + sessionDetail: sessionId, + }).toString(), + }); + }} + > + {sessionId} + + ))} + + ) : null, + }); }; return ( @@ -589,14 +599,12 @@ const VFolderNodesV2: React.FC = ({ const folderId = vfolder?.id; if (!folderId) return; const handleError = (error: Error) => { - // TODO(FR-XXXX): Swap back to `upsertNotification` once the - // V2 VFolder branch lands in BAINodeNotificationItem. - message.error(getErrorMessage(error)); - // upsertNotification({ - // key: `vfolder-error-${folderId}`, - // description: getErrorMessage(error), - // open: true, - // }); + upsertNotification({ + key: `vfolder-error-${folderId}`, + node: vfolder?.notificationFrgmt ?? null, + description: getErrorMessage(error), + open: true, + }); }; commitRestoreMutation({ variables: { vfolderId: toLocalId(folderId) }, @@ -629,7 +637,7 @@ const VFolderNodesV2: React.FC = ({ { key: 'status', title: t('data.folders.Status'), - dataIndex: 'status', + dataIndex: 'vfolderStatus', render: (status: string) => { return ( = ({ edges @required(action: THROW) { node @required(action: THROW) { id @required(action: THROW) - status + vfolderStatus: status ...VFolderNodesV2Fragment ...DeleteVFolderModalV2Fragment ...DeleteForeverVFolderModalV2Fragment @@ -442,8 +442,8 @@ const ProjectAdminDataContent: React.FC = ({ getCheckboxProps(record: VFolderNodeInList) { return { disabled: - isDeletedCategory(record.status) && - record.status !== 'DELETE_PENDING', + isDeletedCategory(record.vfolderStatus) && + record.vfolderStatus !== 'DELETE_PENDING', }; }, onChange: (selectedRowKeys) => {