Merge pull request #244 from bluewave-labs/feat/picture-validation

Added validation to picture uploads, resolves #213
This commit is contained in:
Alexander Holliday
2024-07-02 12:58:12 -07:00
committed by GitHub
6 changed files with 224 additions and 121 deletions

View File

@@ -1,6 +1,6 @@
.progress-bar-container{
background-color: #fafafa;
padding: var(--env-var-spacing-1-plus);
.progress-bar-container {
background-color: #fafafa;
padding: var(--env-var-spacing-1-plus);
}
.progress-bar-container h2.MuiTypography-root,
.progress-bar-container p.MuiTypography-root {
@@ -10,9 +10,22 @@
.progress-bar-container p.MuiTypography-root {
opacity: 0.6;
}
.progress-bar-container p.MuiTypography-root:has(span){
font-size: 12px;
.progress-bar-container p.MuiTypography-root:has(span) {
font-size: 12px;
}
.progress-bar-container p.MuiTypography-root span {
padding-left: 2px;
}
padding-left: 2px;
}
.progress-bar-container:has(p.input-error){
border-color: var(--env-var-color-24);
padding: 8px var(--env-var-spacing-1-plus);
}
.progress-bar-container p.input-error{
color: var(--env-var-color-24);
opacity: 0.8;
}
.progress-bar-container:has(p.input-error)>.MuiStack-root>svg{
fill: var(--env-var-color-24);
width: 20px;
height: 20px;
}

View File

@@ -9,6 +9,7 @@ import {
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineOutlinedIcon from "@mui/icons-material/ErrorOutlineOutlined";
import "./index.css";
/**
@@ -18,10 +19,18 @@ import "./index.css";
* @param {string} props.size - The size information for the progress item.
* @param {number} props.progress - The current progress value (0-100).
* @param {function} props.onClick - The function to handle click events on the remove button.
* @param {string} props.error - Error message to display if there's an error (optional).
* @returns {JSX.Element} The rendered component.
*/
const ProgressUpload = ({ icon, label, size, progress = 0, onClick }) => {
const ProgressUpload = ({
icon,
label,
size,
progress = 0,
onClick,
error,
}) => {
const theme = useTheme();
return (
<Box
@@ -34,8 +43,15 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick }) => {
border: `solid 1px ${theme.palette.otherColors.graishWhite}`,
}}
>
<Stack direction="row" mb="10px" gap="10px" alignItems="flex-end">
{icon ? (
<Stack
direction="row"
mb={error ? "0" : "10px"}
gap="10px"
alignItems={error ? "center" : "flex-end"}
>
{error ? (
<ErrorOutlineOutlinedIcon />
) : icon ? (
<IconButton
sx={{
backgroundColor: theme.palette.otherColors.white,
@@ -50,26 +66,36 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick }) => {
) : (
""
)}
<Box>
<Typography variant="h4" component="h2" mb="5px">
{label}
{error ? (
<Typography component="p" className="input-error">
{error}
</Typography>
<Typography variant="h4" component="p">
{size}
</Typography>
</Box>
) : (
<Box>
<Typography component="h2" mb="5px">
{error ? error : label}
</Typography>
<Typography component="p">{!error && size}</Typography>
</Box>
)}
<IconButton
onClick={onClick}
sx={{
alignSelf: "flex-start",
ml: "auto",
mr: "-5px",
mt: "-5px",
padding: "5px",
"&:focus": {
outline: "none",
},
}}
sx={
!error
? {
alignSelf: "flex-start",
ml: "auto",
mr: "-5px",
mt: "-5px",
padding: "5px",
"&:focus": {
outline: "none",
},
}
: {
ml: "auto",
}
}
>
<CloseIcon
sx={{
@@ -78,25 +104,29 @@ const ProgressUpload = ({ icon, label, size, progress = 0, onClick }) => {
/>
</IconButton>
</Stack>
<Stack direction="row" alignItems="center">
<Box sx={{ width: "100%", mr: "10px" }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
width: "100%",
height: "10px",
borderRadius: theme.shape.borderRadius,
maxWidth: "500px",
backgroundColor: theme.palette.otherColors.graishWhite,
}}
/>
</Box>
<Typography variant="h4" component="p" sx={{ minWidth: "max-content" }}>
{progress}
<span>%</span>
</Typography>
</Stack>
{!error ? (
<Stack direction="row" alignItems="center">
<Box sx={{ width: "100%", mr: "10px" }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
width: "100%",
height: "10px",
borderRadius: `${theme.shape.borderRadius}px`,
maxWidth: "500px",
backgroundColor: theme.palette.otherColors.graishWhite,
}}
/>
</Box>
<Typography component="p" sx={{ minWidth: "max-content" }}>
{progress}
<span>%</span>
</Typography>
</Stack>
) : (
""
)}
</Box>
);
};
@@ -107,6 +137,7 @@ ProgressUpload.propTypes = {
size: PropTypes.string.isRequired, // Size information for the progress item
progress: PropTypes.number.isRequired, // Current progress value (0-100)
onClick: PropTypes.func.isRequired, // Function to handle click events on the remove button
error: PropTypes.string, // Error message to display if there's an error (optional)
};
export default ProgressUpload;

View File

@@ -7,7 +7,10 @@ import Button from "../../Button";
import EmailTextField from "../../TextFields/Email/EmailTextField";
import StringTextField from "../../TextFields/Text/TextField";
import Avatar from "../../Avatar";
import { editProfileValidation } from "../../../Validation/validation";
import {
editProfileValidation,
imageValidation,
} from "../../../Validation/validation";
import { useDispatch, useSelector } from "react-redux";
import { update } from "../../../Features/Auth/authSlice";
import ImageField from "../../TextFields/Image";
@@ -76,12 +79,30 @@ const ProfilePanel = () => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const handlePicture = (event) => {
clearError("picture");
const pic = event.target.files[0];
console.log(pic);
//TODO - add setPicture state to localData
setPicture({ name: pic.name, type: pic.type, size: formatBytes(pic.size) });
setIsUploading(true);
const { error } = imageValidation.validate(
{
type: pic.type,
size: pic.size,
},
{ abortEarly: false }
);
if (error) {
setErrors((prev) => {
const updatedErrors = { ...prev };
updatedErrors["picture"] = error.details[0].message;
return updatedErrors;
});
return;
}
setTimeout(() => {
//TODO - add setPicture state to localData
setPicture((prev) => ({ ...prev, src: URL.createObjectURL(pic) }));
@@ -98,20 +119,28 @@ const ProfilePanel = () => {
};
const formatBytes = (bytes) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " MB";
const megabytes = bytes / (1024 * 1024);
return megabytes.toFixed(2) + " MB";
};
const clearError = (err) => {
setErrors((prev) => {
const updatedErrors = { ...prev };
if (updatedErrors[err]) delete updatedErrors[err];
return updatedErrors;
});
};
const handleCancelUpload = () => {
//TODO - add setPicture state to localData
setPicture(null);
setPicture();
setUploadProgress(0);
setIsUploading(false);
clearError("picture");
};
const handleClosePictureModal = () => {
setIsOpen("");
setUploadProgress(0);
setIsUploading(false);
clearError("picture");
};
//TODO - revisit once localData is set up properly
@@ -353,7 +382,6 @@ const ProfilePanel = () => {
aria-describedby="update-profile-picture"
open={isModalOpen("picture")}
onClose={handleClosePictureModal}
disablePortal
>
<Stack
sx={{
@@ -387,6 +415,7 @@ const ProfilePanel = () => {
size={picture?.size}
progress={uploadProgress}
onClick={handleCancelUpload}
error={errors["picture"]}
/>
) : (
""

View File

@@ -1,13 +1,27 @@
.image-field-wrapper h2.MuiTypography-root,
.image-field-wrapper p.MuiTypography-root {
.image-field-wrapper p.MuiTypography-root,
.image-field-wrapper + p.MuiTypography-root,
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
color: var(--env-var-color-2);
}
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
font-weight: 600;
}
.image-field-wrapper h2.MuiTypography-root,
.MuiStack-root:has(#modal-update-picture) button,
.MuiStack-root:has(#modal-update-picture) h1.MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
.image-field-wrapper h2.MuiTypography-root {
margin-top: 10px;
}
.image-field-wrapper + p.MuiTypography-root {
margin-top: 8px;
}
.image-field-wrapper + p.MuiTypography-root,
.image-field-wrapper p.MuiTypography-root {
opacity: 0.8;
opacity: 0.6;
font-size: var(--env-var-font-size-small-plus);
}
.image-field-wrapper h2.MuiTypography-root span {
color: var(--env-var-color-3);

View File

@@ -27,77 +27,78 @@ const ImageField = ({ id, picture, onChange }) => {
return (
<>
{!picture ? (
<Box
className="image-field-wrapper"
mt="20px"
sx={{
position: "relative",
height: "fit-content",
border: "dashed",
borderRadius: `${theme.shape.borderRadius}px`,
borderColor: isDragging
? theme.palette.primary.main
: theme.palette.otherColors.graishWhite,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDragLeave}
>
<TextField
id={id}
type="file"
onChange={onChange}
<>
<Box
className="image-field-wrapper"
mt="20px"
sx={{
width: "100%",
"& .MuiInputBase-input[type='file']": {
opacity: 0,
cursor: "pointer",
maxWidth: "500px",
minHeight: "175px"
},
"& fieldset": {
padding: 0,
border: "none",
position: "relative",
height: "fit-content",
border: "dashed",
borderRadius: `${theme.shape.borderRadius}px`,
borderColor: isDragging
? theme.palette.primary.main
: theme.palette.otherColors.graishWhite,
borderWidth: "2px",
transition: "0.2s",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: "hsl(215, 87%, 51%, 0.05)",
},
}}
/>
<Stack
className="custom-file-text"
alignItems="center"
gap="10px"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "-1",
width: "100%",
}}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDragLeave}
>
<IconButton
<TextField
id={id}
type="file"
onChange={onChange}
sx={{
pointerEvents: "none",
borderRadius: `${theme.shape.borderRadius}px`,
border: `solid ${theme.shape.borderThick}px ${theme.palette.otherColors.graishWhite}`,
boxShadow: theme.shape.boxShadow,
width: "100%",
"& .MuiInputBase-input[type='file']": {
opacity: 0,
cursor: "pointer",
maxWidth: "500px",
minHeight: "175px",
},
"& fieldset": {
padding: 0,
border: "none",
},
}}
/>
<Stack
className="custom-file-text"
alignItems="center"
gap="4px"
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "-1",
width: "100%",
}}
>
<CloudUploadIcon />
</IconButton>
<Typography variant="h4" component="h2">
<span>Click to upload</span> or drag and drop
</Typography>
<Typography variant="h4" component="p">
(max. 800x400px)
</Typography>
</Stack>
</Box>
<IconButton
sx={{
pointerEvents: "none",
borderRadius: `${theme.shape.borderRadius}px`,
border: `solid ${theme.shape.borderThick}px ${theme.palette.otherColors.graishWhite}`,
boxShadow: theme.shape.boxShadow,
}}
>
<CloudUploadIcon />
</IconButton>
<Typography component="h2">
<span>Click to upload</span> or drag and drop
</Typography>
<Typography component="p">(maximum size: 3MB)</Typography>
</Stack>
</Box>
<Typography component="p">Supported formats: JPG, PNG</Typography>
</>
) : (
<Stack direction="row" justifyContent="center">
<Box
@@ -107,10 +108,9 @@ const ImageField = ({ id, picture, onChange }) => {
borderRadius: "50%",
overflow: "hidden",
backgroundImage: `url(${picture})`,
backgroundSize: "cover"
backgroundSize: "cover",
}}
>
</Box>
></Box>
</Stack>
)}
</>

View File

@@ -150,7 +150,23 @@ const createMonitorValidation = joi.object({
}),
});
const imageValidation = joi.object({
type: joi.string().valid("image/jpeg", "image/png").messages({
"any.only": "Invalid file format.",
"string.empty": "File type required.",
}),
size: joi
.number()
.max(3 * 1024 * 1024)
.messages({
"number.base": "File size must be a number.",
"number.max": "File size must be less than 3 MB.",
"number.empty": "File size required.",
}),
});
export {
imageValidation,
createMonitorValidation,
registerValidation,
loginValidation,