Skip to content

Commit 2c1f0a9

Browse files
committed
feat(FR-1342): Editable name in file explorer (#4117)
resolves #4090 ([FR-1342](https://lablup.atlassian.net/browse/FR-1342)) ### Add file/folder rename feature to file explorer This PR adds the ability to rename files and folders in the file explorer. The implementation includes: 1. Created a new `EditableName` component that handles file/folder renaming with validation: - Prevents duplicate names - Handles file extension changes with confirmation - Handles folder merging with confirmation 2. Added a new `BAILink` component to the UI library that provides consistent styling for links with hover states, icons, and ellipsis tooltips 3. Enhanced permission checking to disable rename functionality when the user doesn't have write permissions 4. Updated all locale files with new translation strings for rename-related messages and errors The file explorer now allows users to click the edit icon next to file/folder names to rename them, with appropriate validation and confirmation dialogs to prevent accidental data loss. ### Expected Logic Modify File 1. If a duplicate name exists, the name will not be modified and a failure message will be printed. 2. If you change the file extension, a confirmation modal will appear and you must click the OK button on the confirmation modal to modify the name, including the extension. Modify Folder 1. If the name you are renaming already exists, a confirmation modal will appear, and clicking the OK button will move the modified folder inside it. **Checklist:** - [ ] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after [FR-1342]: https://lablup.atlassian.net/browse/FR-1342?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent c39c167 commit 2c1f0a9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+709
-298
lines changed

packages/backend.ai-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"react-resizable": "^3.0.5",
8484
"react-copy-to-clipboard": "^5.1.0",
8585
"@tanstack/react-query": "^5.69.0",
86+
"react-router-dom": "^6.30.0",
8687
"use-query-params": "^2.2.1"
8788
},
8889
"devDependencies": {

packages/backend.ai-ui/src/components/BAIFlex.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import BAIFlex from './BAIFlex';
2-
import { describe, expect, test, jest } from '@jest/globals';
2+
import { describe, test, jest } from '@jest/globals';
33
import '@testing-library/jest-dom';
44
import { render, screen } from '@testing-library/react';
5-
import React from 'react';
65

76
// Mock antd's theme hook
87
jest.mock('antd', () => ({
@@ -56,6 +55,7 @@ describe('BAIFlex', () => {
5655
</div>
5756
</BAIFlex>,
5857
);
58+
5959
expect(screen.getByTestId('firstChildComponent')).toBeInTheDocument();
6060
expect(screen.getByTestId('secondChildComponent')).toBeInTheDocument();
6161
expect(screen.getByTestId('nestedChildComponent')).toBeInTheDocument();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import BAIFlex from './BAIFlex';
2+
import { Typography } from 'antd';
3+
import { createStyles } from 'antd-style';
4+
import React from 'react';
5+
import { Link, LinkProps } from 'react-router-dom';
6+
7+
const useStyles = createStyles(({ css, token }) => ({
8+
hover: css`
9+
text-decoration: none;
10+
color: ${token.colorLink};
11+
12+
&:hover {
13+
color: ${token.colorLinkHover};
14+
text-decoration: underline;
15+
}
16+
`,
17+
disabled: css`
18+
color: ${token.colorTextDisabled};
19+
cursor: not-allowed;
20+
pointer-events: none;
21+
`,
22+
}));
23+
24+
export interface BAILinkProps extends Omit<LinkProps, 'to'> {
25+
type?: 'hover' | 'disabled' | undefined;
26+
to?: LinkProps['to'];
27+
icon?: React.ReactNode;
28+
ellipsis?: boolean | { tooltip?: string };
29+
children?: string | React.ReactNode;
30+
}
31+
const BAILink: React.FC<BAILinkProps> = ({
32+
type,
33+
to,
34+
icon,
35+
ellipsis,
36+
children,
37+
...linkProps
38+
}) => {
39+
const { styles } = useStyles();
40+
return (
41+
<BAIFlex gap="xs" style={{ display: 'inline-flex' }}>
42+
{icon && icon}
43+
{type !== 'disabled' && to ? (
44+
<Link
45+
className={type ? styles?.[type] : undefined}
46+
to={to}
47+
{...linkProps}
48+
>
49+
{children}
50+
</Link>
51+
) : (
52+
<Typography.Link
53+
className={type ? styles?.[type] : undefined}
54+
onClick={linkProps.onClick}
55+
disabled={type === 'disabled'}
56+
ellipsis={!!ellipsis}
57+
{...linkProps}
58+
>
59+
{typeof ellipsis === 'object' && ellipsis.tooltip ? (
60+
<Typography.Text
61+
className={type ? styles?.[type] : undefined}
62+
ellipsis={ellipsis}
63+
>
64+
{children}
65+
</Typography.Text>
66+
) : (
67+
children
68+
)}
69+
</Typography.Link>
70+
)}
71+
</BAIFlex>
72+
);
73+
};
74+
75+
export default BAILink;

packages/backend.ai-ui/src/components/baiClient/FileExplorer/BAIFileExplorer.tsx

Lines changed: 59 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,21 @@ import useConnectedBAIClient from '../../provider/BAIClientProvider/hooks/useCon
1111
import { VFolderFile } from '../../provider/BAIClientProvider/types';
1212
import DeleteSelectedItemsModal from './DeleteSelectedItemsModal';
1313
import DragAndDrop from './DragAndDrop';
14+
import EditableFileName from './EditableFileName';
1415
import ExplorerActionControls from './ExplorerActionControls';
1516
import FileItemControls from './FileItemControls';
1617
import { useSearchVFolderFiles } from './hooks';
17-
import { FileOutlined, FolderOutlined, HomeOutlined } from '@ant-design/icons';
18-
import {
19-
Breadcrumb,
20-
Skeleton,
21-
TableColumnsType,
22-
theme,
23-
Typography,
24-
} from 'antd';
25-
import { createStyles } from 'antd-style';
18+
import { FolderOutlined } from '@ant-design/icons';
19+
import { Breadcrumb, Skeleton, TableColumnsType, Typography } from 'antd';
2620
import { ItemType } from 'antd/es/breadcrumb/Breadcrumb';
2721
import { RcFile } from 'antd/es/upload';
2822
import dayjs from 'dayjs';
2923
import _ from 'lodash';
24+
import { HouseIcon } from 'lucide-react';
3025
import { createContext, Suspense, useEffect, useMemo, useState } from 'react';
3126
import { useTranslation } from 'react-i18next';
3227
import { graphql, useFragment } from 'react-relay';
3328

34-
const useStyles = createStyles(({ css, token }) => ({
35-
hover: css`
36-
text-decoration: none;
37-
/* color: ${token.colorLink}; */
38-
39-
&:hover {
40-
/* color: ${token.colorLinkHover}; */
41-
text-decoration: underline;
42-
}
43-
`,
44-
}));
45-
4629
export const FolderInfoContext = createContext<{
4730
targetVFolderId: string;
4831
currentPath: string;
@@ -71,8 +54,6 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
7154
style,
7255
}) => {
7356
const { t } = useTranslation();
74-
const { token } = theme.useToken();
75-
const { styles } = useStyles();
7657
const [isDragMode, setIsDragMode] = useState(false);
7758
const [selectedItems, setSelectedItems] = useState<Array<VFolderFile>>([]);
7859
const [selectedSingleItem, setSelectedSingleItem] =
@@ -103,50 +84,21 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
10384
const vFolderNode = useFragment(
10485
graphql`
10586
fragment BAIFileExplorerFragment on VirtualFolderNode {
87+
permissions
10688
...FileItemControlsFragment
89+
...ExplorerActionControlsFragment
90+
...EditableFileNameFragment
10791
}
10892
`,
10993
vfolderNodeFrgmt,
11094
);
11195

112-
useEffect(() => {
113-
const handleDragEnter = (e: DragEvent) => {
114-
e.preventDefault();
115-
setIsDragMode(true);
116-
};
117-
const handleDragLeave = (e: DragEvent) => {
118-
e.preventDefault();
119-
if (!e.relatedTarget || !document.contains(e.relatedTarget as Node)) {
120-
setIsDragMode(false);
121-
}
122-
};
123-
const handleDragOver = (e: DragEvent) => {
124-
e.preventDefault();
125-
};
126-
const handleDrop = (e: DragEvent) => {
127-
e.preventDefault();
128-
setIsDragMode(false);
129-
};
130-
131-
document.addEventListener('dragenter', handleDragEnter);
132-
document.addEventListener('dragleave', handleDragLeave);
133-
document.addEventListener('dragover', handleDragOver);
134-
document.addEventListener('drop', handleDrop);
135-
136-
return () => {
137-
document.removeEventListener('dragenter', handleDragEnter);
138-
document.removeEventListener('dragleave', handleDragLeave);
139-
document.removeEventListener('dragover', handleDragOver);
140-
document.removeEventListener('drop', handleDrop);
141-
};
142-
}, []);
143-
14496
const breadCrumbItems: Array<ItemType> = useMemo(() => {
14597
const pathParts = currentPath === '.' ? [] : currentPath.split('/');
14698

14799
const items: Array<ItemType> = [
148100
{
149-
title: <HomeOutlined />,
101+
title: <HouseIcon />,
150102
onClick: () => {
151103
navigateToPath('.');
152104
setSelectedItems([]);
@@ -201,48 +153,29 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
201153
title: t('comp:FileExplorer.Name'),
202154
dataIndex: 'name',
203155
sorter: (a, b) => localeCompare(a.name, b.name),
204-
fixed: 'left',
205-
render: (name, record) =>
206-
record?.type === 'DIRECTORY' ? (
207-
// FIXME: need to implement BAILink into backend.ai-ui and use it here
208-
<Typography.Link
209-
className={styles.hover}
210-
onClick={(e) => {
211-
e.stopPropagation();
156+
render: (name, record) => (
157+
<EditableFileName
158+
vfolderNodeFrgmt={vFolderNode}
159+
fileInfo={record}
160+
existingFiles={fetchedFilesCache}
161+
onEndEdit={() => {
162+
refetch();
163+
}}
164+
onClick={(e) => {
165+
e.stopPropagation();
166+
const targetEl = e.target as HTMLElement;
167+
if (targetEl.closest('button')) return;
168+
if (record.type === 'DIRECTORY') {
212169
navigateDown(name);
213170
setSelectedItems([]);
214-
}}
215-
style={{ display: 'block', width: 'fit-content' }} // To prevent conflicts with the click event of onRow.
216-
>
217-
<BAIFlex gap="xs">
218-
<FolderOutlined />
219-
<Typography.Text
220-
ellipsis={{
221-
tooltip: name,
222-
}}
223-
style={{ maxWidth: 200, color: token.colorLink }}
224-
>
225-
{name}
226-
</Typography.Text>
227-
</BAIFlex>
228-
</Typography.Link>
229-
) : (
230-
<BAIFlex gap="xs">
231-
<FileOutlined />
232-
<Typography.Text
233-
ellipsis={{
234-
tooltip: name,
235-
}}
236-
style={{ maxWidth: 200 }}
237-
>
238-
{name}
239-
</Typography.Text>
240-
</BAIFlex>
241-
),
171+
}
172+
}}
173+
/>
174+
),
242175
},
243176
{
244177
title: t('comp:FileExplorer.Controls'),
245-
fixed: 'left',
178+
width: 80,
246179
render: (_, record) => {
247180
return (
248181
<Suspense fallback={<Skeleton.Button size="small" active />}>
@@ -284,6 +217,38 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
284217
},
285218
]);
286219

220+
useEffect(() => {
221+
const handleDragEnter = (e: DragEvent) => {
222+
e.preventDefault();
223+
setIsDragMode(true);
224+
};
225+
const handleDragLeave = (e: DragEvent) => {
226+
e.preventDefault();
227+
if (!e.relatedTarget || !document.contains(e.relatedTarget as Node)) {
228+
setIsDragMode(false);
229+
}
230+
};
231+
const handleDragOver = (e: DragEvent) => {
232+
e.preventDefault();
233+
};
234+
const handleDrop = (e: DragEvent) => {
235+
e.preventDefault();
236+
setIsDragMode(false);
237+
};
238+
239+
document.addEventListener('dragenter', handleDragEnter);
240+
document.addEventListener('dragleave', handleDragLeave);
241+
document.addEventListener('dragover', handleDragOver);
242+
document.addEventListener('drop', handleDrop);
243+
244+
return () => {
245+
document.removeEventListener('dragenter', handleDragEnter);
246+
document.removeEventListener('dragleave', handleDragLeave);
247+
document.removeEventListener('dragover', handleDragOver);
248+
document.removeEventListener('drop', handleDrop);
249+
};
250+
}, []);
251+
287252
return (
288253
<FolderInfoContext.Provider value={{ targetVFolderId, currentPath }}>
289254
{isDragMode && (
@@ -309,6 +274,7 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
309274
)}
310275
/>
311276
<ExplorerActionControls
277+
vFolderNodeFrgmt={vFolderNode}
312278
selectedFiles={selectedItems}
313279
onUpload={(files, currentPath) => onUpload(files, currentPath)}
314280
onRequestClose={(

0 commit comments

Comments
 (0)