Merge backend + frontend #1

Merged
minhtrannhat merged 17 commits from frontend into master 2023-11-02 01:59:49 +00:00
16 changed files with 1923 additions and 3286 deletions
Showing only changes of commit 4aba3db888 - Show all commits

View File

@ -9,9 +9,12 @@
### Dependencies ### Dependencies
- React - React
- React helment async: manage changes to document head. - React helment async: manage changes to document head
- Material UI - React router dom: route management
- Material UI: UI styled components
- Roboto font - Roboto font
- Formik: form management
- Yup: validate input data
### Frontend Technical Write-up ### Frontend Technical Write-up

File diff suppressed because it is too large Load Diff

View File

@ -3,35 +3,40 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.9.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.9.3",
"@fontsource/roboto": "^5.0.5", "@fontsource/roboto": "^4.5.7",
"@mui/icons-material": "^5.14.1", "@mui/icons-material": "^5.8.4",
"@mui/lab": "^5.0.0-alpha.137", "@mui/lab": "^5.0.0-alpha.91",
"@mui/material": "^5.14.1", "@mui/material": "^5.9.1",
"@testing-library/jest-dom": "^5.17.0", "@mui/x-date-pickers": "^5.0.0-beta.2",
"@testing-library/react": "^13.4.0", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.18.38", "@types/node": "^16.11.45",
"@types/react": "^18.2.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"date-fns": "^2.29.1",
"formik": "^2.2.9",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0", "react-helmet-async": "^1.3.0",
"react-router-dom": "^6.14.2", "react-router-dom": "^6.3.0",
"react-scripts": "^5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.9.5", "typescript": "^4.7.4",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"webpack": ">=5.76.0" "yup": "^0.32.11",
"zxcvbn": "^4.4.2"
}, },
"scripts": { "scripts": {
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
"format": "eslint --fix \"src/**/*.{ts,tsx}\" && prettier --parser typescript --write \"src/**/*.{ts,tsx}\"",
"lint": " eslint \"src/**/*.{ts,tsx}\" && prettier --parser typescript --list-different \"src/**/*.{ts,tsx}\"",
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject"
"analyze": "npm run build && source-map-explorer \"build/static/js/*.js\"",
"format": "eslint --fix \"src/**/*.{ts,tsx}\" && prettier --parser typescript --write \"src/**/*.{ts,tsx}\"",
"lint": "eslint \"src/**/*.{ts,tsx}\" && prettier --parser typescript --list-different \"src/**/*.{ts,tsx}\""
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -61,14 +66,13 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@types/zxcvbn": "^4.4.1",
"@types/react-dom": "^18.2.7", "eslint": "^8.20.0",
"eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0",
"eslint-config-prettier": "^8.8.0", "eslint-import-resolver-typescript": "^3.3.0",
"eslint-import-resolver-typescript": "^3.5.5", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-import": "^2.27.5", "prettier": "^2.7.1",
"prettier": "^3.0.0", "source-map-explorer": "^2.5.2"
"source-map-explorer": "^2.5.3"
}, },
"prettier": { "prettier": {
"trailingComma": "all" "trailingComma": "all"

View File

@ -1,9 +1,9 @@
import { BrowserRouter, Routes } from "react-router-dom"; import { BrowserRouter, Routes } from "react-router-dom";
const Router = () => { const Router = () => (
<BrowserRouter> <BrowserRouter>
<Routes>{}</Routes> <Routes>{}</Routes>
</BrowserRouter>; </BrowserRouter>
}; );
export default Router; export default Router;

View File

@ -0,0 +1,38 @@
import Checkbox from "@mui/material/Checkbox";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormHelperText from "@mui/material/FormHelperText";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";
type IProps = FieldHookConfig<boolean> & {
fullWidth?: boolean;
helperText?: string;
label: string;
required?: boolean;
};
const CheckboxField = (props: IProps) => {
const [field, meta] = useField<boolean>(props);
return (
<FormControl
component="fieldset"
error={Boolean(meta.error) && meta.touched}
fullWidth={props.fullWidth}
margin="normal"
required={props.required}
>
<FormControlLabel
control={<Checkbox {...field} checked={field.value} />}
label={props.label}
/>
<FormHelperText>
{combineHelperText(props.helperText, meta)}
</FormHelperText>
</FormControl>
);
};
export default CheckboxField;

View File

@ -0,0 +1,30 @@
import TextField, { TextFieldProps } from "@mui/material/TextField";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";
const DateField = (props: FieldHookConfig<Date | null> & TextFieldProps) => {
const [field, meta, helpers] = useField<Date | null>(props);
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DatePicker
label={props.label}
value={field.value}
onChange={(newValue) => helpers.setValue(newValue)}
renderInput={(params) => (
<TextField
fullWidth={props.fullWidth}
{...params}
helperText={combineHelperText(props.helperText, meta)}
/>
)}
/>
</LocalizationProvider>
);
};
export default DateField;

View File

@ -0,0 +1,22 @@
import TextField, { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";
const EmailField = (props: FieldHookConfig<string> & TextFieldProps) => {
const [field, meta] = useField<string>(props);
return (
<TextField
{...props}
autoComplete="email"
error={Boolean(meta.error) && meta.touched}
helperText={combineHelperText(props.helperText, meta)}
margin="normal"
type="email"
{...field}
/>
);
};
export default EmailField;

View File

@ -0,0 +1,43 @@
import Button from "@mui/material/Button";
import LoadingButton from "@mui/lab/LoadingButton";
import Stack from "@mui/material/Stack";
import { Link } from "react-router-dom";
interface ILink {
label: string;
to: string;
state?: any;
}
interface IProps {
disabled: boolean;
isSubmitting: boolean;
label: string;
links?: ILink[];
}
const FormActions = ({ disabled, isSubmitting, label, links }: IProps) => (
<Stack direction="row" spacing={1} sx={{ marginTop: 2 }}>
<LoadingButton
disabled={disabled}
loading={isSubmitting}
type="submit"
variant="contained"
>
{label}
</LoadingButton>
{(links ?? []).map(({ label, to, state }) => (
<Button
component={Link}
key={to}
state={state}
to={to}
variant="outlined"
>
{label}
</Button>
))}
</Stack>
);
export default FormActions;

View File

@ -0,0 +1,19 @@
import { TextFieldProps } from "@mui/material/TextField";
import { lazy, Suspense } from "react";
import { FieldHookConfig } from "formik";
import PasswordField from "src/components/PasswordField";
const PasswordWithStrengthField = lazy(
() => import("src/components/PasswordWithStrengthField"),
);
const LazyPasswordWithStrengthField = (
props: FieldHookConfig<string> & TextFieldProps,
) => (
<Suspense fallback={<PasswordField {...props} />}>
<PasswordWithStrengthField {...props} />
</Suspense>
);
export default LazyPasswordWithStrengthField;

View File

@ -0,0 +1,39 @@
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import { FieldHookConfig, useField } from "formik";
import { useState } from "react";
import { combineHelperText } from "src/utils";
const PasswordField = (props: FieldHookConfig<string> & TextFieldProps) => {
const [field, meta] = useField<string>(props);
const [showPassword, setShowPassword] = useState(false);
return (
<TextField
{...props}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword((value) => !value)}
tabIndex={-1}
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
),
}}
error={Boolean(meta.error) && meta.touched}
helperText={combineHelperText(props.helperText, meta)}
margin="normal"
type={showPassword ? "text" : "password"}
{...field}
/>
);
};
export default PasswordField;

View File

@ -0,0 +1,58 @@
import LinearProgress from "@mui/material/LinearProgress";
import { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import zxcvbn from "zxcvbn";
import PasswordField from "src/components/PasswordField";
const scoreToDisplay = (score: number) => {
let progressColor = "other.red";
let helperText = "Weak";
switch (score) {
case 25:
progressColor = "other.pink";
break;
case 50:
progressColor = "other.orange";
break;
case 75:
progressColor = "other.yellow";
helperText = "Good";
break;
case 100:
progressColor = "other.green";
helperText = "Strong";
break;
}
return [progressColor, helperText];
};
const PasswordWithStrengthField = (
props: FieldHookConfig<string> & TextFieldProps,
) => {
const [field] = useField<string>(props);
const result = zxcvbn(field.value ?? "");
const score = (result.score * 100) / 4;
const [progressColor, helperText] = scoreToDisplay(score);
return (
<>
<PasswordField {...props} helperText={helperText} />
<LinearProgress
sx={{
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: progressColor,
},
backgroundColor: "action.selected",
margin: "0 4px 24px 4px",
}}
value={score}
variant="determinate"
/>
</>
);
};
export default PasswordWithStrengthField;

View File

@ -1,5 +1,6 @@
import { useContext } from "react"; import { useContext } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { AuthContext } from "src/AuthContext"; import { AuthContext } from "src/AuthContext";
interface IProps { interface IProps {
@ -8,13 +9,11 @@ interface IProps {
const RequireAuth = ({ children }: IProps) => { const RequireAuth = ({ children }: IProps) => {
const { authenticated } = useContext(AuthContext); const { authenticated } = useContext(AuthContext);
const location = useLocation(); const location = useLocation();
if (authenticated) { if (authenticated) {
return <>{children}</>; return <>{children}</>;
} else { } else {
// re-route user back to login page if not logged in
return <Navigate state={{ from: location }} to="/login/" />; return <Navigate state={{ from: location }} to="/login/" />;
} }
}; };

View File

@ -4,8 +4,6 @@ import { useLocation } from "react-router";
const ScrollToTop = () => { const ScrollToTop = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
// pathname changes => scroll to top
// scrolling only on navigation
useEffect(() => { useEffect(() => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, [pathname]); }, [pathname]);

View File

@ -0,0 +1,21 @@
import MUITextField, { TextFieldProps } from "@mui/material/TextField";
import { FieldHookConfig, useField } from "formik";
import { combineHelperText } from "src/utils";
const TextField = (props: FieldHookConfig<string> & TextFieldProps) => {
const [field, meta] = useField<string>(props);
return (
<MUITextField
{...props}
error={Boolean(meta.error) && meta.touched}
helperText={combineHelperText(props.helperText, meta)}
margin="normal"
type="text"
{...field}
/>
);
};
export default TextField;

View File

@ -8,7 +8,7 @@ interface IProps {
const Title = ({ title }: IProps) => ( const Title = ({ title }: IProps) => (
<> <>
<Helmet> <Helmet>
<title>Todo | {title}</title> <title>Tozo | {title}</title>
</Helmet> </Helmet>
<Typography component="h1" variant="h5"> <Typography component="h1" variant="h5">
{title} {title}

21
frontend/src/utils.tsx Normal file
View File

@ -0,0 +1,21 @@
import { FieldMetaProps } from "formik";
import React from "react";
export const combineHelperText = <T,>(
helperText: React.ReactNode | string | undefined,
meta: FieldMetaProps<T>,
) => {
if (Boolean(meta.error) && meta.touched) {
if (typeof helperText === "string") {
return `${meta.error}. ${helperText ?? ""}`;
} else {
return (
<>
{meta.error}. {helperText}
</>
);
}
} else {
return helperText;
}
};