Files
rowy/src/components/Table/TableHeader/ImportCsv.tsx
2021-10-29 11:47:12 +11:00

305 lines
8.3 KiB
TypeScript

import React, { useState, useCallback } from "react";
import clsx from "clsx";
import parse from "csv-parse";
import { useDropzone } from "react-dropzone";
import { useDebouncedCallback } from "use-debounce";
import { makeStyles, createStyles } from "@mui/styles";
import {
Button,
Popover,
PopoverProps as MuiPopoverProps,
Grid,
Typography,
TextField,
FormHelperText,
} from "@mui/material";
import Tab from "@mui/material/Tab";
import TabContext from "@mui/lab/TabContext";
import TabList from "@mui/lab/TabList";
import TabPanel from "@mui/lab/TabPanel";
import TableHeaderButton from "./TableHeaderButton";
import ImportIcon from "@src/assets/icons/Import";
import FileUploadIcon from "@src/assets/icons/Upload";
import CheckIcon from "@mui/icons-material/CheckCircle";
import ImportCsvWizard, {
IImportCsvWizardProps,
} from "@src/components/Wizards/ImportCsvWizard";
const useStyles = makeStyles((theme) =>
createStyles({
tabPanel: {
padding: theme.spacing(2, 3),
width: 400,
height: 200,
},
continueButton: {
margin: theme.spacing(-4, "auto", 2),
display: "flex",
minWidth: 100,
},
dropzone: {
height: 137,
borderRadius: theme.shape.borderRadius,
border: `dashed 2px ${theme.palette.divider}`,
backgroundColor: theme.palette.action.input,
cursor: "pointer",
"& svg": { opacity: theme.palette.action.activeOpacity },
"&:focus": {
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
outline: "none",
},
},
error: {
"$dropzone&": {
borderColor: theme.palette.error.main,
color: theme.palette.error.main,
},
},
dropzoneError: { margin: theme.spacing(0.5, 1.5) },
pasteField: {
...theme.typography.body2,
fontFamily: theme.typography.fontFamilyMono,
},
pasteInput: {
whiteSpace: "nowrap",
overflow: "auto",
},
})
);
export interface IImportCsvProps {
render?: (
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
) => React.ReactNode;
PopoverProps?: Partial<MuiPopoverProps>;
}
export default function ImportCsv({ render, PopoverProps }: IImportCsvProps) {
const classes = useStyles();
const [open, setOpen] = useState<HTMLButtonElement | null>(null);
const [tab, setTab] = useState("upload");
const [csvData, setCsvData] =
useState<IImportCsvWizardProps["csvData"]>(null);
const [error, setError] = useState("");
const validCsv =
csvData !== null && csvData?.columns.length > 0 && csvData?.rows.length > 0;
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) =>
setOpen(event.currentTarget);
const handleClose = () => {
setOpen(null);
setCsvData(null);
setTab("upload");
setError("");
};
const popoverId = open ? "csv-popover" : undefined;
const parseCsv = (csvString: string) =>
parse(csvString, {}, (err, rows) => {
if (err) {
setError(err.message);
} else {
const columns = rows.shift() ?? [];
if (columns.length === 0) {
setError("No columns detected");
} else {
const mappedRows = rows.map((row) =>
row.reduce((a, c, i) => ({ ...a, [columns[i]]: c }), {})
);
setCsvData({ columns, rows: mappedRows });
setError("");
}
}
});
const onDrop = useCallback(async (acceptedFiles) => {
const file = acceptedFiles[0];
const reader = new FileReader();
reader.onload = (event: any) => parseCsv(event.target.result);
reader.readAsText(file);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false,
accept: "text/csv",
});
const [handlePaste] = useDebouncedCallback(
(value: string) => parseCsv(value),
1000
);
const [loading, setLoading] = useState(false);
const [handleUrl] = useDebouncedCallback((value: string) => {
setLoading(true);
setError("");
fetch(value, { mode: "no-cors" })
.then((res) => res.text())
.then((data) => {
parseCsv(data);
setLoading(false);
})
.catch((e) => {
setError(e.message);
setLoading(false);
});
}, 1000);
const [openWizard, setOpenWizard] = useState(false);
return (
<>
{render ? (
render(handleOpen)
) : (
<TableHeaderButton
title="Import CSV"
onClick={handleOpen}
icon={<ImportIcon />}
/>
)}
<Popover
id={popoverId}
open={!!open}
anchorEl={open}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
{...PopoverProps}
>
<TabContext value={tab}>
<TabList
onChange={(_, v) => {
setTab(v);
setCsvData(null);
setError("");
}}
aria-label="Import CSV method tabs"
action={(actions) =>
setTimeout(() => actions?.updateIndicator(), 200)
}
variant="fullWidth"
>
<Tab label="Upload" value="upload" />
<Tab label="Paste" value="paste" />
<Tab label="URL" value="url" />
</TabList>
<TabPanel value="upload" className={classes.tabPanel}>
<Grid
container
justifyContent="center"
alignContent="center"
alignItems="center"
direction="column"
{...getRootProps()}
className={clsx(classes.dropzone, error && classes.error)}
>
<input {...getInputProps()} />
{isDragActive ? (
<Typography variant="button" color="primary">
Drop CSV file here
</Typography>
) : (
<>
<Grid item>
{validCsv ? <CheckIcon /> : <FileUploadIcon />}
</Grid>
<Grid item>
<Typography variant="button" color="inherit">
{validCsv
? "Valid CSV"
: "Click to upload or drop CSV file here"}
</Typography>
</Grid>
</>
)}
</Grid>
{error && (
<FormHelperText error className={classes.dropzoneError}>
{error}
</FormHelperText>
)}
</TabPanel>
<TabPanel value="paste" className={classes.tabPanel}>
<TextField
variant="filled"
multiline
inputProps={{ minRows: 3 }}
autoFocus
fullWidth
label="Paste CSV text"
placeholder="column, column, …"
onChange={(e) => {
if (csvData !== null) setCsvData(null);
handlePaste(e.target.value);
}}
InputProps={{
classes: {
root: classes.pasteField,
input: classes.pasteInput,
},
}}
helperText={error}
error={!!error}
sx={{ "& .MuiInputBase-input": { fontFamily: "mono" } }}
/>
</TabPanel>
<TabPanel value="url" className={classes.tabPanel}>
<TextField
variant="filled"
autoFocus
fullWidth
label="Paste URL to CSV file"
placeholder="https://"
onChange={(e) => {
if (csvData !== null) setCsvData(null);
handleUrl(e.target.value);
}}
helperText={loading ? "Fetching CSV…" : error}
error={!!error}
/>
</TabPanel>
</TabContext>
<Button
variant="contained"
color="primary"
disabled={!validCsv}
className={classes.continueButton}
onClick={() => setOpenWizard(true)}
>
Continue
</Button>
</Popover>
{openWizard && csvData && (
<ImportCsvWizard
handleClose={() => setOpenWizard(false)}
csvData={csvData}
/>
)}
</>
);
}