Skip to content

Commit

Permalink
feat(upload): add upload button functionality (#176)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
safeamiiir committed Jul 3, 2024
1 parent 2ca6cce commit 6f5565e
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 74 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { styled } from "@mui/material";

export const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Table from "@mui/material/Table";

import { FileSelectContextType, SortOrder, selectedFileType } from "types/componentTypes";
import { getComparator, stableSort } from "helper/functions";
import UploadZone from "components/templates/UploadFileZone";
import { ExperimentFileType } from "types/globalTypes";
import { dateFormatter } from "helper/formatters";

Expand Down Expand Up @@ -70,10 +71,12 @@ function Explorer({
files,
handleSelectFile,
selectedItem,
handleExperimentFileUpload
}: {
files: ExperimentFileType[];
handleSelectFile: FileSelectContextType['setSelectedFile'];
selectedItem: selectedFileType;
handleExperimentFileUpload?: (file: File) => void
}) {
const [order, setOrder] = useState<SortOrder>('desc');
const [orderBy, setOrderBy] = useState<keyof ExperimentFileType>('modifiedAt');
Expand Down Expand Up @@ -114,46 +117,48 @@ function Explorer({

return (
<ExplorerBox>
<TableContainer
sx={{ boxShadow: "none", borderRadius: "8px 8px 0 0", maxHeight: 540, height: 540 }}
>
<Table stickyHeader>
<ExplorerTableHead>
<TableRow>
{headCells.map((headCell) => (
<HeaderCell
key={headCell.id}
sortDirection={orderBy === headCell.id ? order : false}
>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={createSortHandler(headCell.id)}
<UploadZone handleUpload={handleExperimentFileUpload}>
<TableContainer
sx={{ boxShadow: "none", borderRadius: "8px 8px 0 0", maxHeight: 540, height: 540 }}
>
<Table stickyHeader>
<ExplorerTableHead>
<TableRow>
{headCells.map((headCell) => (
<HeaderCell
key={headCell.id}
sortDirection={orderBy === headCell.id ? order : false}
>
{headCell.label}
</TableSortLabel>
</HeaderCell>
))}
</TableRow>
</ExplorerTableHead>
<TableBody>
{visibleRows.map((row, index) => (
<TableRow
hover={selectedItem !== index}
key={row.name}
onClick={() => handleSelectFile(String(row.name))}
selected={selectedItem === row.name}
>
<FileNameCell>
{getFileIcon(String(row.name))}
{row.name}
</FileNameCell>
<DateAddedCell>{dateFormatter(new Date(row.modifiedAt))}</DateAddedCell>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={createSortHandler(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</HeaderCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</ExplorerTableHead>
<TableBody>
{visibleRows.map((row, index) => (
<TableRow
hover={selectedItem !== index}
key={row.name}
onClick={() => handleSelectFile(String(row.name))}
selected={selectedItem === row.name}
>
<FileNameCell>
{getFileIcon(String(row.name))}
{row.name}
</FileNameCell>
<DateAddedCell>{dateFormatter(new Date(row.modifiedAt))}</DateAddedCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</UploadZone>
</ExplorerBox>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Grid, Typography, styled } from "@mui/material";
// import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
// import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
// import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
// import { Divider } from "@mui/material";
import { useContext } from "react";
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
import { Grid, Typography, styled } from "@mui/material";
import { ChangeEvent, useContext } from "react";

import { BorderedButtonWithIcon } from "components/atoms/sharedStyledComponents/BorderedButtonWithIcon";
import { VisuallyHiddenInput } from "components/atoms/sharedStyledComponents/VisuallyHiddenInput";
import { ExperimentDataType, ExperimentFileType } from "types/globalTypes";
import { FileSelectStateContext } from "context/FileSelectProvider";
import { ExperimentFileType } from "types/globalTypes";
import useFileUpload from "hooks/useUploadFile";
import Explorer from "./Explorer";
import Viewer from "./Viewer";

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

// const BorderedButtonWithIcon = styled(Button)`
// border-color: ${(props) => props.theme.palette.neutral.main};
// color: ${(props) =>
// props.theme.palette.mode === "dark"
// ? props.theme.palette.common.white
// : props.theme.palette.common.black};
// text-transform: none;
// padding-left: ${(props) => `${props.theme.spacing}`};
// padding-right: ${(props) => `${props.theme.spacing}`};
// `;

interface AttachmentProps {
experimentUuid: ExperimentFileType[];
experimentUuid: ExperimentDataType['uuid'];
experimentFiles: ExperimentFileType[];
}

function Attachments({ experimentUuid, experimentFiles }: AttachmentProps) {
const { selectedFile, setSelectedFile } = useContext(FileSelectStateContext)

const { handleExperimentFileUpload } = useFileUpload(experimentUuid)
function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
if (e.target.files) {
[...e.target.files].forEach(file => {
handleExperimentFileUpload(file)
})
}
}
return (
<>
<SectionTitle sx={{ mt: 3 }}>Attachment</SectionTitle>
{/* // TODO: will be uncommented when functionality is back*/}
{/* <Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item>
<BorderedButtonWithIcon
// @ts-expect-error is not assignable to type
component="label"
role={undefined}
variant="outlined"
size="small"
color="neutral"
startIcon={<UploadFileOutlinedIcon />}
>
File upload
<VisuallyHiddenInput type="file" multiple onChange={handleChangeFile} />
</BorderedButtonWithIcon>
</Grid>
<Grid item>
{/* <Grid item>
<Divider orientation="vertical" />
</Grid>
<Grid item>
<BorderedButtonWithIcon
variant="outlined"
size="small"
color="neutral"
startIcon={<FileDownloadOutlinedIcon />}
>
Download
</BorderedButtonWithIcon>
</Grid>
<Grid item>
</Grid> */}
{/* <Grid item>
<BorderedButtonWithIcon
variant="outlined"
size="small"
Expand All @@ -71,14 +61,15 @@ function Attachments({ experimentUuid, experimentFiles }: AttachmentProps) {
>
Delete
</BorderedButtonWithIcon>
</Grid>
</Grid> */}
</Grid> */}
</Grid>
<Grid container spacing={2} sx={{ mt: 0 }}>
<Grid item xs={12} lg={6}>
<Explorer
files={experimentFiles}
handleSelectFile={setSelectedFile}
selectedItem={selectedFile}
handleExperimentFileUpload={handleExperimentFileUpload}
/>
</Grid>
<Grid item xs={12} lg={6}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { DragEvent, PropsWithChildren } from "react";
import {
useTheme,
} from "@mui/material";


function DrawerLayout({ children, handleUpload }: PropsWithChildren<{ handleUpload?: (file: File) => void }>) {
const theme = useTheme();
function dropHandler(e: DragEvent<HTMLDivElement>) {
if (!handleUpload) return;
dragLeaveHandler(e)
e.preventDefault();
if (e?.dataTransfer) {
if (e.dataTransfer?.items) {
[...e.dataTransfer.items].forEach((item) => {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
handleUpload(file)
}
}
});
}
}
}

function dragOverHandler(e: DragEvent<HTMLDivElement>) {
if (!handleUpload) return;
e.preventDefault();
e.currentTarget.style.background = theme.palette.action.selected
}

function dragLeaveHandler(e: DragEvent<HTMLDivElement>) {
e.currentTarget.style.background = "inherit"
}
return (
<div
onDrop={dropHandler}
onDragOver={dragOverHandler}
onDragLeave={dragLeaveHandler}
>
{children}
</div>)
}
export default DrawerLayout;
94 changes: 94 additions & 0 deletions aqueductcore/frontend/src/hooks/useUploadFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { renderHook, act } from '@testing-library/react';
import toast from 'react-hot-toast';
import { useContext } from 'react';

import { client } from 'API/apolloClientConfig';
import { AQD_FILE_URI } from 'constants/api';
import useFileUpload from './useUploadFile';

jest.mock('react-hot-toast');

jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn(),
}));

jest.mock('API/apolloClientConfig', () => ({
client: {
refetchQueries: jest.fn(),
},
}));

global.fetch = jest.fn();

let setSelectedFileMock: jest.Mock;

beforeEach(() => {
setSelectedFileMock = jest.fn();
(useContext as jest.Mock).mockReturnValue({
setSelectedFile: setSelectedFileMock,
});
(fetch as jest.Mock).mockClear();
(client.refetchQueries as jest.Mock).mockClear();
});

test('should handle successful file upload', async () => {
const mockResponse = {
status: 200,
json: jest.fn().mockResolvedValue({ result: 'Upload success!' }),
};
(fetch as jest.Mock).mockResolvedValue(mockResponse);

const { result } = renderHook(() => useFileUpload('test-uuid'));

await act(async () => {
result.current.handleExperimentFileUpload(new File(['dummy content'], 'test.txt', { type: 'text/plain' }));
});

expect(fetch).toHaveBeenCalledWith(`${AQD_FILE_URI}/api/files/test-uuid`, expect.any(Object));
expect(client.refetchQueries).toHaveBeenCalledWith({
include: 'active',
});
expect(setSelectedFileMock).toHaveBeenCalledWith('test.txt');
expect(toast.success).toHaveBeenCalledWith('Upload success!', {
id: 'upload_success',
});
});

test('should handle failed file upload with error message', async () => {
const mockResponse = {
status: 400,
statusText: 'Bad Request',
json: jest.fn().mockResolvedValue({ detail: 'Upload failed!' }),
};
(fetch as jest.Mock).mockResolvedValue(mockResponse);

const { result } = renderHook(() => useFileUpload('test-uuid'));

await act(async () => {
result.current.handleExperimentFileUpload(new File(['dummy content'], 'test.txt', { type: 'text/plain' }));
});

expect(toast.error).toHaveBeenCalledWith('Upload failed!', {
id: 'upload_failed',
});
});

test('should handle failed file upload without error message', async () => {
const mockResponse = {
status: 400,
statusText: 'Bad Request',
json: jest.fn().mockRejectedValue(new Error('Failed to parse JSON')),
};
(fetch as jest.Mock).mockResolvedValue(mockResponse);

const { result } = renderHook(() => useFileUpload('test-uuid'));

await act(async () => {
result.current.handleExperimentFileUpload(new File(['dummy content'], 'test.txt', { type: 'text/plain' }));
});

expect(toast.error).toHaveBeenCalledWith('Bad Request', {
id: 'upload_catch',
});
});
Loading

2 comments on commit 6f5565e

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
aqueductcore/backend
   context.py34488%49–53, 60–61
   main.py19479%21–24, 31
   session.py13469%24–28
aqueductcore/backend/models
   extensions.py1511491%53, 56, 80–81, 88, 94, 96–97, 143, 177, 219, 247, 253, 261
   orm.py31197%58
aqueductcore/backend/routers
   files.py88298%169–170
   frontend.py241154%22–33, 37
aqueductcore/backend/routers/graphql
   mutations_schema.py58297%48, 135
   query_schema.py45296%66–68
   router.py11191%15
aqueductcore/backend/routers/graphql/mutations
   experiment_mutations.py24196%30
aqueductcore/backend/routers/graphql/resolvers
   experiment_resolver.py25292%28, 36
   tags_resolver.py25388%14, 44, 46
aqueductcore/backend/services
   experiment.py2203285%82, 111–124, 154, 188, 194, 240, 243, 268, 300, 348, 354, 383, 389, 406, 431, 438, 446, 476–482, 489–493, 505–508
   extensions_executor.py88891%39–42, 68, 83, 86, 152–158, 206
   validators.py33294%41, 46
aqueductcore/cli
   commands.py60788%32, 82–85, 94–98, 110–114
   exporter.py49198%71
   importer.py61198%90
TOTAL135110292% 

Tests Skipped Failures Errors Time
101 1 💤 0 ❌ 0 🔥 26.264s ⏱️

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
aqueductcore/backend
   context.py34488%49–53, 60–61
   main.py19479%21–24, 31
   session.py13469%24–28
aqueductcore/backend/models
   extensions.py1511491%53, 56, 80–81, 88, 94, 96–97, 143, 177, 219, 247, 253, 261
   orm.py31197%58
aqueductcore/backend/routers
   files.py88298%169–170
   frontend.py241154%22–33, 37
aqueductcore/backend/routers/graphql
   mutations_schema.py58297%48, 135
   query_schema.py45296%66–68
   router.py11191%15
aqueductcore/backend/routers/graphql/mutations
   experiment_mutations.py24196%30
aqueductcore/backend/routers/graphql/resolvers
   experiment_resolver.py25292%28, 36
   tags_resolver.py25388%14, 44, 46
aqueductcore/backend/services
   experiment.py2203285%82, 111–124, 154, 188, 194, 240, 243, 268, 300, 348, 354, 383, 389, 406, 431, 438, 446, 476–482, 489–493, 505–508
   extensions_executor.py88891%39–42, 68, 83, 86, 152–158, 206
   validators.py33294%41, 46
aqueductcore/cli
   commands.py60788%32, 82–85, 94–98, 110–114
   exporter.py49198%71
   importer.py61198%90
TOTAL135110292% 

Tests Skipped Failures Errors Time
101 1 💤 0 ❌ 0 🔥 27.324s ⏱️

Please sign in to comment.