Skip to content
This repository was archived by the owner on Jun 19, 2025. It is now read-only.

Commit 6f5565e

Browse files
authored
feat(upload): add upload button functionality (#176)
* feat(upload): add upload button functionality * fix(rename): rename upload file * feat(dnd): super simple drag and drop been added * fix(test): hook test * fix(refactor): move UploadZone into a separated file * fix(refactor): remove older version dataTransfer support
1 parent 2ca6cce commit 6f5565e

File tree

6 files changed

+282
-74
lines changed

6 files changed

+282
-74
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { styled } from "@mui/material";
2+
3+
export const VisuallyHiddenInput = styled('input')({
4+
clip: 'rect(0 0 0 0)',
5+
clipPath: 'inset(50%)',
6+
height: 1,
7+
overflow: 'hidden',
8+
position: 'absolute',
9+
bottom: 0,
10+
left: 0,
11+
whiteSpace: 'nowrap',
12+
width: 1,
13+
});

aqueductcore/frontend/src/components/organisms/Attachments/Explorer/index.tsx

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Table from "@mui/material/Table";
1212

1313
import { FileSelectContextType, SortOrder, selectedFileType } from "types/componentTypes";
1414
import { getComparator, stableSort } from "helper/functions";
15+
import UploadZone from "components/templates/UploadFileZone";
1516
import { ExperimentFileType } from "types/globalTypes";
1617
import { dateFormatter } from "helper/formatters";
1718

@@ -70,10 +71,12 @@ function Explorer({
7071
files,
7172
handleSelectFile,
7273
selectedItem,
74+
handleExperimentFileUpload
7375
}: {
7476
files: ExperimentFileType[];
7577
handleSelectFile: FileSelectContextType['setSelectedFile'];
7678
selectedItem: selectedFileType;
79+
handleExperimentFileUpload?: (file: File) => void
7780
}) {
7881
const [order, setOrder] = useState<SortOrder>('desc');
7982
const [orderBy, setOrderBy] = useState<keyof ExperimentFileType>('modifiedAt');
@@ -114,46 +117,48 @@ function Explorer({
114117

115118
return (
116119
<ExplorerBox>
117-
<TableContainer
118-
sx={{ boxShadow: "none", borderRadius: "8px 8px 0 0", maxHeight: 540, height: 540 }}
119-
>
120-
<Table stickyHeader>
121-
<ExplorerTableHead>
122-
<TableRow>
123-
{headCells.map((headCell) => (
124-
<HeaderCell
125-
key={headCell.id}
126-
sortDirection={orderBy === headCell.id ? order : false}
127-
>
128-
<TableSortLabel
129-
active={orderBy === headCell.id}
130-
direction={orderBy === headCell.id ? order : 'asc'}
131-
onClick={createSortHandler(headCell.id)}
120+
<UploadZone handleUpload={handleExperimentFileUpload}>
121+
<TableContainer
122+
sx={{ boxShadow: "none", borderRadius: "8px 8px 0 0", maxHeight: 540, height: 540 }}
123+
>
124+
<Table stickyHeader>
125+
<ExplorerTableHead>
126+
<TableRow>
127+
{headCells.map((headCell) => (
128+
<HeaderCell
129+
key={headCell.id}
130+
sortDirection={orderBy === headCell.id ? order : false}
132131
>
133-
{headCell.label}
134-
</TableSortLabel>
135-
</HeaderCell>
136-
))}
137-
</TableRow>
138-
</ExplorerTableHead>
139-
<TableBody>
140-
{visibleRows.map((row, index) => (
141-
<TableRow
142-
hover={selectedItem !== index}
143-
key={row.name}
144-
onClick={() => handleSelectFile(String(row.name))}
145-
selected={selectedItem === row.name}
146-
>
147-
<FileNameCell>
148-
{getFileIcon(String(row.name))}
149-
{row.name}
150-
</FileNameCell>
151-
<DateAddedCell>{dateFormatter(new Date(row.modifiedAt))}</DateAddedCell>
132+
<TableSortLabel
133+
active={orderBy === headCell.id}
134+
direction={orderBy === headCell.id ? order : 'asc'}
135+
onClick={createSortHandler(headCell.id)}
136+
>
137+
{headCell.label}
138+
</TableSortLabel>
139+
</HeaderCell>
140+
))}
152141
</TableRow>
153-
))}
154-
</TableBody>
155-
</Table>
156-
</TableContainer>
142+
</ExplorerTableHead>
143+
<TableBody>
144+
{visibleRows.map((row, index) => (
145+
<TableRow
146+
hover={selectedItem !== index}
147+
key={row.name}
148+
onClick={() => handleSelectFile(String(row.name))}
149+
selected={selectedItem === row.name}
150+
>
151+
<FileNameCell>
152+
{getFileIcon(String(row.name))}
153+
{row.name}
154+
</FileNameCell>
155+
<DateAddedCell>{dateFormatter(new Date(row.modifiedAt))}</DateAddedCell>
156+
</TableRow>
157+
))}
158+
</TableBody>
159+
</Table>
160+
</TableContainer>
161+
</UploadZone>
157162
</ExplorerBox>
158163
);
159164
}

aqueductcore/frontend/src/components/organisms/Attachments/index.tsx

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { Grid, Typography, styled } from "@mui/material";
21
// import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
3-
// import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
4-
// import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
5-
// import { Divider } from "@mui/material";
6-
import { useContext } from "react";
2+
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
3+
import { Grid, Typography, styled } from "@mui/material";
4+
import { ChangeEvent, useContext } from "react";
75

6+
import { BorderedButtonWithIcon } from "components/atoms/sharedStyledComponents/BorderedButtonWithIcon";
7+
import { VisuallyHiddenInput } from "components/atoms/sharedStyledComponents/VisuallyHiddenInput";
8+
import { ExperimentDataType, ExperimentFileType } from "types/globalTypes";
89
import { FileSelectStateContext } from "context/FileSelectProvider";
9-
import { ExperimentFileType } from "types/globalTypes";
10+
import useFileUpload from "hooks/useUploadFile";
1011
import Explorer from "./Explorer";
1112
import Viewer from "./Viewer";
1213

@@ -15,54 +16,43 @@ font-size: 1.15rem;
1516
margin-top: ${(props) => `${props.theme.spacing(1.5)}`};
1617
`;
1718

18-
// const BorderedButtonWithIcon = styled(Button)`
19-
// border-color: ${(props) => props.theme.palette.neutral.main};
20-
// color: ${(props) =>
21-
// props.theme.palette.mode === "dark"
22-
// ? props.theme.palette.common.white
23-
// : props.theme.palette.common.black};
24-
// text-transform: none;
25-
// padding-left: ${(props) => `${props.theme.spacing}`};
26-
// padding-right: ${(props) => `${props.theme.spacing}`};
27-
// `;
28-
2919
interface AttachmentProps {
30-
experimentUuid: ExperimentFileType[];
20+
experimentUuid: ExperimentDataType['uuid'];
3121
experimentFiles: ExperimentFileType[];
3222
}
3323

3424
function Attachments({ experimentUuid, experimentFiles }: AttachmentProps) {
3525
const { selectedFile, setSelectedFile } = useContext(FileSelectStateContext)
36-
26+
const { handleExperimentFileUpload } = useFileUpload(experimentUuid)
27+
function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
28+
if (e.target.files) {
29+
[...e.target.files].forEach(file => {
30+
handleExperimentFileUpload(file)
31+
})
32+
}
33+
}
3734
return (
3835
<>
3936
<SectionTitle sx={{ mt: 3 }}>Attachment</SectionTitle>
40-
{/* // TODO: will be uncommented when functionality is back*/}
41-
{/* <Grid container spacing={1} sx={{ mt: 0.5 }}>
37+
<Grid container spacing={1} sx={{ mt: 0.5 }}>
4238
<Grid item>
4339
<BorderedButtonWithIcon
40+
// @ts-expect-error is not assignable to type
41+
component="label"
42+
role={undefined}
4443
variant="outlined"
4544
size="small"
4645
color="neutral"
4746
startIcon={<UploadFileOutlinedIcon />}
4847
>
4948
File upload
49+
<VisuallyHiddenInput type="file" multiple onChange={handleChangeFile} />
5050
</BorderedButtonWithIcon>
5151
</Grid>
52-
<Grid item>
52+
{/* <Grid item>
5353
<Divider orientation="vertical" />
54-
</Grid>
55-
<Grid item>
56-
<BorderedButtonWithIcon
57-
variant="outlined"
58-
size="small"
59-
color="neutral"
60-
startIcon={<FileDownloadOutlinedIcon />}
61-
>
62-
Download
63-
</BorderedButtonWithIcon>
64-
</Grid>
65-
<Grid item>
54+
</Grid> */}
55+
{/* <Grid item>
6656
<BorderedButtonWithIcon
6757
variant="outlined"
6858
size="small"
@@ -71,14 +61,15 @@ function Attachments({ experimentUuid, experimentFiles }: AttachmentProps) {
7161
>
7262
Delete
7363
</BorderedButtonWithIcon>
74-
</Grid>
75-
</Grid> */}
64+
</Grid> */}
65+
</Grid>
7666
<Grid container spacing={2} sx={{ mt: 0 }}>
7767
<Grid item xs={12} lg={6}>
7868
<Explorer
7969
files={experimentFiles}
8070
handleSelectFile={setSelectedFile}
8171
selectedItem={selectedFile}
72+
handleExperimentFileUpload={handleExperimentFileUpload}
8273
/>
8374
</Grid>
8475
<Grid item xs={12} lg={6}>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { DragEvent, PropsWithChildren } from "react";
2+
import {
3+
useTheme,
4+
} from "@mui/material";
5+
6+
7+
function DrawerLayout({ children, handleUpload }: PropsWithChildren<{ handleUpload?: (file: File) => void }>) {
8+
const theme = useTheme();
9+
function dropHandler(e: DragEvent<HTMLDivElement>) {
10+
if (!handleUpload) return;
11+
dragLeaveHandler(e)
12+
e.preventDefault();
13+
if (e?.dataTransfer) {
14+
if (e.dataTransfer?.items) {
15+
[...e.dataTransfer.items].forEach((item) => {
16+
if (item.kind === "file") {
17+
const file = item.getAsFile();
18+
if (file) {
19+
handleUpload(file)
20+
}
21+
}
22+
});
23+
}
24+
}
25+
}
26+
27+
function dragOverHandler(e: DragEvent<HTMLDivElement>) {
28+
if (!handleUpload) return;
29+
e.preventDefault();
30+
e.currentTarget.style.background = theme.palette.action.selected
31+
}
32+
33+
function dragLeaveHandler(e: DragEvent<HTMLDivElement>) {
34+
e.currentTarget.style.background = "inherit"
35+
}
36+
return (
37+
<div
38+
onDrop={dropHandler}
39+
onDragOver={dragOverHandler}
40+
onDragLeave={dragLeaveHandler}
41+
>
42+
{children}
43+
</div>)
44+
}
45+
export default DrawerLayout;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import toast from 'react-hot-toast';
3+
import { useContext } from 'react';
4+
5+
import { client } from 'API/apolloClientConfig';
6+
import { AQD_FILE_URI } from 'constants/api';
7+
import useFileUpload from './useUploadFile';
8+
9+
jest.mock('react-hot-toast');
10+
11+
jest.mock('react', () => ({
12+
...jest.requireActual('react'),
13+
useContext: jest.fn(),
14+
}));
15+
16+
jest.mock('API/apolloClientConfig', () => ({
17+
client: {
18+
refetchQueries: jest.fn(),
19+
},
20+
}));
21+
22+
global.fetch = jest.fn();
23+
24+
let setSelectedFileMock: jest.Mock;
25+
26+
beforeEach(() => {
27+
setSelectedFileMock = jest.fn();
28+
(useContext as jest.Mock).mockReturnValue({
29+
setSelectedFile: setSelectedFileMock,
30+
});
31+
(fetch as jest.Mock).mockClear();
32+
(client.refetchQueries as jest.Mock).mockClear();
33+
});
34+
35+
test('should handle successful file upload', async () => {
36+
const mockResponse = {
37+
status: 200,
38+
json: jest.fn().mockResolvedValue({ result: 'Upload success!' }),
39+
};
40+
(fetch as jest.Mock).mockResolvedValue(mockResponse);
41+
42+
const { result } = renderHook(() => useFileUpload('test-uuid'));
43+
44+
await act(async () => {
45+
result.current.handleExperimentFileUpload(new File(['dummy content'], 'test.txt', { type: 'text/plain' }));
46+
});
47+
48+
expect(fetch).toHaveBeenCalledWith(`${AQD_FILE_URI}/api/files/test-uuid`, expect.any(Object));
49+
expect(client.refetchQueries).toHaveBeenCalledWith({
50+
include: 'active',
51+
});
52+
expect(setSelectedFileMock).toHaveBeenCalledWith('test.txt');
53+
expect(toast.success).toHaveBeenCalledWith('Upload success!', {
54+
id: 'upload_success',
55+
});
56+
});
57+
58+
test('should handle failed file upload with error message', async () => {
59+
const mockResponse = {
60+
status: 400,
61+
statusText: 'Bad Request',
62+
json: jest.fn().mockResolvedValue({ detail: 'Upload failed!' }),
63+
};
64+
(fetch as jest.Mock).mockResolvedValue(mockResponse);
65+
66+
const { result } = renderHook(() => useFileUpload('test-uuid'));
67+
68+
await act(async () => {
69+
result.current.handleExperimentFileUpload(new File(['dummy content'], 'test.txt', { type: 'text/plain' }));
70+
});
71+
72+
expect(toast.error).toHaveBeenCalledWith('Upload failed!', {
73+
id: 'upload_failed',
74+
});
75+
});
76+
77+
test('should handle failed file upload without error message', async () => {
78+
const mockResponse = {
79+
status: 400,
80+
statusText: 'Bad Request',
81+
json: jest.fn().mockRejectedValue(new Error('Failed to parse JSON')),
82+
};
83+
(fetch as jest.Mock).mockResolvedValue(mockResponse);
84+
85+
const { result } = renderHook(() => useFileUpload('test-uuid'));
86+
87+
await act(async () => {
88+
result.current.handleExperimentFileUpload(new File(['dummy content'], 'test.txt', { type: 'text/plain' }));
89+
});
90+
91+
expect(toast.error).toHaveBeenCalledWith('Bad Request', {
92+
id: 'upload_catch',
93+
});
94+
});

0 commit comments

Comments
 (0)