Skip to content

Commit

Permalink
Add FinalFormFileSelect (#1461)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Ricky James Smith <jamesricky@me.com>
Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com>
  • Loading branch information
3 people authored May 14, 2024
1 parent f89af8b commit 52130af
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .changeset/strong-onions-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@comet/admin": minor
---

Add `FinalFormFileSelect` component

Allows selecting files via the file dialog or using drag-and-drop.
1 change: 1 addition & 0 deletions packages/admin/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"query-string": "^6.8.1",
"react-dropzone": "^14.0.0",
"use-constant": "^1.0.0",
"uuid": "^9.0.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/admin/admin/src/form/FieldContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type FieldContainerClassKey =
const styles = (theme: Theme) => {
return createStyles<FieldContainerClassKey, FieldContainerProps>({
root: {
maxWidth: "100%",
"&:not($fieldMarginNever)": {
marginBottom: theme.spacing(4),
"&:not($fullWidth)": {
Expand Down
300 changes: 300 additions & 0 deletions packages/admin/admin/src/form/FinalFormFileSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { Delete, Error, Select } from "@comet/admin-icons";
import { Button, Chip, ComponentsOverrides, FormHelperText, IconButton, Theme, Typography } from "@mui/material";
import { createStyles, WithStyles, withStyles } from "@mui/styles";
import clsx from "clsx";
import * as React from "react";
import { Accept, useDropzone } from "react-dropzone";
import { FieldRenderProps } from "react-final-form";
import { FormattedMessage } from "react-intl";

import { Alert } from "../alert/Alert";
import { Tooltip } from "../common/Tooltip";
import { PrettyBytes } from "../helpers/PrettyBytes";

export type FinalFormFileSelectClassKey =
| "root"
| "dropzone"
| "droppableArea"
| "droppableAreaCaption"
| "droppableAreaError"
| "fileList"
| "fileListItem"
| "fileListItemInfos"
| "rejectedFileListItem"
| "errorMessage"
| "droppableAreaIsDisabled"
| "droppableAreaHasError"
| "fileListText"
| "selectButton";

const styles = ({ palette }: Theme) => {
return createStyles<FinalFormFileSelectClassKey, FinalFormFileSelectProps>({
root: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "10px",
},
dropzone: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "10px",
width: "100%",
},
droppableArea: {
position: "relative",
display: "flex",
height: "80px",
justifyContent: "center",
alignItems: "center",
alignSelf: "stretch",
borderRadius: "4px",
border: `1px dashed ${palette.grey[200]}`,
cursor: "pointer",
"&$droppableAreaIsDisabled": {
cursor: "default",
},
"&$droppableAreaHasError": {
border: `1px dashed ${palette.error.main}`,
},
},
droppableAreaCaption: {
padding: "30px",
color: palette.grey[400],
},
droppableAreaError: {
position: "absolute",
top: 0,
right: 0,
padding: "10px",
color: palette.error.main,
},
fileList: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
width: "100%",
},
fileListItem: {
display: "flex",
padding: "8px 7px 8px 15px",
alignItems: "center",
gap: "10px",
borderRadius: "4px",
background: palette.grey[50],
justifyContent: "space-between",
width: "100%",
boxSizing: "border-box",
},
fileListItemInfos: {
display: "flex",
justifyContent: "end",
gap: "10px",
},
rejectedFileListItem: {
display: "flex",
padding: "14px 15px",
alignItems: "center",
gap: "10px",
borderRadius: "4px",
background: palette.grey[50],
justifyContent: "space-between",
border: `1px dashed ${palette.error.main}`,
color: palette.error.main,
width: "100%",
boxSizing: "border-box",
},
errorMessage: {
display: "flex",
alignItems: "center",
color: palette.error.main,
gap: "5px",
},
droppableAreaIsDisabled: {},
droppableAreaHasError: {},
fileListText: {
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
},
selectButton: {
backgroundColor: palette.grey[800],
color: palette.common.white,
"&:hover": {
backgroundColor: palette.grey[800],
color: palette.common.white,
},
},
});
};

export interface FinalFormFileSelectProps extends FieldRenderProps<File | File[], HTMLInputElement> {
disableDropzone?: boolean;
disableSelectFileButton?: boolean;
accept?: Accept;
maxSize?: number;
maxFiles?: number;
iconMapping?: {
delete?: React.ReactNode;
error?: React.ReactNode;
select?: React.ReactNode;
};
}

const FinalFormFileSelectComponent: React.FunctionComponent<WithStyles<typeof styles> & FinalFormFileSelectProps> = ({
classes,
disabled,
disableDropzone,
disableSelectFileButton,
accept,
maxSize = 50 * 1024 * 1024,
maxFiles,
input: { onChange, value: fieldValue, multiple: multipleFiles },
iconMapping = {},
}) => {
const { delete: deleteIcon = <Delete />, error: errorIcon = <Error color="error" />, select: selectIcon = <Select /> } = iconMapping;

const dropzoneDisabled = disabled || (maxFiles && fieldValue.length >= maxFiles);

const onDrop = React.useCallback(
(acceptedFiles: File[]) => {
if (multipleFiles) {
if (Array.isArray(fieldValue)) {
if (!maxFiles || (maxFiles && fieldValue.length < maxFiles && fieldValue.length + acceptedFiles.length <= maxFiles)) {
onChange([...fieldValue, ...acceptedFiles]);
}
} else {
onChange([...acceptedFiles]);
}
} else {
onChange(acceptedFiles[0]);
}
},
[fieldValue, multipleFiles, onChange, maxFiles],
);

const removeFile = (removedFile: File) => () => {
const newFiles = Array.isArray(fieldValue) ? fieldValue.filter((file) => file !== removedFile) : undefined;
onChange(newFiles);
};

const { fileRejections, getRootProps, getInputProps, isDragReject } = useDropzone({
onDrop,
accept,
disabled: dropzoneDisabled,
multiple: multipleFiles,
maxSize: maxSize,
maxFiles,
});

let acceptedFiles: File[] = [];

if (Array.isArray(fieldValue)) {
acceptedFiles = fieldValue;
} else if (fieldValue.name !== undefined) {
acceptedFiles = [fieldValue];
}

const rejectedFiles = fileRejections.map((rejectedFile, index) => (
<div key={index} className={classes.rejectedFileListItem}>
<Tooltip trigger="hover" title={rejectedFile.file.name}>
<div className={classes.fileListText}>{rejectedFile.file.name}</div>
</Tooltip>
{errorIcon}
</div>
));

return (
<div className={classes.root}>
{maxFiles && fieldValue.length >= maxFiles ? (
<Alert title={<FormattedMessage id="comet.finalFormFileSelect.maximumReached" defaultMessage="Maximum reached" />} severity="info">
<FormattedMessage
id="comet.finalFormFileSelect.maximumFilesAmount"
defaultMessage="The maximum number of uploads has been reached. Please delete files from the list before uploading new files."
/>
</Alert>
) : (
<div {...getRootProps()} className={classes.dropzone}>
<input {...getInputProps()} />
{!disableDropzone && (
<div
className={clsx(
classes.droppableArea,
dropzoneDisabled && classes.droppableAreaIsDisabled,
isDragReject && classes.droppableAreaHasError,
)}
>
{isDragReject && <div className={classes.droppableAreaError}>{errorIcon}</div>}
<Typography variant="body2" className={classes.droppableAreaCaption}>
<FormattedMessage id="comet.finalFormFileSelect.dropfiles" defaultMessage="Drop files here to upload" />
</Typography>
</div>
)}
{!disableSelectFileButton && (
<Button
disabled={dropzoneDisabled}
variant="contained"
color="secondary"
startIcon={selectIcon}
className={classes.selectButton}
>
<FormattedMessage id="comet.finalFormFileSelect.selectfile" defaultMessage="Select file" />
</Button>
)}
</div>
)}
{acceptedFiles.length > 0 && (
<div className={classes.fileList}>
{acceptedFiles.map((file, index) => (
<div key={index} className={classes.fileListItem}>
<Tooltip trigger="hover" title={file.name}>
<div className={classes.fileListText}>{file.name}</div>
</Tooltip>
<div className={classes.fileListItemInfos}>
<Chip label={<PrettyBytes value={file.size} />} />
<IconButton onClick={removeFile(file)}>{deleteIcon}</IconButton>
</div>
</div>
))}
</div>
)}
{fileRejections.length > 0 && <div className={classes.fileList}>{rejectedFiles}</div>}
{(fileRejections.length > 0 || isDragReject) && (
<div className={classes.errorMessage}>
{errorIcon}
<FormattedMessage id="comet.finalFormFileSelect.errors.unknownError" defaultMessage="Something went wrong." />
</div>
)}
<FormHelperText sx={{ margin: 0 }}>
<FormattedMessage
id="comet.finalFormFileSelect.maximumFileSize"
defaultMessage="Maximum file size {fileSize}"
values={{
fileSize: <PrettyBytes value={maxSize} />,
}}
/>
</FormHelperText>
</div>
);
};

export const FinalFormFileSelect = withStyles(styles, { name: "CometAdminFinalFormFileSelect" })(FinalFormFileSelectComponent);

declare module "@mui/material/styles" {
interface ComponentNameToClassKey {
CometAdminFinalFormFileSelect: FinalFormFileSelectClassKey;
}

interface ComponentsPropsList {
CometAdminFinalFormFileSelect: FinalFormFileSelectProps;
}

interface Components {
CometAdminFinalFormFileSelect?: {
defaultProps?: Partial<ComponentsPropsList["CometAdminFinalFormFileSelect"]>;
styleOverrides?: ComponentsOverrides<Theme>["CometAdminFinalFormFileSelect"];
};
}
}
1 change: 1 addition & 0 deletions packages/admin/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export { SwitchField, SwitchFieldProps } from "./form/fields/SwitchField";
export { TextAreaField, TextAreaFieldProps } from "./form/fields/TextAreaField";
export { TextField, TextFieldProps } from "./form/fields/TextField";
export { FinalFormContext, FinalFormContextProvider, FinalFormContextProviderProps, useFinalFormContext } from "./form/FinalFormContextProvider";
export { FinalFormFileSelect, FinalFormFileSelectClassKey, FinalFormFileSelectProps } from "./form/FinalFormFileSelect";
export { FinalFormInput, FinalFormInputProps } from "./form/FinalFormInput";
export { FinalFormRangeInput, FinalFormRangeInputClassKey, FinalFormRangeInputProps } from "./form/FinalFormRangeInput";
export { FinalFormSearchTextField, FinalFormSearchTextFieldProps } from "./form/FinalFormSearchTextField";
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 52130af

Please sign in to comment.