Skip to content

Commit 4ce2fa9

Browse files
claudeironAiken2
authored andcommitted
feat(FR-2619): migrate single-folder detail reads to Strawberry V2 vfolderV2 query
Replace the legacy `vfolder_node(id)` GraphQL query and `VirtualFolderNode` Relay fragments with the V2 `vfolderV2(vfolderId)` query and `VFolder` fragments across the detail / explorer paths: - VFolderLazyView, EditableVFolderName, VFolderNodeDescription, FolderExplorerHeader, FolderExplorerModal switch to `vfolderV2` + VFolder fragments with nested `metadata` / `accessControl` / `ownership` shapes. - FileBrowserButton, SFTPServerButton, useVirtualFolderNodePath switch their fragments from `on VirtualFolderNode` to `on VFolder`. - V2 `VFolderMountPermission` enum is mapped to legacy REST 'ro'/'rw' for the existing `baiClient.vfolder.update_folder` call; `RW_DELETE` implies `delete_content`, `READ_WRITE`/`RW_DELETE` imply `write_content` in the explorer permission checks. TODO(needs-backend) markers note where V2 still lacks a richer per-operation permission array.
1 parent 94c01ea commit 4ce2fa9

15 files changed

Lines changed: 1741 additions & 10 deletions

react/src/components/BAIVFolderNotificationItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import dayjs from 'dayjs';
1616
import * as _ from 'lodash-es';
1717
import { useTranslation } from 'react-i18next';
1818
import { graphql, useFragment } from 'react-relay';
19-
import { useNavigate } from 'react-router-dom';
2019
import { BAIVFolderNotificationItemFragment$key } from 'src/__generated__/BAIVFolderNotificationItemFragment.graphql';
20+
import { useWebUINavigate } from 'src/hooks';
2121
import {
2222
NotificationState,
2323
useSetBAINotification,
@@ -41,7 +41,7 @@ const BAIVFolderNotificationItem: React.FC<BAIVFolderNotificationItemProps> = ({
4141
}) => {
4242
'use memo';
4343

44-
const navigate = useNavigate();
44+
const navigate = useWebUINavigate();
4545
const { t } = useTranslation();
4646
const { token } = theme.useToken();
4747
const { closeNotification } = useSetBAINotification();
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
import { EditableVFolderNameV2Fragment$key } from '../__generated__/EditableVFolderNameV2Fragment.graphql';
6+
import { EditableVFolderNameV2RefetchQuery } from '../__generated__/EditableVFolderNameV2RefetchQuery.graphql';
7+
import { useSuspendedBackendaiClient } from '../hooks';
8+
import { useCurrentUserInfo } from '../hooks/backendai';
9+
import { useTanMutation } from '../hooks/reactQueryAlias';
10+
import { useCurrentProjectValue } from '../hooks/useCurrentProject';
11+
import { isDeletedCategory } from '../pages/VFolderNodeListPage';
12+
import { useFolderExplorerOpener } from './FolderExplorerOpener';
13+
import {
14+
theme,
15+
Form,
16+
Input,
17+
App,
18+
GetProps,
19+
Typography,
20+
InputProps,
21+
} from 'antd';
22+
import { BAILink, toLocalId, useErrorMessageResolver } from 'backend.ai-ui';
23+
import * as _ from 'lodash-es';
24+
import { CornerDownLeftIcon } from 'lucide-react';
25+
import React, { useRef, useState } from 'react';
26+
import { useTranslation } from 'react-i18next';
27+
import {
28+
graphql,
29+
fetchQuery,
30+
useFragment,
31+
useRelayEnvironment,
32+
} from 'react-relay';
33+
34+
type EditableVFolderNameV2Props = {
35+
vfolderNodeFrgmt: EditableVFolderNameV2Fragment$key;
36+
enableLink?: boolean;
37+
inputProps?: InputProps;
38+
onEditEnd?: () => void;
39+
onEditStart?: () => void;
40+
} & (
41+
| ({ component?: typeof Typography.Text } & Omit<
42+
GetProps<typeof Typography.Text>,
43+
'children'
44+
>)
45+
| ({ component: typeof Typography.Title } & Omit<
46+
GetProps<typeof Typography.Title>,
47+
'children'
48+
>)
49+
);
50+
51+
const EditableVFolderNameV2: React.FC<EditableVFolderNameV2Props> = ({
52+
component: Component = Typography.Text,
53+
vfolderNodeFrgmt,
54+
editable: editableOfProps,
55+
style,
56+
enableLink = true,
57+
onEditEnd,
58+
onEditStart,
59+
inputProps,
60+
...otherProps
61+
}) => {
62+
'use memo';
63+
const vfolderNode = useFragment(
64+
graphql`
65+
fragment EditableVFolderNameV2Fragment on VFolder {
66+
id
67+
status
68+
metadata {
69+
name
70+
}
71+
ownership {
72+
userId
73+
projectId
74+
}
75+
}
76+
`,
77+
vfolderNodeFrgmt,
78+
);
79+
const [optimisticName, setOptimisticName] = useState(
80+
vfolderNode.metadata?.name,
81+
);
82+
const [userInfo] = useCurrentUserInfo();
83+
const currentProject = useCurrentProjectValue();
84+
const baiClient = useSuspendedBackendaiClient();
85+
const renameMutation = useTanMutation({
86+
mutationFn: (input: { id: string; name: string }) => {
87+
return baiClient.vfolder.rename(input.name, toLocalId(vfolderNode?.id));
88+
},
89+
});
90+
const relayEnv = useRelayEnvironment();
91+
92+
const { t } = useTranslation();
93+
const { token } = theme.useToken();
94+
const { message } = App.useApp();
95+
const { getErrorMessage } = useErrorMessageResolver();
96+
const { generateFolderPath } = useFolderExplorerOpener();
97+
const [isEditing, setIsEditing] = useState(false);
98+
99+
const isEditingAllowed =
100+
editableOfProps &&
101+
(userInfo.uuid === vfolderNode.ownership?.userId ||
102+
currentProject?.id === vfolderNode.ownership?.projectId) &&
103+
!isDeletedCategory(vfolderNode.status);
104+
105+
const isPendingRenameMutation =
106+
renameMutation.isPending || optimisticName !== vfolderNode.metadata?.name;
107+
108+
// focus back to the text component after editing for better UX related to keyboard shortcuts
109+
const textRef = useRef<HTMLElement>(null);
110+
const focusFallback = () => {
111+
setTimeout(() => {
112+
textRef.current?.focus();
113+
}, 0);
114+
};
115+
116+
return (
117+
<>
118+
{(!isEditing || isPendingRenameMutation) && (
119+
<Component
120+
ref={textRef}
121+
tabIndex={-1}
122+
editable={
123+
isEditingAllowed && !isPendingRenameMutation
124+
? {
125+
onStart: () => {
126+
setIsEditing(true);
127+
onEditStart?.();
128+
},
129+
onEnd: () => {
130+
setIsEditing(false);
131+
onEditEnd?.();
132+
},
133+
onCancel: () => {
134+
setIsEditing(false);
135+
onEditEnd?.();
136+
},
137+
triggerType: ['icon'],
138+
...(!_.isBoolean(editableOfProps) ? editableOfProps : {}),
139+
}
140+
: false
141+
}
142+
style={{
143+
// after editing, focus this element, remove outline
144+
outline: 'none',
145+
...style,
146+
color: isPendingRenameMutation
147+
? token.colorTextTertiary
148+
: style?.color,
149+
}}
150+
title={vfolderNode.metadata?.name || undefined}
151+
{...otherProps}
152+
>
153+
{enableLink && !isEditing && (
154+
<BAILink
155+
type="hover"
156+
to={generateFolderPath(toLocalId(vfolderNode?.id))}
157+
>
158+
{isPendingRenameMutation
159+
? optimisticName
160+
: vfolderNode.metadata?.name}
161+
</BAILink>
162+
)}
163+
{!enableLink &&
164+
(isPendingRenameMutation
165+
? optimisticName
166+
: vfolderNode.metadata?.name)}
167+
</Component>
168+
)}
169+
{isEditing && !isPendingRenameMutation && (
170+
<Form
171+
onFinish={(values) => {
172+
setIsEditing(false);
173+
focusFallback();
174+
if (values.vfolderName === vfolderNode.metadata?.name) {
175+
onEditEnd?.();
176+
return;
177+
}
178+
setOptimisticName(values.vfolderName);
179+
renameMutation.mutate(
180+
{
181+
id: vfolderNode.id,
182+
name: values.vfolderName,
183+
},
184+
{
185+
onSuccess: () => {
186+
onEditEnd?.();
187+
message.success(t('data.folders.FileRenamed'));
188+
return fetchQuery<EditableVFolderNameV2RefetchQuery>(
189+
relayEnv,
190+
graphql`
191+
query EditableVFolderNameV2RefetchQuery(
192+
$vfolderId: UUID!
193+
) {
194+
vfolderV2(vfolderId: $vfolderId) {
195+
id
196+
metadata {
197+
name
198+
}
199+
}
200+
}
201+
`,
202+
{
203+
vfolderId: toLocalId(vfolderNode.id),
204+
},
205+
).toPromise();
206+
},
207+
onError: (error) => {
208+
onEditEnd?.();
209+
const errorMessage = getErrorMessage(error);
210+
if (
211+
errorMessage.includes(
212+
'One of your accessible vfolders already has the name you requested.',
213+
)
214+
) {
215+
message.error(t('data.FolderAlreadyExists'));
216+
} else {
217+
message.error(errorMessage);
218+
}
219+
setOptimisticName(vfolderNode.metadata?.name);
220+
},
221+
},
222+
);
223+
}}
224+
initialValues={{
225+
vfolderName: vfolderNode.metadata?.name,
226+
}}
227+
style={{
228+
flex: 1,
229+
}}
230+
>
231+
<Form.Item
232+
name="vfolderName"
233+
rules={[
234+
{
235+
max: 64,
236+
message: t('data.FolderNameTooLong'),
237+
type: 'string',
238+
},
239+
{
240+
required: true,
241+
message: t('data.FolderNameRequired'),
242+
},
243+
{
244+
pattern: /^[a-zA-Z0-9-_.]+$/,
245+
message: t('data.AllowsLettersNumbersAnd-_Dot'),
246+
},
247+
]}
248+
style={{
249+
margin: 0,
250+
}}
251+
>
252+
<Input
253+
size="small"
254+
suffix={
255+
<CornerDownLeftIcon
256+
style={{
257+
fontSize: '0.8em',
258+
color: token.colorTextTertiary,
259+
}}
260+
/>
261+
}
262+
autoFocus
263+
onKeyDown={(e) => {
264+
// when press escape key, cancel editing
265+
if (e.key === 'Escape') {
266+
e.stopPropagation();
267+
setIsEditing(false);
268+
focusFallback();
269+
onEditEnd?.();
270+
}
271+
}}
272+
{...inputProps}
273+
/>
274+
</Form.Item>
275+
</Form>
276+
)}
277+
</>
278+
);
279+
};
280+
281+
export default EditableVFolderNameV2;

0 commit comments

Comments
 (0)