Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f561be4

Browse files
author
zhuoyu.chen
committedMar 20, 2025
Merge remote-tracking branch 'origin/master'
2 parents 628da6e + 382177d commit f561be4

File tree

20 files changed

+831
-497
lines changed

20 files changed

+831
-497
lines changed
 

‎desktop/core/src/desktop/js/apps/admin/Configuration/Configuration.scss

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@
2121
background-color: $fluidx-gray-100;
2222
padding: 24px;
2323

24-
.config__section-header {
24+
.config__section-dropdown-label {
2525
color: $fluidx-gray-700;
2626
}
2727

28+
.config__section-header {
29+
margin-top: 16px;
30+
}
31+
2832
.config__main-item {
2933
padding: 16px 0 8px 16px;
3034
border-bottom: solid 1px $fluidx-gray-300;

‎desktop/core/src/desktop/js/apps/admin/Configuration/ConfigurationTab.test.tsx

+83-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import React from 'react';
1818
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
19+
import userEvent from '@testing-library/user-event';
1920
import '@testing-library/jest-dom';
2021
import Configuration from './ConfigurationTab';
2122
import { ConfigurationKey } from './ConfigurationKey';
@@ -31,7 +32,10 @@ beforeEach(() => {
3132
jest.clearAllMocks();
3233
ApiHelper.fetchHueConfigAsync = jest.fn(() =>
3334
Promise.resolve({
34-
apps: [{ name: 'desktop', has_ui: true, display_name: 'Desktop' }],
35+
apps: [
36+
{ name: 'desktop', has_ui: true, display_name: 'Desktop' },
37+
{ name: 'test', has_ui: true, display_name: 'test' }
38+
],
3539
config: [
3640
{
3741
help: 'Main configuration section',
@@ -51,6 +55,19 @@ beforeEach(() => {
5155
value: 'Another value'
5256
}
5357
]
58+
},
59+
{
60+
help: '',
61+
key: 'test',
62+
is_anonymous: false,
63+
values: [
64+
{
65+
help: 'Example config help text2',
66+
key: 'test.config2',
67+
is_anonymous: false,
68+
value: 'Hello World'
69+
}
70+
]
5471
}
5572
],
5673
conf_dir: '/conf/directory'
@@ -63,7 +80,7 @@ describe('Configuration Component', () => {
6380
jest.clearAllMocks();
6481
});
6582

66-
test('Renders Configuration component with fetched data', async () => {
83+
test('Renders Configuration component with fetched data for default desktop section', async () => {
6784
render(<Configuration />);
6885

6986
await waitFor(() => {
@@ -75,6 +92,70 @@ describe('Configuration Component', () => {
7592
});
7693
});
7794

95+
test('Renders Configuration component with fetched data for all sections', async () => {
96+
render(<Configuration />);
97+
98+
await waitFor(() => {
99+
expect(screen.getByText(/Sections/i)).toBeInTheDocument();
100+
expect(screen.getByText(/Desktop/i)).toBeInTheDocument();
101+
expect(screen.getByText(/example\.config/i)).toBeInTheDocument();
102+
expect(screen.getByText(/Example value/i)).toBeInTheDocument();
103+
expect(screen.queryAllByText(/test/i)).toHaveLength(0);
104+
});
105+
106+
const user = userEvent.setup();
107+
108+
// Open dropdown
109+
const select = screen.getByRole('combobox');
110+
await user.click(select);
111+
112+
// Wait for and select "ALL" option
113+
const allOption = await screen.findByTitle('ALL');
114+
await user.click(allOption);
115+
116+
// Verify the updated content
117+
await waitFor(() => {
118+
expect(screen.getAllByText(/test/i)).toHaveLength(3);
119+
expect(screen.getByText(/test\.config2/i)).toBeInTheDocument();
120+
expect(screen.getByText(/Hello World/i)).toBeInTheDocument();
121+
});
122+
});
123+
124+
test('Renders Configuration component mathcing search', async () => {
125+
render(<Configuration />);
126+
127+
await waitFor(() => {
128+
expect(screen.getByText(/Sections/i)).toBeInTheDocument();
129+
expect(screen.getByText(/Desktop/i)).toBeInTheDocument();
130+
expect(screen.getByText(/example\.config/i)).toBeInTheDocument();
131+
expect(screen.getByText(/Example value/i)).toBeInTheDocument();
132+
expect(screen.queryAllByText(/test/i)).toHaveLength(0);
133+
});
134+
135+
const user = userEvent.setup();
136+
137+
// Open dropdown
138+
const select = screen.getByRole('combobox');
139+
await user.click(select);
140+
141+
// Wait for and select "ALL" option
142+
const allOption = await screen.findByTitle('ALL');
143+
await user.click(allOption);
144+
145+
const filterinput = screen.getByPlaceholderText('Filter...');
146+
await user.click(filterinput);
147+
await user.type(filterinput, 'config2');
148+
149+
// Verify the updated content
150+
await waitFor(() => {
151+
expect(screen.getAllByText(/test/i)).toHaveLength(3);
152+
expect(screen.getByText(/test\.config2/i)).toBeInTheDocument();
153+
expect(screen.getByText(/Hello World/i)).toBeInTheDocument();
154+
155+
expect(screen.queryByText(/example\.config/i)).not.toBeInTheDocument();
156+
});
157+
});
158+
78159
test('Filters configuration based on input text', async () => {
79160
render(<Configuration />);
80161

‎desktop/core/src/desktop/js/apps/admin/Configuration/ConfigurationTab.tsx

+64-27
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,27 @@ interface HueConfig {
5151
conf_dir: string;
5252
}
5353

54+
interface VisualSection {
55+
header: string;
56+
content: Array<AdminConfigValue>;
57+
}
58+
5459
const Configuration: React.FC = (): JSX.Element => {
5560
const { t } = i18nReact.useTranslation();
5661
const [hueConfig, setHueConfig] = useState<HueConfig>();
5762
const [loading, setLoading] = useState(true);
5863
const [error, setError] = useState<string>();
59-
const [selectedApp, setSelectedApp] = useState<string>('desktop');
64+
const [selectedSection, setSelectedSection] = useState<string>('desktop');
6065
const [filter, setFilter] = useState<string>('');
6166

67+
const ALL_SECTIONS_OPTION = t('ALL');
68+
6269
useEffect(() => {
6370
ApiHelper.fetchHueConfigAsync()
6471
.then(data => {
6572
setHueConfig(data);
6673
if (data.apps.find(app => app.name === 'desktop')) {
67-
setSelectedApp('desktop');
74+
setSelectedSection('desktop');
6875
}
6976
})
7077
.catch(error => {
@@ -98,50 +105,80 @@ const Configuration: React.FC = (): JSX.Element => {
98105
return undefined;
99106
};
100107

101-
const selectedConfig = useMemo(() => {
102-
const filterSelectedApp = hueConfig?.config?.find(config => config.key === selectedApp);
108+
const visualSections = useMemo(() => {
109+
const showAllSections = selectedSection === ALL_SECTIONS_OPTION;
110+
const selectedFullConfigs = !hueConfig?.config
111+
? []
112+
: showAllSections
113+
? hueConfig.config
114+
: hueConfig.config.filter(config => config.key === selectedSection);
115+
116+
return selectedFullConfigs
117+
.map(selectedSection => ({
118+
header: selectedSection.key,
119+
content: selectedSection.values
120+
.map(config => filterConfig(config, filter.toLowerCase()))
121+
.filter(Boolean) as Config[]
122+
}))
123+
.filter(headerContentObj => !!headerContentObj.content) as Array<VisualSection>;
124+
}, [hueConfig, filter, selectedSection]);
125+
126+
const renderVisualSection = (visualSection: VisualSection) => {
127+
const showingMultipleSections = selectedSection === ALL_SECTIONS_OPTION;
128+
const content = visualSection.content;
129+
return content.length > 0 ? (
130+
<>
131+
{showingMultipleSections && (
132+
<h4 className="config__section-header">{visualSection.header}</h4>
133+
)}
134+
{content.map((record, index) => (
135+
<div key={index} className="config__main-item">
136+
<ConfigurationKey record={record} />
137+
<ConfigurationValue record={record} />
138+
</div>
139+
))}
140+
</>
141+
) : (
142+
!showingMultipleSections && <i>{t('Empty configuration section')}</i>
143+
);
144+
};
103145

104-
return filterSelectedApp?.values
105-
.map(config => filterConfig(config, filter.toLowerCase()))
106-
.filter(Boolean) as Config[];
107-
}, [hueConfig, filter, selectedApp]);
146+
const optionsIncludingAll = [
147+
ALL_SECTIONS_OPTION,
148+
...(hueConfig?.apps.map(app => app.name) || [])
149+
];
108150

109151
return (
110152
<div className="config-component">
111153
<Spin spinning={loading}>
112154
{error && (
113155
<Alert
114156
message={`Error: ${error}`}
115-
description="Error in displaying the Configuration!"
157+
description={t('Error in displaying the Configuration!')}
116158
type="error"
117159
/>
118160
)}
119161

120162
{!error && (
121163
<>
122-
<div className="config__section-header">Sections</div>
164+
<div className="config__section-dropdown-label">{t('Sections')}</div>
123165
<AdminHeader
124-
options={hueConfig?.apps.map(app => app.name) || []}
125-
selectedValue={selectedApp}
126-
onSelectChange={setSelectedApp}
166+
options={optionsIncludingAll}
167+
selectedValue={selectedSection}
168+
onSelectChange={setSelectedSection}
127169
filterValue={filter}
128170
onFilterChange={setFilter}
129-
placeholder={`Filter in ${selectedApp}...`}
171+
placeholder={
172+
selectedSection === ALL_SECTIONS_OPTION
173+
? t('Filter...')
174+
: `${t('Filter in')} ${selectedSection}...`
175+
}
130176
configAddress={hueConfig?.conf_dir}
131177
/>
132-
{selectedApp &&
133-
selectedConfig &&
134-
(selectedConfig.length > 0 ? (
135-
<>
136-
{selectedConfig.map((record, index) => (
137-
<div key={index} className="config__main-item">
138-
<ConfigurationKey record={record} />
139-
<ConfigurationValue record={record} />
140-
</div>
141-
))}
142-
</>
143-
) : (
144-
<i>{t('Empty configuration section')}</i>
178+
{selectedSection &&
179+
visualSections?.length &&
180+
visualSections.map(visualSection => (
181+
<div key={visualSection.header}>{renderVisualSection(visualSection)}</div>
145182
))}
146183
</>
147184
)}

‎desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx

+4-8
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,10 @@ import formatBytes from '../../../utils/formatBytes';
3636
import './StorageDirectoryPage.scss';
3737
import { formatTimestamp } from '../../../utils/dateTimeUtils';
3838
import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
39-
import {
40-
DEFAULT_PAGE_SIZE,
41-
DEFAULT_POLLING_TIME,
42-
FileUploadStatus
43-
} from '../../../utils/constants/storageBrowser';
39+
import { DEFAULT_PAGE_SIZE, DEFAULT_POLLING_TIME } from '../../../utils/constants/storageBrowser';
4440
import DragAndDrop from '../../../reactComponents/DragAndDrop/DragAndDrop';
4541
import UUID from '../../../utils/string/UUID';
46-
import { UploadItem } from '../../../utils/hooks/useFileUpload/util';
42+
import { RegularFile, FileStatus } from '../../../utils/hooks/useFileUpload/types';
4743
import FileUploadQueue from '../../../reactComponents/FileUploadQueue/FileUploadQueue';
4844
import { useWindowSize } from '../../../utils/hooks/useWindowSize/useWindowSize';
4945
import LoadingErrorWrapper from '../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
@@ -74,7 +70,7 @@ const StorageDirectoryPage = ({
7470
}: StorageDirectoryPageProps): JSX.Element => {
7571
const [loadingFiles, setLoadingFiles] = useState<boolean>(false);
7672
const [selectedFiles, setSelectedFiles] = useState<StorageDirectoryTableData[]>([]);
77-
const [filesToUpload, setFilesToUpload] = useState<UploadItem[]>([]);
73+
const [filesToUpload, setFilesToUpload] = useState<RegularFile[]>([]);
7874
const [polling, setPolling] = useState<boolean>(false);
7975

8076
const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
@@ -152,7 +148,7 @@ const StorageDirectoryPage = ({
152148
file,
153149
filePath: fileStats.path,
154150
uuid: UUID(),
155-
status: FileUploadStatus.Pending
151+
status: FileStatus.Pending
156152
};
157153
});
158154
setPolling(true);

‎desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.scss

+1-32
Original file line numberDiff line numberDiff line change
@@ -44,37 +44,6 @@
4444
overflow: auto;
4545
padding: 16px;
4646
gap: 16px;
47-
48-
&__row {
49-
display: flex;
50-
align-items: center;
51-
padding: 8px;
52-
font-size: 14px;
53-
gap: 8px;
54-
border: solid 1px vars.$fluidx-gray-300;
55-
56-
&__status {
57-
display: flex;
58-
width: 20px;
59-
}
60-
61-
&__name {
62-
display: flex;
63-
flex: 1;
64-
text-overflow: ellipsis;
65-
}
66-
67-
&__size {
68-
display: flex;
69-
text-align: right;
70-
color: vars.$fluidx-gray-700;
71-
}
72-
73-
&__close {
74-
display: flex;
75-
}
76-
}
7747
}
78-
7948
}
80-
}
49+
}

‎desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.test.tsx

+5-31
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,22 @@
1515
// limitations under the License.
1616

1717
import React from 'react';
18-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
18+
import { render, screen, fireEvent } from '@testing-library/react';
1919
import '@testing-library/jest-dom';
2020
import FileUploadQueue from './FileUploadQueue';
21-
import { FileUploadStatus } from '../../utils/constants/storageBrowser';
22-
import { UploadItem } from '../../utils/hooks/useFileUpload/util';
21+
import { FileStatus, RegularFile } from '../../utils/hooks/useFileUpload/types';
2322

24-
const mockFilesQueue: UploadItem[] = [
23+
const mockFilesQueue: RegularFile[] = [
2524
{
2625
uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx1',
2726
filePath: '/path/to/file1.txt',
28-
status: FileUploadStatus.Pending,
27+
status: FileStatus.Pending,
2928
file: new File([], 'file1.txt')
3029
},
3130
{
3231
uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx2',
3332
filePath: '/path/to/file2.txt',
34-
status: FileUploadStatus.Pending,
33+
status: FileStatus.Pending,
3534
file: new File([], 'file2.txt')
3635
}
3736
];
@@ -55,19 +54,6 @@ describe('FileUploadQueue', () => {
5554
expect(getByText('file2.txt')).toBeInTheDocument();
5655
});
5756

58-
it('should call onCancel when cancel button is clicked', async () => {
59-
const { getAllByTestId } = render(
60-
<FileUploadQueue filesQueue={mockFilesQueue} onClose={() => {}} onComplete={() => {}} />
61-
);
62-
63-
const cancelButton = getAllByTestId('upload-queue__list__row__close-icon')[0];
64-
fireEvent.click(cancelButton);
65-
66-
await waitFor(() => {
67-
expect(mockOnCancel).toHaveBeenCalled();
68-
});
69-
});
70-
7157
it('should toggle the visibility of the queue when the header is clicked', () => {
7258
const { getByTestId } = render(
7359
<FileUploadQueue filesQueue={mockFilesQueue} onClose={() => {}} onComplete={() => {}} />
@@ -85,16 +71,4 @@ describe('FileUploadQueue', () => {
8571
expect(screen.getByText('file1.txt')).toBeInTheDocument();
8672
expect(screen.getByText('file2.txt')).toBeInTheDocument();
8773
});
88-
89-
it('should render cancel button for files in Pending state', () => {
90-
const { getAllByTestId } = render(
91-
<FileUploadQueue filesQueue={mockFilesQueue} onClose={() => {}} onComplete={() => {}} />
92-
);
93-
94-
const cancelButtons = getAllByTestId('upload-queue__list__row__close-icon');
95-
expect(cancelButtons).toHaveLength(mockFilesQueue.length);
96-
97-
expect(cancelButtons[0]).toBeInTheDocument();
98-
expect(cancelButtons[1]).toBeInTheDocument();
99-
});
10074
});

‎desktop/core/src/desktop/js/reactComponents/FileUploadQueue/FileUploadQueue.tsx

+20-58
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,29 @@
1515
// limitations under the License.
1616

1717
import React, { useState } from 'react';
18-
import './FileUploadQueue.scss';
19-
import { Tooltip } from 'antd';
2018
import CloseIcon from '../../components/icons/CloseIcon';
2119
import { i18nReact } from '../../utils/i18nReact';
22-
import formatBytes from '../../utils/formatBytes';
23-
import StatusPendingIcon from '@cloudera/cuix-core/icons/react/StatusPendingIcon';
24-
import StatusInProgressIcon from '@cloudera/cuix-core/icons/react/StatusInProgressIcon';
25-
import StatusSuccessIcon from '@cloudera/cuix-core/icons/react/StatusSuccessIcon';
26-
import StatusStoppedIcon from '@cloudera/cuix-core/icons/react/StatusStoppedIcon';
27-
import StatusErrorIcon from '@cloudera/cuix-core/icons/react/StatusErrorIcon';
28-
import { UploadItem } from '../../utils/hooks/useFileUpload/util';
20+
import { RegularFile, FileStatus } from '../../utils/hooks/useFileUpload/types';
2921
import useFileUpload from '../../utils/hooks/useFileUpload/useFileUpload';
30-
import {
31-
DEFAULT_ENABLE_CHUNK_UPLOAD,
32-
FileUploadStatus
33-
} from '../../utils/constants/storageBrowser';
22+
import { DEFAULT_ENABLE_CHUNK_UPLOAD } from '../../utils/constants/storageBrowser';
3423
import { getLastKnownConfig } from '../../config/hueConfig';
24+
import FileUploadRow from './FileUploadRow/FileUploadRow';
25+
26+
import './FileUploadQueue.scss';
3527

3628
interface FileUploadQueueProps {
37-
filesQueue: UploadItem[];
29+
filesQueue: RegularFile[];
3830
onClose: () => void;
3931
onComplete: () => void;
4032
}
4133

4234
const sortOrder = [
43-
FileUploadStatus.Uploading,
44-
FileUploadStatus.Failed,
45-
FileUploadStatus.Pending,
46-
FileUploadStatus.Canceled,
47-
FileUploadStatus.Uploaded
48-
].reduce((acc: Record<string, number>, status: FileUploadStatus, index: number) => {
35+
FileStatus.Uploading,
36+
FileStatus.Failed,
37+
FileStatus.Pending,
38+
FileStatus.Cancelled,
39+
FileStatus.Uploaded
40+
].reduce((acc: Record<string, number>, status: FileStatus, index: number) => {
4941
acc[status] = index + 1;
5042
return acc;
5143
}, {});
@@ -64,21 +56,11 @@ const FileUploadQueue: React.FC<FileUploadQueueProps> = ({ filesQueue, onClose,
6456
onComplete
6557
});
6658

67-
const uploadedCount = uploadQueue.filter(
68-
item => item.status === FileUploadStatus.Uploaded
69-
).length;
59+
const uploadedCount = uploadQueue.filter(item => item.status === FileStatus.Uploaded).length;
7060
const pendingCount = uploadQueue.filter(
71-
item => item.status === FileUploadStatus.Pending || item.status === FileUploadStatus.Uploading
61+
item => item.status === FileStatus.Pending || item.status === FileStatus.Uploading
7262
).length;
7363

74-
const statusIcon = {
75-
[FileUploadStatus.Pending]: <StatusPendingIcon />,
76-
[FileUploadStatus.Uploading]: <StatusInProgressIcon />,
77-
[FileUploadStatus.Uploaded]: <StatusSuccessIcon />,
78-
[FileUploadStatus.Canceled]: <StatusStoppedIcon />,
79-
[FileUploadStatus.Failed]: <StatusErrorIcon />
80-
};
81-
8264
return (
8365
<div className="upload-queue cuix antd">
8466
<div
@@ -99,32 +81,12 @@ const FileUploadQueue: React.FC<FileUploadQueueProps> = ({ filesQueue, onClose,
9981
<div className="upload-queue__list">
10082
{uploadQueue
10183
.sort((a, b) => sortOrder[a.status] - sortOrder[b.status])
102-
.map((row: UploadItem) => (
103-
<div key={`${row.filePath}__${row.file.name}`} className="upload-queue__list__row">
104-
<Tooltip
105-
title={row.status}
106-
mouseEnterDelay={1.5}
107-
className="upload-queue__list__row__status"
108-
>
109-
{statusIcon[row.status]}
110-
</Tooltip>
111-
<div className="upload-queue__list__row__name">{row.file.name}</div>
112-
<div className="upload-queue__list__row__size">{formatBytes(row.file.size)}</div>
113-
{row.status === FileUploadStatus.Pending && (
114-
<Tooltip
115-
title={t('Cancel')}
116-
mouseEnterDelay={1.5}
117-
className="upload-queue__list__row__close"
118-
>
119-
<CloseIcon
120-
data-testid="upload-queue__list__row__close-icon"
121-
onClick={() => onCancel(row)}
122-
height={16}
123-
width={16}
124-
/>
125-
</Tooltip>
126-
)}
127-
</div>
84+
.map((row: RegularFile) => (
85+
<FileUploadRow
86+
key={`${row.filePath}__${row.file.name}`}
87+
data={row}
88+
onCancel={() => onCancel(row)}
89+
/>
12890
))}
12991
</div>
13092
)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Licensed to Cloudera, Inc. under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. Cloudera, Inc. licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
@use 'variables' as vars;
18+
19+
.antd.cuix {
20+
.hue-upload-queue-row {
21+
display: flex;
22+
flex-direction: column;
23+
border: solid 1px vars.$fluidx-gray-300;
24+
25+
&__container {
26+
display: flex;
27+
align-items: center;
28+
font-size: 14px;
29+
padding: 8px;
30+
gap: 8px;
31+
}
32+
33+
&__status {
34+
display: flex;
35+
width: 20px;
36+
}
37+
38+
&__name {
39+
display: flex;
40+
flex: 1;
41+
text-overflow: ellipsis;
42+
}
43+
44+
&__size {
45+
display: flex;
46+
text-align: right;
47+
color: vars.$fluidx-gray-700;
48+
}
49+
50+
&__close-icon {
51+
cursor: pointer;
52+
height: 16px;
53+
width: 16px;
54+
}
55+
56+
&__progressbar {
57+
height: 2px;
58+
background-color: vars.$fluidx-blue-500;
59+
}
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Licensed to Cloudera, Inc. under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. Cloudera, Inc. licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
import React from 'react';
18+
import { render, fireEvent, waitFor } from '@testing-library/react';
19+
import '@testing-library/jest-dom';
20+
import { RegularFile, FileStatus } from '../../../utils/hooks/useFileUpload/types';
21+
import FileUploadRow from './FileUploadRow';
22+
23+
const mockUploadRow: RegularFile = {
24+
uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx1',
25+
filePath: '/path/to/file1.txt',
26+
status: FileStatus.Pending,
27+
file: new File(['mock test file'], 'file1.txt'),
28+
progress: 0
29+
};
30+
const mockOnCancel = jest.fn();
31+
32+
describe('FileUploadRow', () => {
33+
it('should render the row with name', () => {
34+
const { getByText } = render(<FileUploadRow data={mockUploadRow} onCancel={mockOnCancel} />);
35+
36+
expect(getByText('file1.txt')).toBeInTheDocument();
37+
expect(getByText('14 Bytes')).toBeInTheDocument();
38+
});
39+
40+
it('should call onCancel when cancel button is clicked', async () => {
41+
const { getByTestId } = render(<FileUploadRow data={mockUploadRow} onCancel={mockOnCancel} />);
42+
43+
const cancelButton = getByTestId('hue-upload-queue-row__close-icon');
44+
fireEvent.click(cancelButton);
45+
46+
await waitFor(() => {
47+
expect(mockOnCancel).toHaveBeenCalled();
48+
});
49+
});
50+
51+
it('should hide cancel button for files is not in Pending state', () => {
52+
const mockData = { ...mockUploadRow, status: FileStatus.Failed };
53+
const { queryByTestId } = render(<FileUploadRow data={mockData} onCancel={mockOnCancel} />);
54+
55+
const cancelButtons = queryByTestId('hue-upload-queue-row__close-icon');
56+
expect(cancelButtons).not.toBeInTheDocument();
57+
});
58+
59+
it('should show progress bar when file is in uploading state', () => {
60+
const mockData = { ...mockUploadRow, status: FileStatus.Uploading, progress: 10 };
61+
const { getByRole } = render(<FileUploadRow data={mockData} onCancel={mockOnCancel} />);
62+
const progressBar = getByRole('hue-upload-queue-row__progressbar');
63+
expect(progressBar).toBeInTheDocument();
64+
expect(progressBar).toHaveStyle('width: 10%');
65+
});
66+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Licensed to Cloudera, Inc. under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. Cloudera, Inc. licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
import React from 'react';
18+
import './FileUploadRow.scss';
19+
import { Tooltip } from 'antd';
20+
import CloseIcon from '../../../components/icons/CloseIcon';
21+
import formatBytes from '../../../utils/formatBytes';
22+
import StatusPendingIcon from '@cloudera/cuix-core/icons/react/StatusPendingIcon';
23+
import StatusInProgressIcon from '@cloudera/cuix-core/icons/react/StatusInProgressIcon';
24+
import StatusSuccessIcon from '@cloudera/cuix-core/icons/react/StatusSuccessIcon';
25+
import StatusStoppedIcon from '@cloudera/cuix-core/icons/react/StatusStoppedIcon';
26+
import StatusErrorIcon from '@cloudera/cuix-core/icons/react/StatusErrorIcon';
27+
import { RegularFile, FileStatus } from '../../../utils/hooks/useFileUpload/types';
28+
import { i18nReact } from '../../../utils/i18nReact';
29+
30+
interface FileUploadRowProps {
31+
data: RegularFile;
32+
onCancel: () => void;
33+
}
34+
35+
const statusIcon = {
36+
[FileStatus.Pending]: <StatusPendingIcon />,
37+
[FileStatus.Uploading]: <StatusInProgressIcon />,
38+
[FileStatus.Uploaded]: <StatusSuccessIcon />,
39+
[FileStatus.Cancelled]: <StatusStoppedIcon />,
40+
[FileStatus.Failed]: <StatusErrorIcon />
41+
};
42+
43+
const FileUploadRow: React.FC<FileUploadRowProps> = ({ data, onCancel }) => {
44+
const { t } = i18nReact.useTranslation();
45+
46+
return (
47+
<div key={`${data.filePath}__${data.file.name}`} className="hue-upload-queue-row">
48+
<div className="hue-upload-queue-row__container">
49+
<Tooltip title={data.status} mouseEnterDelay={1.5} className="hue-upload-queue-row__status">
50+
{statusIcon[data.status]}
51+
</Tooltip>
52+
<div className="hue-upload-queue-row__name">{data.file.name}</div>
53+
<div className="hue-upload-queue-row__size">{formatBytes(data.file.size)}</div>
54+
{data.status === FileStatus.Pending && (
55+
<Tooltip title={t('Cancel')} mouseEnterDelay={1.5}>
56+
<CloseIcon data-testid="hue-upload-queue-row__close-icon" onClick={onCancel} />
57+
</Tooltip>
58+
)}
59+
</div>
60+
<div
61+
className="hue-upload-queue-row__progressbar"
62+
role="hue-upload-queue-row__progressbar"
63+
style={{
64+
width: `${data.status === FileStatus.Uploading ? data.progress : 0}%`
65+
}}
66+
/>
67+
</div>
68+
);
69+
};
70+
71+
export default FileUploadRow;

‎desktop/core/src/desktop/js/utils/constants/storageBrowser.ts

-8
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,6 @@ export enum SupportedFileTypes {
3131
OTHER = 'other'
3232
}
3333

34-
export enum FileUploadStatus {
35-
Pending = 'Pending',
36-
Uploading = 'Uploading',
37-
Uploaded = 'Uploaded',
38-
Canceled = 'Canceled',
39-
Failed = 'Failed'
40-
}
41-
4234
export const SUPPORTED_FILE_EXTENSIONS: Record<string, SupportedFileTypes> = {
4335
png: SupportedFileTypes.IMAGE,
4436
jpg: SupportedFileTypes.IMAGE,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Licensed to Cloudera, Inc. under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. Cloudera, Inc. licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
export enum FileStatus {
18+
Pending = 'pending',
19+
Uploading = 'uploading',
20+
Uploaded = 'uploaded',
21+
Cancelled = 'cancelled',
22+
Failed = 'failed'
23+
}
24+
25+
// Interface for file upload as a whole file in one single request.
26+
export interface RegularFile {
27+
uuid: string;
28+
filePath: string;
29+
file: File;
30+
status: FileStatus;
31+
progress?: number;
32+
error?: Error;
33+
}
34+
35+
// Interface for file upload in chunks.
36+
// One RegularFile can be broken down into multiple ChunkedFile.
37+
// And each ChunkedFile can be uploaded independently and combined at backed server
38+
export interface ChunkedFile extends Omit<RegularFile, 'file'> {
39+
file: Blob; // storing only part of the file to avoid big file duplication
40+
fileName: string;
41+
totalSize: number;
42+
chunkNumber: number;
43+
chunkStartOffset: number;
44+
chunkEndOffset: number;
45+
totalChunks: number;
46+
}
47+
48+
export interface FileVariables extends Partial<Omit<RegularFile, 'uuid' | 'filePath' | 'file'>> {}
49+
50+
export interface FileChunkMetaData {
51+
qqtotalparts: string;
52+
qqtotalfilesize: string;
53+
qqfilename: string;
54+
inputName: string;
55+
dest: string;
56+
qquuid: string;
57+
}
58+
59+
export interface ChunkedFilesInProgress {
60+
[uuid: string]: {
61+
chunkNumber: number;
62+
progress: number;
63+
chunkSize: number;
64+
}[];
65+
}
66+
67+
export interface FileUploadApiPayload {
68+
url: string;
69+
payload: FormData;
70+
}

‎desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.ts

+108-118
Original file line numberDiff line numberDiff line change
@@ -14,181 +14,171 @@
1414
// See the License for the specific language governing permissions and
1515
// limitations under the License.
1616

17-
import { useEffect, useState } from 'react';
17+
import { useState } from 'react';
1818
import { getLastKnownConfig } from '../../../config/hueConfig';
1919
import useSaveData from '../useSaveData/useSaveData';
2020
import useQueueProcessor from '../useQueueProcessor/useQueueProcessor';
2121
import {
2222
DEFAULT_CHUNK_SIZE,
23-
DEFAULT_CONCURRENT_MAX_CONNECTIONS,
24-
FileUploadStatus
23+
DEFAULT_CONCURRENT_MAX_CONNECTIONS
2524
} from '../../constants/storageBrowser';
2625
import useLoadData from '../useLoadData/useLoadData';
2726
import { TaskServerResponse, TaskStatus } from '../../../reactComponents/TaskBrowser/TaskBrowser';
2827
import {
29-
createChunks,
3028
getChunksCompletePayload,
29+
getItemProgress,
30+
getItemsTotalProgress,
3131
getChunkItemPayload,
32-
getChunkSinglePayload,
32+
createChunks,
3333
getStatusHashMap,
34-
getTotalChunk,
35-
UploadChunkItem,
36-
UploadItem
37-
} from './util';
38-
import { get } from '../../../api/utils';
39-
import { UPLOAD_AVAILABLE_SPACE_URL } from '../../../apps/storageBrowser/api';
40-
41-
interface UseUploadQueueResponse {
42-
addFiles: (item: UploadItem[]) => void;
43-
removeFile: (item: UploadItem) => void;
34+
addChunkToInProcess,
35+
isSpaceAvailableInServer,
36+
isAllChunksOfFileUploaded
37+
} from './utils';
38+
import {
39+
RegularFile,
40+
ChunkedFile,
41+
FileVariables,
42+
FileStatus,
43+
ChunkedFilesInProgress
44+
} from './types';
45+
46+
interface UseChunkUploadResponse {
47+
addFiles: (item: RegularFile[]) => void;
48+
cancelFile: (item: RegularFile['uuid']) => void;
4449
isLoading: boolean;
4550
}
4651

4752
interface ChunkUploadOptions {
4853
concurrentProcess?: number;
49-
onStatusUpdate: (item: UploadItem, newStatus: FileUploadStatus) => void;
54+
updateFileVariables: (itemId: ChunkedFile['uuid'], variables: FileVariables) => void;
5055
onComplete: () => void;
5156
}
5257

5358
const useChunkUpload = ({
5459
concurrentProcess = DEFAULT_CONCURRENT_MAX_CONNECTIONS,
55-
onStatusUpdate,
60+
updateFileVariables,
5661
onComplete
57-
}: ChunkUploadOptions): UseUploadQueueResponse => {
62+
}: ChunkUploadOptions): UseChunkUploadResponse => {
5863
const config = getLastKnownConfig();
5964
const chunkSize = config?.storage_browser?.file_upload_chunk_size ?? DEFAULT_CHUNK_SIZE;
60-
const [processingItem, setProcessingItem] = useState<UploadItem>();
61-
const [pendingUploadItems, setPendingUploadItems] = useState<UploadItem[]>([]);
62-
const [awaitingStatusItems, setAwaitingStatusItems] = useState<UploadItem[]>([]);
63-
64-
const onError = () => {
65-
if (processingItem) {
66-
onStatusUpdate(processingItem, FileUploadStatus.Failed);
67-
setProcessingItem(undefined);
68-
}
69-
};
70-
71-
const onSuccess = (item: UploadItem) => () => {
72-
setAwaitingStatusItems(prev => [...prev, item]);
73-
setProcessingItem(undefined);
74-
};
65+
const [filesWaitingFinalStatus, setFilesWaitingFinalStatus] = useState<ChunkedFile['uuid'][]>([]);
66+
const [filesInProgress, setFilesInProgress] = useState<ChunkedFilesInProgress>({});
7567

7668
const { save } = useSaveData(undefined, {
7769
postOptions: {
7870
qsEncodeData: false,
7971
headers: { 'Content-Type': 'multipart/form-data' }
80-
},
81-
onError
72+
}
8273
});
8374

84-
const updateItemStatus = (serverResponse: TaskServerResponse[]) => {
85-
const statusMap = getStatusHashMap(serverResponse);
86-
87-
const remainingItems = awaitingStatusItems.filter(item => {
88-
const status = statusMap[item.uuid];
89-
if (status === TaskStatus.Success || status === TaskStatus.Failure) {
90-
const ItemStatus =
91-
status === TaskStatus.Success ? FileUploadStatus.Uploaded : FileUploadStatus.Failed;
92-
onStatusUpdate(item, ItemStatus);
93-
return false;
75+
const processTaskServerResponse = (response: TaskServerResponse[]) => {
76+
const statusMap = getStatusHashMap(response);
77+
setFilesWaitingFinalStatus(prev => {
78+
const remainingFiles = prev.filter(uuid => {
79+
const fileStatus = statusMap[uuid];
80+
if (fileStatus === TaskStatus.Success || fileStatus === TaskStatus.Failure) {
81+
const mappedStatus =
82+
fileStatus === TaskStatus.Success ? FileStatus.Uploaded : FileStatus.Failed;
83+
updateFileVariables(uuid, { status: mappedStatus });
84+
return false; // remove the file as final status is received
85+
}
86+
return true;
87+
});
88+
if (remainingFiles.length === 0) {
89+
onComplete();
9490
}
95-
return true;
91+
return remainingFiles;
9692
});
97-
if (remainingItems.length === 0) {
98-
onComplete();
99-
}
100-
setAwaitingStatusItems(remainingItems);
10193
};
10294

103-
const { data: tasksStatus } = useLoadData<TaskServerResponse[]>(
104-
'/desktop/api2/taskserver/get_taskserver_tasks/',
105-
{
106-
pollInterval: awaitingStatusItems.length ? 5000 : undefined,
107-
skip: !awaitingStatusItems.length,
108-
transformKeys: 'none'
109-
}
110-
);
95+
useLoadData<TaskServerResponse[]>('/desktop/api2/taskserver/get_taskserver_tasks/', {
96+
pollInterval: 5000,
97+
skip: filesWaitingFinalStatus.length === 0,
98+
onSuccess: processTaskServerResponse,
99+
transformKeys: 'none'
100+
});
111101

112-
useEffect(() => {
113-
if (tasksStatus) {
114-
updateItemStatus(tasksStatus);
115-
}
116-
}, [tasksStatus]);
117-
118-
const onChunksUploadComplete = async () => {
119-
if (processingItem) {
120-
const { url, payload } = getChunksCompletePayload(processingItem, chunkSize);
121-
return save(payload, {
122-
url,
123-
onSuccess: onSuccess(processingItem)
124-
});
125-
}
102+
const handleAllChunksUploaded = (chunk: ChunkedFile) => {
103+
const { url, payload } = getChunksCompletePayload(chunk);
104+
return save(payload, {
105+
url,
106+
onSuccess: () => setFilesWaitingFinalStatus(prev => [...prev, chunk.uuid]),
107+
onError: error => updateFileVariables(chunk.uuid, { status: FileStatus.Failed, error })
108+
});
126109
};
127110

128-
const uploadChunk = async (chunkItem: UploadChunkItem) => {
129-
const { url, payload } = getChunkItemPayload(chunkItem, chunkSize);
130-
return save(payload, { url });
111+
const onChunkUploadSuccess = (chunk: ChunkedFile) => () => {
112+
setFilesInProgress(prev => {
113+
const isAllChunksUploaded = isAllChunksOfFileUploaded(prev, chunk);
114+
if (isAllChunksUploaded) {
115+
handleAllChunksUploaded(chunk);
116+
delete prev[chunk.uuid];
117+
}
118+
119+
return prev;
120+
});
131121
};
132122

133-
const { enqueue } = useQueueProcessor<UploadChunkItem>(uploadChunk, {
134-
concurrentProcess,
135-
onSuccess: onChunksUploadComplete
136-
});
123+
const onUploadProgress = (chunk: ChunkedFile) => (chunkProgress: ProgressEvent) => {
124+
setFilesInProgress(prev => {
125+
const allChunks = prev[chunk.uuid] || [];
126+
const chunk1 = allChunks.find(c => c.chunkNumber === chunk.chunkNumber);
127+
if (!chunk1) {
128+
return prev;
129+
}
130+
chunk1.progress = getItemProgress(chunkProgress);
137131

138-
const uploadItemInChunks = (item: UploadItem) => {
139-
const chunks = createChunks(item, chunkSize);
140-
return enqueue(chunks);
132+
const totalProgress = getItemsTotalProgress(chunk, allChunks);
133+
updateFileVariables(chunk.uuid, { progress: totalProgress });
134+
return { ...prev, [chunk.uuid]: allChunks };
135+
});
141136
};
142137

143-
const uploadItemInSingleChunk = async (item: UploadItem) => {
144-
const { url, payload } = getChunkSinglePayload(item, chunkSize);
138+
const uploadChunkToServer = async (chunk: ChunkedFile) => {
139+
const { url, payload } = getChunkItemPayload(chunk);
145140
return save(payload, {
146141
url,
147-
onSuccess: onSuccess(item)
142+
onSuccess: onChunkUploadSuccess(chunk),
143+
onError: error => updateFileVariables(chunk.uuid, { status: FileStatus.Failed, error }),
144+
postOptions: { onUploadProgress: onUploadProgress(chunk) }
148145
});
149146
};
150147

151-
const checkAvailableSpace = async (fileSize: number) => {
152-
const { upload_available_space: availableSpace } = await get<{
153-
upload_available_space: number;
154-
}>(UPLOAD_AVAILABLE_SPACE_URL);
155-
return availableSpace >= fileSize;
156-
};
157-
158-
const uploadItem = async (item: UploadItem) => {
159-
const isSpaceAvailable = await checkAvailableSpace(item.file.size);
160-
if (!isSpaceAvailable) {
161-
onStatusUpdate(item, FileUploadStatus.Failed);
162-
return Promise.resolve();
148+
const processChunkedFile = async (chunk: ChunkedFile): Promise<void> => {
149+
const isFirstChunk = !filesInProgress[chunk.uuid];
150+
if (isFirstChunk) {
151+
updateFileVariables(chunk.uuid, { status: FileStatus.Uploading });
152+
const isUploadPossible = await isSpaceAvailableInServer(chunk.totalSize);
153+
if (!isUploadPossible) {
154+
const error = new Error('Upload server ran out of space. Try again later.');
155+
cancelFile(chunk.uuid);
156+
return updateFileVariables(chunk.uuid, { status: FileStatus.Failed, error });
157+
}
163158
}
159+
setFilesInProgress(prev => addChunkToInProcess(prev, chunk));
164160

165-
onStatusUpdate(item, FileUploadStatus.Uploading);
166-
const chunks = getTotalChunk(item.file.size, chunkSize);
167-
if (chunks === 1) {
168-
return uploadItemInSingleChunk(item);
169-
}
170-
return uploadItemInChunks(item);
161+
return uploadChunkToServer(chunk);
171162
};
172163

173-
const addFiles = (newItems: UploadItem[]) => {
174-
setPendingUploadItems(prev => [...prev, ...newItems]);
175-
};
164+
const { enqueue, dequeue } = useQueueProcessor<ChunkedFile>(processChunkedFile, {
165+
concurrentProcess
166+
});
176167

177-
const removeFile = (item: UploadItem) => {
178-
setPendingUploadItems(prev => prev.filter(i => i.uuid !== item.uuid));
168+
const addFiles = (newFiles: RegularFile[]) => {
169+
newFiles.forEach(file => {
170+
const chunks = createChunks(file, chunkSize);
171+
enqueue(chunks);
172+
});
179173
};
180174

181-
useEffect(() => {
182-
// Ensures one file is broken down in chunks and uploaded to the server
183-
if (!processingItem && pendingUploadItems.length) {
184-
const item = pendingUploadItems[0];
185-
setProcessingItem(item);
186-
setPendingUploadItems(prev => prev.slice(1));
187-
uploadItem(item);
188-
}
189-
}, [pendingUploadItems, processingItem]);
175+
const cancelFile = (fileUuid: ChunkedFile['uuid']) => dequeue(fileUuid, 'uuid');
190176

191-
return { addFiles, removeFile, isLoading: !!processingItem || !!pendingUploadItems.length };
177+
return {
178+
addFiles,
179+
cancelFile,
180+
isLoading: !!(filesWaitingFinalStatus.length || filesInProgress.length)
181+
};
192182
};
193183

194184
export default useChunkUpload;

‎desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.ts

+42-34
Original file line numberDiff line numberDiff line change
@@ -17,82 +17,90 @@
1717
import { useCallback, useEffect, useState } from 'react';
1818
import useRegularUpload from './useRegularUpload';
1919
import useChunkUpload from './useChunkUpload';
20-
import { getNewFileItems, UploadItem } from './util';
21-
import {
22-
DEFAULT_CONCURRENT_MAX_CONNECTIONS,
23-
FileUploadStatus
24-
} from '../../constants/storageBrowser';
20+
import { DEFAULT_CONCURRENT_MAX_CONNECTIONS } from '../../constants/storageBrowser';
2521
import { getLastKnownConfig } from '../../../config/hueConfig';
22+
import { getNewRegularFiles } from './utils';
23+
import { FileStatus, RegularFile, FileVariables } from './types';
2624

2725
interface UseUploadQueueResponse {
28-
uploadQueue: UploadItem[];
29-
onCancel: (item: UploadItem) => void;
26+
uploadQueue: RegularFile[];
27+
onCancel: (item: RegularFile) => void;
3028
isLoading: boolean;
3129
}
3230

3331
interface UploadQueueOptions {
3432
isChunkUpload?: boolean;
35-
onComplete: () => UploadItem[] | void;
33+
onComplete: () => RegularFile[] | void;
3634
}
3735

3836
const useFileUpload = (
39-
filesQueue: UploadItem[],
37+
filesQueue: RegularFile[],
4038
{ isChunkUpload = false, onComplete }: UploadQueueOptions
4139
): UseUploadQueueResponse => {
4240
const config = getLastKnownConfig();
4341
const concurrentProcess =
4442
config?.storage_browser.concurrent_max_connection ?? DEFAULT_CONCURRENT_MAX_CONNECTIONS;
4543

46-
const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
47-
48-
const onStatusUpdate = (item: UploadItem, newStatus: FileUploadStatus) =>
49-
setUploadQueue(prev =>
50-
prev.map(queueItem =>
51-
queueItem.uuid === item.uuid ? { ...queueItem, status: newStatus } : queueItem
52-
)
53-
);
54-
55-
const findQueueItem = (item: UploadItem) =>
56-
uploadQueue.filter(queueItem => queueItem.uuid === item.uuid)?.[0];
44+
const [uploadQueue, setUploadQueue] = useState<RegularFile[]>([]);
45+
46+
const updateFileVariables = (
47+
itemId: RegularFile['uuid'],
48+
{ status, error, progress }: FileVariables
49+
) => {
50+
setUploadQueue(prev => {
51+
return prev.map(queueItem => {
52+
if (queueItem.uuid === itemId) {
53+
return {
54+
...queueItem,
55+
status: status ?? queueItem.status,
56+
error: error ?? queueItem.error,
57+
progress: progress ?? queueItem.progress
58+
};
59+
}
60+
return queueItem;
61+
});
62+
});
63+
};
5764

5865
const {
5966
addFiles: addToChunkUpload,
60-
removeFile: removeFromChunkUpload,
67+
cancelFile: cancelFromChunkUpload,
6168
isLoading: isChunkLoading
6269
} = useChunkUpload({
6370
concurrentProcess,
64-
onStatusUpdate,
71+
updateFileVariables,
6572
onComplete
6673
});
6774

6875
const {
6976
addFiles: addToRegularUpload,
70-
removeFile: removeFromRegularUpload,
71-
isLoading: isNonChunkLoading
77+
cancelFile: cancelFromRegularUpload,
78+
isLoading: isRegularLoading
7279
} = useRegularUpload({
7380
concurrentProcess,
74-
onStatusUpdate,
81+
updateFileVariables,
7582
onComplete
7683
});
7784

7885
const onCancel = useCallback(
79-
(item: UploadItem) => {
80-
const queueItem = findQueueItem(item);
81-
if (queueItem.status === FileUploadStatus.Pending) {
82-
onStatusUpdate(item, FileUploadStatus.Canceled);
86+
(item: RegularFile) => {
87+
const queueItem = uploadQueue.find(q => q.uuid === item.uuid);
88+
if (queueItem?.status === FileStatus.Pending) {
89+
const error = new Error('Upload cancelled');
90+
updateFileVariables(item.uuid, { status: FileStatus.Cancelled, error });
8391

8492
if (isChunkUpload) {
85-
removeFromChunkUpload(item);
93+
cancelFromChunkUpload(item.uuid);
8694
} else {
87-
removeFromRegularUpload(item);
95+
cancelFromRegularUpload(item.uuid);
8896
}
8997
}
9098
},
91-
[isChunkUpload, onStatusUpdate, removeFromChunkUpload, removeFromRegularUpload]
99+
[isChunkUpload, updateFileVariables, cancelFromChunkUpload, cancelFromRegularUpload]
92100
);
93101

94102
useEffect(() => {
95-
const newQueueItems = getNewFileItems(filesQueue, uploadQueue);
103+
const newQueueItems = getNewRegularFiles(filesQueue, uploadQueue);
96104

97105
if (newQueueItems.length > 0) {
98106
setUploadQueue(prev => [...prev, ...newQueueItems]);
@@ -105,7 +113,7 @@ const useFileUpload = (
105113
}
106114
}, [filesQueue, uploadQueue, isChunkUpload]);
107115

108-
return { uploadQueue, onCancel, isLoading: isChunkLoading || isNonChunkLoading };
116+
return { uploadQueue, onCancel, isLoading: isChunkLoading || isRegularLoading };
109117
};
110118

111119
export default useFileUpload;

‎desktop/core/src/desktop/js/utils/hooks/useFileUpload/useRegularUpload.ts

+23-17
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,26 @@
1616

1717
import useQueueProcessor from '../useQueueProcessor/useQueueProcessor';
1818
import { UPLOAD_FILE_URL } from '../../../apps/storageBrowser/api';
19-
import {
20-
DEFAULT_CONCURRENT_MAX_CONNECTIONS,
21-
FileUploadStatus
22-
} from '../../constants/storageBrowser';
19+
import { DEFAULT_CONCURRENT_MAX_CONNECTIONS } from '../../constants/storageBrowser';
2320
import useSaveData from '../useSaveData/useSaveData';
24-
import { UploadItem } from './util';
21+
import { getItemProgress } from './utils';
22+
import { RegularFile, FileVariables, FileStatus } from './types';
2523

2624
interface UseUploadQueueResponse {
27-
addFiles: (item: UploadItem[]) => void;
28-
removeFile: (item: UploadItem) => void;
25+
addFiles: (item: RegularFile[]) => void;
26+
cancelFile: (uuid: RegularFile['uuid']) => void;
2927
isLoading: boolean;
3028
}
3129

3230
interface UploadQueueOptions {
3331
concurrentProcess?: number;
34-
onStatusUpdate: (item: UploadItem, newStatus: FileUploadStatus) => void;
32+
updateFileVariables: (item: RegularFile['uuid'], variables: FileVariables) => void;
3533
onComplete: () => void;
3634
}
3735

3836
const useRegularUpload = ({
3937
concurrentProcess = DEFAULT_CONCURRENT_MAX_CONNECTIONS,
40-
onStatusUpdate,
38+
updateFileVariables,
4139
onComplete
4240
}: UploadQueueOptions): UseUploadQueueResponse => {
4341
const { save } = useSaveData(UPLOAD_FILE_URL, {
@@ -49,33 +47,41 @@ const useRegularUpload = ({
4947
}
5048
});
5149

52-
const processUploadItem = async (item: UploadItem) => {
53-
onStatusUpdate(item, FileUploadStatus.Uploading);
50+
const processRegularFile = async (item: RegularFile) => {
51+
updateFileVariables(item.uuid, { status: FileStatus.Uploading });
5452

5553
const payload = new FormData();
5654
payload.append('file', item.file);
5755
payload.append('destination_path', item.filePath);
5856

5957
return save(payload, {
6058
onSuccess: () => {
61-
onStatusUpdate(item, FileUploadStatus.Uploaded);
59+
updateFileVariables(item.uuid, { status: FileStatus.Uploaded });
6260
},
63-
onError: () => {
64-
onStatusUpdate(item, FileUploadStatus.Failed);
61+
onError: error => {
62+
updateFileVariables(item.uuid, { status: FileStatus.Failed, error });
63+
},
64+
postOptions: {
65+
onUploadProgress: progress => {
66+
const itemProgress = getItemProgress(progress);
67+
updateFileVariables(item.uuid, { progress: itemProgress });
68+
}
6569
}
6670
});
6771
};
6872

6973
const {
7074
enqueue: addFiles,
71-
dequeue: removeFile,
75+
dequeue,
7276
isLoading
73-
} = useQueueProcessor<UploadItem>(processUploadItem, {
77+
} = useQueueProcessor<RegularFile>(processRegularFile, {
7478
concurrentProcess,
7579
onSuccess: onComplete
7680
});
7781

78-
return { addFiles, removeFile, isLoading };
82+
const cancelFile = (itemId: RegularFile['uuid']) => dequeue(itemId, 'uuid');
83+
84+
return { addFiles, cancelFile, isLoading };
7985
};
8086

8187
export default useRegularUpload;

‎desktop/core/src/desktop/js/utils/hooks/useFileUpload/util.ts

-137
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Licensed to Cloudera, Inc. under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. Cloudera, Inc. licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
import { get } from '../../../api/utils';
18+
import {
19+
CHUNK_UPLOAD_URL,
20+
CHUNK_UPLOAD_COMPLETE_URL,
21+
UPLOAD_AVAILABLE_SPACE_URL
22+
} from '../../../apps/storageBrowser/api';
23+
import { TaskServerResponse, TaskStatus } from '../../../reactComponents/TaskBrowser/TaskBrowser';
24+
import {
25+
ChunkedFile,
26+
ChunkedFilesInProgress,
27+
FileChunkMetaData,
28+
FileUploadApiPayload,
29+
RegularFile
30+
} from './types';
31+
32+
export const getNewRegularFiles = (
33+
newQueue: RegularFile[],
34+
oldQueue: RegularFile[]
35+
): RegularFile[] => {
36+
return newQueue.filter(
37+
newItem =>
38+
!oldQueue.some(
39+
oldItem => oldItem.file.name === newItem.file.name && oldItem.filePath === newItem.filePath
40+
)
41+
);
42+
};
43+
44+
export const getTotalChunk = (fileSize: number, DEFAULT_CHUNK_SIZE: number): number => {
45+
return Math.ceil(fileSize / DEFAULT_CHUNK_SIZE);
46+
};
47+
48+
export const getMetaData = (item: ChunkedFile): FileChunkMetaData => ({
49+
qqtotalparts: String(item.totalChunks),
50+
qqtotalfilesize: String(item.totalSize),
51+
qqfilename: item.fileName,
52+
inputName: 'hdfs_file',
53+
dest: item.filePath,
54+
qquuid: item.uuid
55+
});
56+
57+
export const createChunks = (item: RegularFile, chunkSize: number): ChunkedFile[] => {
58+
const totalChunks = getTotalChunk(item.file.size, chunkSize);
59+
60+
const chunks = Array.from({ length: totalChunks }, (_, i) => {
61+
const chunkStartOffset = i * chunkSize;
62+
const chunkEndOffset = Math.min(chunkStartOffset + chunkSize, item.file.size);
63+
return {
64+
...item,
65+
fileName: item.file.name,
66+
totalSize: item.file.size,
67+
file: item.file.slice(chunkStartOffset, chunkEndOffset),
68+
totalChunks,
69+
chunkNumber: i,
70+
chunkStartOffset,
71+
chunkEndOffset
72+
};
73+
});
74+
75+
return chunks;
76+
};
77+
78+
export const getStatusHashMap = (
79+
serverResponse: TaskServerResponse[]
80+
): Record<string, TaskStatus> =>
81+
serverResponse.reduce(
82+
(acc, row: TaskServerResponse) => ({
83+
...acc,
84+
[row.task_id]: row.status
85+
}),
86+
{}
87+
);
88+
89+
export const getChunkItemPayload = (chunkItem: ChunkedFile): FileUploadApiPayload => {
90+
const metaData = getMetaData(chunkItem);
91+
const chunkQueryParams = new URLSearchParams({
92+
...metaData,
93+
qqpartindex: String(chunkItem.chunkNumber),
94+
qqpartbyteoffset: String(chunkItem.chunkStartOffset),
95+
qqchunksize: String(chunkItem.chunkEndOffset - chunkItem.chunkStartOffset)
96+
}).toString();
97+
98+
const url = `${CHUNK_UPLOAD_URL}?${chunkQueryParams}`;
99+
100+
const payload = new FormData();
101+
payload.append('hdfs_file', chunkItem.file);
102+
return { url, payload };
103+
};
104+
105+
export const getChunksCompletePayload = (processingItem: ChunkedFile): FileUploadApiPayload => {
106+
const fileMetaData = getMetaData(processingItem);
107+
const payload = new FormData();
108+
Object.entries(fileMetaData).forEach(([key, value]) => {
109+
payload.append(key, value);
110+
});
111+
return { url: CHUNK_UPLOAD_COMPLETE_URL, payload };
112+
};
113+
114+
export const getItemProgress = (progress?: ProgressEvent): number => {
115+
if (!progress?.total || !progress?.loaded || progress?.total === 0) {
116+
return 0;
117+
}
118+
return Math.round((progress.loaded * 100) / progress.total);
119+
};
120+
121+
export const getItemsTotalProgress = (
122+
chunkItem?: ChunkedFile,
123+
chunks?: ChunkedFilesInProgress['uuid']
124+
): number => {
125+
if (!chunkItem || !chunks) {
126+
return 0;
127+
}
128+
return chunks.reduce((acc, chunk) => {
129+
return acc + (chunk.progress * chunk.chunkSize) / chunkItem.totalSize;
130+
}, 0);
131+
};
132+
133+
export const addChunkToInProcess = (
134+
currentInProcess: ChunkedFilesInProgress,
135+
chunkItem: ChunkedFile
136+
): ChunkedFilesInProgress => {
137+
const inProcessChunkObj = {
138+
chunkNumber: chunkItem.chunkNumber,
139+
progress: 0,
140+
chunkSize: chunkItem.file.size
141+
};
142+
if (currentInProcess[chunkItem.uuid] === undefined) {
143+
currentInProcess[chunkItem.uuid] = [inProcessChunkObj];
144+
} else {
145+
currentInProcess[chunkItem.uuid].push(inProcessChunkObj);
146+
}
147+
return currentInProcess;
148+
};
149+
150+
export const isSpaceAvailableInServer = async (fileSize: number): Promise<boolean> => {
151+
const response = await get<{
152+
upload_available_space: number;
153+
}>(UPLOAD_AVAILABLE_SPACE_URL);
154+
return !!response?.upload_available_space && response.upload_available_space >= fileSize;
155+
};
156+
157+
export const isAllChunksOfFileUploaded = (
158+
filesInProgress: ChunkedFilesInProgress,
159+
chunk: ChunkedFile
160+
): boolean => {
161+
const fileAllChunks = filesInProgress[chunk.uuid];
162+
const isTotalChunkCountMatched = fileAllChunks.length === chunk.totalChunks;
163+
const isAllChunksUploaded = filesInProgress[chunk.uuid]?.every(chunk => chunk.progress === 100);
164+
165+
return isTotalChunkCountMatched && isAllChunksUploaded;
166+
};

‎desktop/core/src/desktop/js/utils/hooks/useQueueProcessor/useQueueProcessor.test.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,32 @@ describe('useQueueProcessor', () => {
9999
expect(result.current.queue).toEqual(['item3']);
100100
});
101101

102+
it('should update the queue when an item is dequeued with itemKey', async () => {
103+
const { result } = renderHook(() =>
104+
useQueueProcessor<{ id: number; name: string }>(mockProcessItem, { concurrentProcess: 1 })
105+
);
106+
107+
act(() => {
108+
result.current.enqueue([
109+
{ id: 1, name: 'item1' },
110+
{ id: 2, name: 'item2' },
111+
{ id: 3, name: 'item3' }
112+
]);
113+
});
114+
115+
expect(result.current.queue).toEqual([
116+
{ id: 2, name: 'item2' },
117+
{ id: 3, name: 'item3' }
118+
]);
119+
expect(result.current.isLoading).toBe(true);
120+
121+
act(() => {
122+
result.current.dequeue('item2', 'name');
123+
});
124+
125+
expect(result.current.queue).toEqual([{ id: 3, name: 'item3' }]);
126+
});
127+
102128
it('should update isLoading when items are being processed', async () => {
103129
const { result } = renderHook(() =>
104130
useQueueProcessor(mockProcessItem, { concurrentProcess: 1 })

‎desktop/core/src/desktop/js/utils/hooks/useQueueProcessor/useQueueProcessor.ts

+7-16
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ import { useState, useEffect } from 'react';
1919
interface UseQueueProcessorResult<T> {
2020
queue: T[];
2121
enqueue: (newItems: T[]) => void;
22-
enqueueAsync: (newItems: T[]) => Promise<void>;
23-
dequeue: (item: T) => void;
22+
dequeue: (itemValue: T[keyof T] | T, itemKey?: keyof T) => void;
2423
isLoading: boolean;
2524
}
2625

@@ -41,22 +40,15 @@ const useQueueProcessor = <T>(
4140
setQueue(prevQueue => [...prevQueue, ...newItems]);
4241
};
4342

44-
const enqueueAsync = (newItems: T[]) => {
45-
enqueue(newItems);
46-
return new Promise<void>(resolve => {
47-
const interval = setInterval(() => {
48-
if (queue.length === 0 && processingQueue.length === 0) {
49-
clearInterval(interval);
50-
resolve();
51-
}
52-
}, 100);
43+
const dequeue = (itemValue: T[keyof T] | T, itemKey?: keyof T) => {
44+
setQueue(prev => {
45+
if (itemKey) {
46+
return prev.filter(i => i[itemKey] !== itemValue);
47+
}
48+
return prev.filter(i => i !== itemValue);
5349
});
5450
};
5551

56-
const dequeue = (item: T) => {
57-
setQueue(prev => prev.filter(i => i !== item));
58-
};
59-
6052
const processQueueItem = async (item: T) => {
6153
if (!isLoading) {
6254
setIsLoading(true);
@@ -85,7 +77,6 @@ const useQueueProcessor = <T>(
8577
queue,
8678
isLoading,
8779
enqueue,
88-
enqueueAsync,
8980
dequeue
9081
};
9182
};

‎desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.ts

+9-8
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,17 @@
1414
// See the License for the specific language governing permissions and
1515
// limitations under the License.
1616

17-
import { useCallback, useEffect, useMemo, useState } from 'react';
17+
import { useCallback, useEffect, useState } from 'react';
1818
import { ApiFetchOptions, post } from '../../../api/utils';
1919

2020
interface saveOptions<T> {
2121
url?: string;
2222
onSuccess?: (data: T) => void;
2323
onError?: (error: Error) => void;
24+
postOptions?: ApiFetchOptions<T>;
2425
}
2526

2627
export interface Options<T> extends saveOptions<T> {
27-
postOptions?: ApiFetchOptions<T>;
2828
skip?: boolean;
2929
}
3030

@@ -46,11 +46,6 @@ const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSav
4646
ignoreSuccessErrors: true
4747
};
4848

49-
const postOptions = useMemo(
50-
() => ({ ...postOptionsDefault, ...localOptions?.postOptions }),
51-
[localOptions]
52-
);
53-
5449
const saveData = useCallback(
5550
async (body: U, saveOptions?: saveOptions<T>) => {
5651
// Avoid Posting data if the skip option is true
@@ -62,6 +57,12 @@ const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSav
6257
setLoading(true);
6358
setError(undefined);
6459

60+
const postOptions = {
61+
...postOptionsDefault,
62+
...localOptions?.postOptions,
63+
...saveOptions?.postOptions
64+
};
65+
6566
try {
6667
const response = await post<T, U>(apiUrl, body, postOptions);
6768
setData(response);
@@ -83,7 +84,7 @@ const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSav
8384
setLoading(false);
8485
}
8586
},
86-
[url, localOptions, postOptions]
87+
[url, localOptions]
8788
);
8889

8990
useEffect(() => {

0 commit comments

Comments
 (0)
Please sign in to comment.