Compare commits

20 Commits

Author SHA1 Message Date
0b36de6705 Fix(backend): Updated README.md 2023-11-01 23:57:43 -04:00
8f3973c961 Fix(backend): format files to satisfy github CI 2023-11-01 23:52:46 -04:00
4e6f6f5e10 Merge branch 'backend' 2023-11-01 23:49:21 -04:00
181dfb06a2 Feat(Docker): Containerized the application 2023-11-01 23:48:43 -04:00
0688648d75 Merge pull request #14 from minhtrannhat/frontend
Feat(backend): update deps
2023-09-09 00:33:02 -04:00
3a907399bd Feat(backend): update deps 2023-09-09 00:27:42 -04:00
c8ba1f2676 Merge pull request #13 from minhtrannhat/frontend
Frontend completed
2023-09-04 17:27:13 -04:00
141eac7d17 Feat(frontend): Create/Edit/Delete todos 2023-09-04 17:25:45 -04:00
5e72b2ebc9 Merge branch 'backend' into frontend 2023-09-04 16:48:01 -04:00
056401bb22 Feat(frontend): email, login, password for user 2023-09-04 16:38:53 -04:00
bf31eeb652 Feat(frontend): navigation and registration page 2023-09-04 16:29:32 -04:00
1b9d1ee7d5 Feat(frontend): Toasts 2023-09-04 16:13:20 -04:00
1f7066a2d9 Feat(frontend): React Query for interacting with the backend 2023-09-04 16:08:44 -04:00
36fadc059a Feat(frontend): Model the todo class 2023-09-04 15:52:53 -04:00
4aba3db888 Feat(frontend): Styled Form Components
- Updated README.md
2023-09-04 15:46:17 -04:00
7611c9c2fa Merge pull request #12 from minhtrannhat/backend
Feat(Backend): Added docs to openAPI
2023-07-26 16:52:09 -04:00
719889e2bc Feat(Backend): Added docs to openAPI 2023-07-21 22:45:28 -04:00
71cb9bdff6 Feat(Frontend): Added Routing 2023-07-21 22:37:28 -04:00
a73afc90d9 Feat(Frontend): Authentication Context 2023-07-21 22:22:19 -04:00
046726de11 Fix(Frontend): Set maxWidth to md
- This will make sure Material UI's Container will not expand to fill
  the entire screen and instead just stop expanding at around 960px
  (which is `md`).
2023-07-21 18:23:34 -04:00
48 changed files with 3981 additions and 3879 deletions

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:18-bullseye-slim as frontend
WORKDIR /frontend/
COPY frontend/package.json frontend/package-lock.json /frontend/
RUN npm install
COPY frontend /frontend/
RUN npm run build
FROM python:3.10.1-slim-bullseye
RUN apt-get update && apt install dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
EXPOSE 8080
RUN mkdir -p /app
WORKDIR /app
COPY hypercorn.toml /app/
CMD ["pdm", "run", "hypercorn", "--config", "hypercorn.toml", "backend.run:app"]
RUN python -m venv /ve
ENV PATH=/ve/bin:${PATH}
RUN pip install --no-cache-dir pdm
COPY backend/pdm.lock backend/pyproject.toml /app/
RUN pdm install --prod --no-lock --no-editable
COPY --from=frontend /frontend/build/*.js* /app/backend/static/
COPY --from=frontend /frontend/build/*.png /frontend/build/*.svg /app/backend/static/
COPY --from=frontend /frontend/build/index.html \
/app/backend/templates/
COPY --from=frontend /frontend/build/static/. /app/backend/static/
COPY backend/src/ /app/
USER nobody

View File

@@ -1,5 +1,9 @@
# Todo API # Todo API
## To run
`docker-compose up -d`
## Frontend ## Frontend
### Development Workflow ### Development Workflow
@@ -9,9 +13,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

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
# Backend Technical Write-up # Backend Technical Write-up
## Steps ## Steps
@@ -16,16 +15,6 @@
`blueprint`: a collection of route handlers/API functionalities. `blueprint`: a collection of route handlers/API functionalities.
## API route trailing slashes
API paths should end with a slash i.e: `/sessions/` rather than `/session`.
This is because requests sent to `/sessions` will be redirected to `/sessions/` whereas `/sessions/` won't get redirected.
## Difference between database schema and database model
- A schema defines the structure of data within the database.
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
## Managing user's sessions (Authentication) ## Managing user's sessions (Authentication)
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request. - Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
@@ -35,11 +24,6 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
## Idempotent routes ## Idempotent routes
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent. Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
||||||| 3c78fe9
=======
# Backend Technical Write Up
## General Bits of Information
### SameSite setting ### SameSite setting
@@ -53,10 +37,6 @@ Pydantic is to validate the schema/the shape of our input/output (works with JSO
Class full of data. Meant to be used to serialize data into JSON objects. Class full of data. Meant to be used to serialize data into JSON objects.
### Quart specific terminologies
`blueprint`: a collection of route handlers/API functionalities.
### API route trailing slashes ### API route trailing slashes
API paths should end with a slash i.e: `/sessions/` rather than `/session`. API paths should end with a slash i.e: `/sessions/` rather than `/session`.
@@ -66,14 +46,3 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
- A schema defines the structure of data within the database. - A schema defines the structure of data within the database.
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member. - A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
### Managing user's sessions (Authentication)
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
The presence and value of this cookie are used to determine whether the member is logged in, and which member made the request.
- Logout results in the cookie being deleted.
### Idempotent routes
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
>>>>>>> master

1075
backend/pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ dependencies = [
"bcrypt>=4.0.1", "bcrypt>=4.0.1",
"itsdangerous>=2.1.2", "itsdangerous>=2.1.2",
"quart-rate-limiter>=0.7.0", "quart-rate-limiter>=0.7.0",
"pydantic[email]==1.10.11", "pydantic[email]",
"quart-schema>=0.14.3", "quart-schema>=0.14.3",
"quart-db[postgresql]>=0.4.1", "quart-db[postgresql]>=0.4.1",
"httpx>=0.23.1", "httpx>=0.23.1",

View File

@@ -7,4 +7,7 @@ blueprint = Blueprint("control", __name__)
@blueprint.get("/control/ping/") @blueprint.get("/control/ping/")
@rate_exempt @rate_exempt
async def ping() -> ResponseReturnValue: async def ping() -> ResponseReturnValue:
"""Ping the server
Check if server is up and running.
"""
return {"ping": "pong"} return {"ping": "pong"}

View File

@@ -0,0 +1,11 @@
from quart import Blueprint, ResponseReturnValue, render_template
from quart_rate_limiter import rate_exempt
blueprint = Blueprint("serving", __name__)
@blueprint.get("/")
@blueprint.get("/<path:path>")
@rate_exempt
async def index(path: str | None = None) -> ResponseReturnValue:
return await render_template("index.html")

View File

@@ -21,6 +21,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError
# Each blueprint is a logical collection of features in our web app # Each blueprint is a logical collection of features in our web app
from backend.blueprints.control import blueprint as control_blueprint from backend.blueprints.control import blueprint as control_blueprint
from backend.blueprints.members import blueprint as members_blueprint from backend.blueprints.members import blueprint as members_blueprint
from backend.blueprints.serving import blueprint as serving_blueprint
from backend.blueprints.sessions import blueprint as sessions_blueprint from backend.blueprints.sessions import blueprint as sessions_blueprint
from backend.blueprints.todos import blueprint as todos_blueprint from backend.blueprints.todos import blueprint as todos_blueprint
@@ -48,6 +49,7 @@ app.register_blueprint(control_blueprint)
app.register_blueprint(sessions_blueprint) app.register_blueprint(sessions_blueprint)
app.register_blueprint(members_blueprint) app.register_blueprint(members_blueprint)
app.register_blueprint(todos_blueprint) app.register_blueprint(todos_blueprint)
app.register_blueprint(serving_blueprint)
# Rate limiting # Rate limiting

38
docker-compose.yaml Normal file
View File

@@ -0,0 +1,38 @@
version: "3"
services:
web-service:
build:
context: .
dockerfile: ./Dockerfile
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
networks:
- my_network
environment:
TODO_SECRET_KEY: "secret key"
TODO_QUART_DB_DATABASE_URL: postgres://postgres:postgres_password@postgres:5432/todo
TODO_QUART_DB_DATA_PATH: migrations/data.py
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres_password
POSTGRES_DB: todo
POSTGRES_USER: postgres
networks:
- my_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 30s
timeout: 10s
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
networks:
my_network:

View File

@@ -21,10 +21,18 @@ useMemo is a React Hook that lets you cache the result of a calculation between
Change default component colors to suit one's needs. Change default component colors to suit one's needs.
#### CSSBaseline ##### CSSBaseline
Reset the CSS injected into `<head>`. A collection of HTML element and attribute style-normalizations, you can expect all of the elements to look the same across all browsers. Reset the CSS injected into `<head>`. A collection of HTML element and attribute style-normalizations, you can expect all of the elements to look the same across all browsers.
#### useMediaQuery ##### useMediaQuery
This React hook listens for matches to a CSS media query. It allows the rendering of components based on whether the query matches or not. This React hook listens for matches to a CSS media query. It allows the rendering of components based on whether the query matches or not.
##### Container's maxWidth
This will make sure Material UI's Container will not expand to fill the entire screen and instead just stop expanding at around 960px (which is `md`).
### Configure the page's title (Browser Tab Text)
Use `react-helmet-async`, wrap it around the `App` in `App.tsx`.

File diff suppressed because it is too large Load Diff

View File

@@ -3,33 +3,42 @@
"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", "@tanstack/react-query": "^4.33.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",
"axios": "^0.27.2",
"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-scripts": "^5.0.1", "react-helmet-async": "^1.3.0",
"typescript": "^4.9.5", "react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"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": [
@@ -59,17 +68,16 @@
] ]
}, },
"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"
}, },
"proxy": "http://localhost:5050" "proxy": "http://127.0.0.1:5050"
} }

View File

@@ -1,9 +1,7 @@
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { render } from "@testing-library/react";
import App from "./App"; import App from "./App";
test("renders learn react link", () => { test("renders the app", () => {
render(<App />); render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
}); });

View File

@@ -1,30 +1,55 @@
import React from "react"; import React from "react";
import logo from "./logo.svg";
import "./App.css"; import "./App.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Roboto font and its weights
import "@fontsource/roboto/300.css"; import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css"; import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
function App() { // Theming
import ThemeProvider from "./ThemeProvider";
// Material UI stuffs
import Container from "@mui/material/Container";
// React helmet async: configure the page's title
import { Helmet, HelmetProvider } from "react-helmet-async";
// Authentication Context: Check if user is logged in or not
import { AuthContextProvider } from "./AuthContext";
// React router
import Router from "./Router";
// Toasts
import Toasts from "./components/Toasts";
import { ToastContextProvider } from "./ToastContext";
const queryClient = new QueryClient();
const App = () => {
return ( return (
<div className="App"> <QueryClientProvider client={queryClient}>
<header className="App-header"> <AuthContextProvider>
<img src={logo} className="App-logo" alt="logo" /> <HelmetProvider>
<p> <Helmet>
Edit <code>src/App.tsx</code> and save to reload. <title>Todo</title>
</p> </Helmet>
<a <ThemeProvider>
className="App-link" <ToastContextProvider>
href="https://reactjs.org" <Container maxWidth="md">
target="_blank" <Toasts />
rel="noopener noreferrer" <Router />
> </Container>
Learn React </ToastContextProvider>
</a> </ThemeProvider>
</header> </HelmetProvider>
</div> </AuthContextProvider>
</QueryClientProvider>
); );
} };
export default App; export default App;

View File

@@ -0,0 +1,25 @@
import { createContext, useState } from "react";
interface IAuth {
authenticated: boolean;
setAuthenticated: (value: boolean) => void;
}
export const AuthContext = createContext<IAuth>({
authenticated: true,
setAuthenticated: (value: boolean) => {},
});
interface IProps {
children?: React.ReactNode;
}
export const AuthContextProvider = ({ children }: IProps) => {
const [authenticated, setAuthenticated] = useState(true);
return (
<AuthContext.Provider value={{ authenticated, setAuthenticated }}>
{children}
</AuthContext.Provider>
);
};

64
frontend/src/Router.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import ScrollToTop from "./components/ScrollToTop";
import TopBar from "./components/TopBar";
import Register from "./pages/Register";
import ConfirmEmail from "./pages/ConfirmEmail";
import Login from "./pages/Login";
import RequireAuth from "./components/RequireAuth";
import ChangePassword from "./pages/ChangePassword";
import ForgottenPassword from "./pages/ForgottenPassword";
import ResetPassword from "./pages/ResetPassword";
import CreateTodo from "./pages/CreateTodo";
import EditTodo from "./pages/EditTodo";
import Todos from "./pages/Todos";
const Router = () => (
<BrowserRouter>
<ScrollToTop />
<TopBar />
<Routes>
<Route path="/register/" element={<Register />} />
<Route path="/confirm-email/:token/" element={<ConfirmEmail />} />
<Route path="/login/" element={<Login />} />
<Route
path="/change-password/"
element={
<RequireAuth>
<ChangePassword />
</RequireAuth>
}
/>
<Route path="/forgotten-password/" element={<ForgottenPassword />} />
<Route path="/reset-password/:token/" element={<ResetPassword />} />
<Route
path="/"
element={
<RequireAuth>
<Todos />
</RequireAuth>
}
/>
<Route
path="/todos/new/"
element={
<RequireAuth>
<CreateTodo />
</RequireAuth>
}
/>
<Route
path="/todos/:id/"
element={
<RequireAuth>
<EditTodo />
</RequireAuth>
}
/>
</Routes>
</BrowserRouter>
);
export default Router;

View File

@@ -0,0 +1,49 @@
import { AlertColor } from "@mui/material/Alert";
import React, { createContext, useState } from "react";
export interface IToast {
category?: AlertColor;
key: number;
message: string;
}
interface IToastContext {
addToast: (message: string, category: AlertColor | undefined) => void;
setToasts: React.Dispatch<React.SetStateAction<IToast[]>>;
toasts: IToast[];
}
export const ToastContext = createContext<IToastContext>({
addToast: () => {},
setToasts: () => {},
toasts: [],
});
interface IProps {
children?: React.ReactNode;
}
export const ToastContextProvider = ({ children }: IProps) => {
const [toasts, setToasts] = useState<IToast[]>([]);
const addToast = (
message: string,
category: AlertColor | undefined = undefined,
) => {
setToasts((prev) => [
...prev,
{
category,
key: new Date().getTime(),
message,
},
]);
};
return (
<ToastContext.Provider value={{ addToast, setToasts, toasts }}>
{children}
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,67 @@
import axios from "axios";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import AccountCircle from "@mui/icons-material/AccountCircle";
import { useQueryClient } from "@tanstack/react-query";
import React, { useContext, useState } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "src/AuthContext";
import { useMutation } from "src/query";
const useLogout = () => {
const { setAuthenticated } = useContext(AuthContext);
const queryClient = useQueryClient();
const { mutate: logout } = useMutation(
async () => await axios.delete("/sessions/"),
{
onSuccess: () => {
setAuthenticated(false);
queryClient.clear();
},
},
);
return logout;
};
const AccountMenu = () => {
const logout = useLogout();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const onMenuOpen = (event: React.MouseEvent<HTMLElement>) =>
setAnchorEl(event.currentTarget);
const onMenuClose = () => setAnchorEl(null);
return (
<>
<IconButton color="inherit" onClick={onMenuOpen}>
<AccountCircle />
</IconButton>
<Menu
anchorEl={anchorEl}
anchorOrigin={{ horizontal: "right", vertical: "top" }}
keepMounted
onClose={onMenuClose}
open={Boolean(anchorEl)}
transformOrigin={{ horizontal: "right", vertical: "top" }}
>
<MenuItem component={Link} onClick={onMenuClose} to="/change-password/">
Change password
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
logout();
onMenuClose();
}}
>
Logout
</MenuItem>
</Menu>
</>
);
};
export default AccountMenu;

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

@@ -0,0 +1,21 @@
import { useContext } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { AuthContext } from "src/AuthContext";
interface IProps {
children: React.ReactNode;
}
const RequireAuth = ({ children }: IProps) => {
const { authenticated } = useContext(AuthContext);
const location = useLocation();
if (authenticated) {
return <>{children}</>;
} else {
return <Navigate state={{ from: location }} to="/login/" />;
}
};
export default RequireAuth;

View File

@@ -0,0 +1,14 @@
import { useEffect } from "react";
import { useLocation } from "react-router";
const ScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
};
export default ScrollToTop;

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

@@ -0,0 +1,19 @@
import Typography from "@mui/material/Typography";
import { Helmet } from "react-helmet-async";
interface IProps {
title: string;
}
const Title = ({ title }: IProps) => (
<>
<Helmet>
<title>Todo | {title}</title>
</Helmet>
<Typography component="h1" variant="h5">
{title}
</Typography>
</>
);
export default Title;

View File

@@ -0,0 +1,47 @@
import Alert from "@mui/material/Alert";
import Snackbar from "@mui/material/Snackbar";
import React, { useContext, useEffect, useState } from "react";
import { ToastContext, IToast } from "src/ToastContext";
const Toasts = () => {
const { toasts, setToasts } = useContext(ToastContext);
const [open, setOpen] = useState(false);
const [currentToast, setCurrentToast] = useState<IToast | undefined>();
useEffect(() => {
if (!open && toasts.length) {
setCurrentToast(toasts[0]);
setToasts((prev) => prev.slice(1));
setOpen(true);
}
}, [open, setCurrentToast, setOpen, setToasts, toasts]);
const onClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason !== "clickaway") {
setOpen(false);
}
};
return (
<Snackbar
anchorOrigin={{
horizontal: "center",
vertical: "top",
}}
autoHideDuration={6000}
key={currentToast?.key}
onClose={onClose}
open={open}
TransitionProps={{
onExited: () => setCurrentToast(undefined),
}}
>
<Alert onClose={onClose} severity={currentToast?.category}>
{currentToast?.message}
</Alert>
</Snackbar>
);
};
export default Toasts;

View File

@@ -0,0 +1,61 @@
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Skeleton from "@mui/material/Skeleton";
import DeleteIcon from "@mui/icons-material/Delete";
import { format } from "date-fns";
import { Link } from "react-router-dom";
import { Todo as TodoModel } from "../models";
import { useDeleteTodoMutation } from "../queries";
interface IProps {
todo?: TodoModel;
}
const Todo = ({ todo }: IProps) => {
const { mutateAsync: deleteTodo } = useDeleteTodoMutation();
let secondary;
if (todo === undefined) {
secondary = <Skeleton width="200px" />;
} else if (todo.due !== null) {
secondary = format(todo.due, "P");
}
return (
<ListItem
secondaryAction={
<IconButton
disabled={todo === undefined}
edge="end"
onClick={() => deleteTodo(todo?.id!)}
>
<DeleteIcon />
</IconButton>
}
>
<ListItemButton
component={Link}
disabled={todo === undefined}
to={`/todos/${todo?.id}/`}
>
<ListItemIcon>
<Checkbox
checked={todo?.complete ?? false}
disabled
disableRipple
edge="start"
tabIndex={-1}
/>
</ListItemIcon>
<ListItemText
primary={todo?.task ?? <Skeleton />}
secondary={secondary}
/>
</ListItemButton>
</ListItem>
);
};
export default Todo;

View File

@@ -0,0 +1,44 @@
import { Form, Formik } from "formik";
import * as yup from "yup";
import CheckboxField from "../components/CheckboxField";
import DateField from "../components/DateField";
import FormActions from "../components/FormActions";
import TextField from "../components/TextField";
import type { ITodoData } from "../queries";
interface IProps {
initialValues: ITodoData;
label: string;
onSubmit: (data: ITodoData) => Promise<any>;
}
const validationSchema = yup.object({
complete: yup.boolean(),
due: yup.date().nullable(),
task: yup.string().required("Required"),
});
const TodoForm = ({ initialValues, label, onSubmit }: IProps) => (
<Formik<ITodoData>
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting }) => (
<Form>
<TextField fullWidth label="Task" name="task" required />
<DateField fullWidth label="Due" name="due" />
<CheckboxField fullWidth label="Complete" name="complete" />
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label={label}
links={[{ label: "Back", to: "/" }]}
/>
</Form>
)}
</Formik>
);
export default TodoForm;

View File

@@ -0,0 +1,37 @@
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Toolbar from "@mui/material/Toolbar";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "src/AuthContext";
import AccountMenu from "src/components/AccountMenu";
const sxToolbar = {
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
paddingTop: "env(safe-area-inset-top)",
};
const TopBar = () => {
const { authenticated } = useContext(AuthContext);
return (
<>
<AppBar position="fixed">
<Toolbar sx={sxToolbar}>
<Box sx={{ flexGrow: 1 }}>
<Button color="inherit" component={Link} to="/">
Todo
</Button>
</Box>
{authenticated ? <AccountMenu /> : null}
</Toolbar>
</AppBar>
<Toolbar sx={{ ...sxToolbar, marginBottom: 2 }} />
</>
);
};
export default TopBar;

36
frontend/src/models.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { formatISO } from "date-fns";
import * as yup from "yup";
const todoSchema = yup.object({
complete: yup.boolean().required(),
due: yup.date().nullable(),
id: yup.number().required().positive().integer(),
task: yup.string().trim().min(1).defined().strict(true),
});
export class Todo {
complete: boolean;
due: Date | null;
id: number;
task: string;
constructor(data: any) {
const validatedData = todoSchema.validateSync(data);
this.complete = validatedData.complete;
this.due = validatedData.due ?? null;
this.id = validatedData.id;
this.task = validatedData.task;
}
toJSON(): any {
return {
complete: this.complete,
due:
this.due !== null
? formatISO(this.due, { representation: "date" })
: null,
id: this.id,
task: this.task,
};
}
}

View File

@@ -0,0 +1,90 @@
import axios from "axios";
import { Form, Formik, FormikHelpers } from "formik";
import { useContext } from "react";
import { useNavigate } from "react-router-dom";
import * as yup from "yup";
import FormActions from "src/components/FormActions";
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
import PasswordField from "src/components/PasswordField";
import Title from "src/components/Title";
import { ToastContext } from "src/ToastContext";
import { useMutation } from "src/query";
interface IForm {
currentPassword: string;
newPassword: string;
}
const useChangePassword = () => {
const { addToast } = useContext(ToastContext);
const { mutateAsync: changePassword } = useMutation(
async (data: IForm) => await axios.put("/members/password/", data),
);
const navigate = useNavigate();
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
try {
await changePassword(data);
addToast("Changed", "success");
navigate("/");
} catch (error: any) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 400) {
setFieldError("newPassword", "Password is too weak");
} else if (error.response?.status === 401) {
setFieldError("currentPassword", "Incorrect password");
}
} else {
addToast("Try again", "error");
}
}
};
};
const validationSchema = yup.object({
currentPassword: yup.string().required("Required"),
newPassword: yup.string().required("Required"),
});
const ChangePassword = () => {
const onSubmit = useChangePassword();
return (
<>
<Title title="Change Password" />
<Formik<IForm>
initialValues={{ currentPassword: "", newPassword: "" }}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting }) => (
<Form>
<PasswordField
autoComplete="current-password"
fullWidth
label="Current password"
name="currentPassword"
required
/>
<LazyPasswordWithStrengthField
autoComplete="new-password"
fullWidth
label="New password"
name="newPassword"
required
/>
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label="Change"
links={[{ label: "Back", to: "/" }]}
/>
</Form>
)}
</Formik>
</>
);
};
export default ChangePassword;

View File

@@ -0,0 +1,44 @@
import LinearProgress from "@mui/material/LinearProgress";
import axios from "axios";
import { useContext } from "react";
import { useParams } from "react-router";
import { Navigate } from "react-router-dom";
import { useQuery } from "../query";
import { ToastContext } from "../ToastContext";
interface IParams {
token?: string;
}
const ConfirmEmail = () => {
const { addToast } = useContext(ToastContext);
const params = useParams() as IParams;
const token = params.token ?? "";
const { isLoading } = useQuery(
["Email"],
async () => await axios.put("/members/email/", { token }),
{
onError: (error: any) => {
if (error.response?.status === 400) {
if (error.response?.data.code === "TOKEN_INVALID") {
addToast("Invalid token", "error");
} else if (error.response?.data.code === "TOKEN_EXPIRED") {
addToast("Token expired", "error");
}
} else {
addToast("Try again", "error");
}
},
onSuccess: () => addToast("Thanks", "success"),
},
);
if (isLoading) {
return <LinearProgress />;
} else {
return <Navigate to="/" />;
}
};
export default ConfirmEmail;

View File

@@ -0,0 +1,36 @@
import { useContext } from "react";
import { useNavigate } from "react-router-dom";
import TodoForm from "../components/TodoForm";
import Title from "../components/Title";
import type { ITodoData } from "../queries";
import { useCreateTodoMutation } from "../queries";
import { ToastContext } from "../ToastContext";
const CreateTodo = () => {
const navigate = useNavigate();
const { addToast } = useContext(ToastContext);
const { mutateAsync: createTodo } = useCreateTodoMutation();
const onSubmit = async (data: ITodoData) => {
try {
await createTodo(data);
navigate("/");
} catch {
addToast("Try Again", "error");
}
};
return (
<>
<Title title="Create a Todo" />
<TodoForm
initialValues={{ complete: false, due: null, task: "" }}
label="Create"
onSubmit={onSubmit}
/>
</>
);
};
export default CreateTodo;

View File

@@ -0,0 +1,52 @@
import Skeleton from "@mui/material/Skeleton";
import { useContext } from "react";
import { useNavigate, useParams } from "react-router";
import TodoForm from "../components/TodoForm";
import Title from "../components/Title";
import type { ITodoData } from "../queries";
import { useEditTodoMutation, useTodoQuery } from "../queries";
import { ToastContext } from "../ToastContext";
interface Iparams {
id: string;
}
const EditTodo = () => {
const navigate = useNavigate();
const params = useParams<keyof Iparams>() as Iparams;
const todoId = parseInt(params.id, 10);
const { addToast } = useContext(ToastContext);
const { data: todo } = useTodoQuery(todoId);
const { mutateAsync: editTodo } = useEditTodoMutation(todoId);
const onSubmit = async (data: ITodoData) => {
try {
await editTodo(data);
navigate("/");
} catch {
addToast("Try again", "error");
}
};
return (
<>
<Title title="Edit todo" />
{todo === undefined ? (
<Skeleton height="80px" />
) : (
<TodoForm
initialValues={{
complete: todo.complete,
due: todo.due,
task: todo.task,
}}
label="Edit"
onSubmit={onSubmit}
/>
)}
</>
);
};
export default EditTodo;

View File

@@ -0,0 +1,82 @@
import axios from "axios";
import { Form, Formik } from "formik";
import { useContext } from "react";
import { useLocation, useNavigate } from "react-router";
import * as yup from "yup";
import EmailField from "src/components/EmailField";
import FormActions from "src/components/FormActions";
import Title from "src/components/Title";
import { useMutation } from "src/query";
import { ToastContext } from "src/ToastContext";
interface IForm {
email: string;
}
const useForgottenPassword = () => {
const navigate = useNavigate();
const { addToast } = useContext(ToastContext);
const { mutateAsync: forgottenPassword } = useMutation(
async (data: IForm) =>
await axios.post("/members/forgotten-password/", data),
);
return async (data: IForm) => {
try {
await forgottenPassword(data);
addToast("Reset link sent to your email", "success");
navigate("/login/");
} catch {
addToast("Try again", "error");
}
};
};
const validationSchema = yup.object({
email: yup.string().email("Email invalid").required("Required"),
});
const ForgottenPassword = () => {
const onSubmit = useForgottenPassword();
const location = useLocation();
return (
<>
<Title title="Forgotten password" />
<Formik<IForm>
initialValues={{
email: (location.state as any)?.email ?? "",
}}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting, values }) => (
<Form>
<EmailField fullWidth label="Email" name="email" required />
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label="Send email"
links={[
{
label: "Login",
to: "/login/",
state: { email: values.email },
},
{
label: "Register",
to: "/register/",
state: { email: values.email },
},
]}
/>
</Form>
)}
</Formik>
</>
);
};
export default ForgottenPassword;

View File

@@ -0,0 +1,99 @@
import axios from "axios";
import { Form, Formik, FormikHelpers } from "formik";
import { useContext } from "react";
import { useLocation, useNavigate } from "react-router";
import * as yup from "yup";
import { AuthContext } from "../AuthContext";
import EmailField from "../components/EmailField";
import FormActions from "../components/FormActions";
import PasswordField from "../components/PasswordField";
import Title from "../components/Title";
import { ToastContext } from "../ToastContext";
import { useMutation } from "../query";
interface IForm {
email: string;
password: string;
}
const useLogin = () => {
const location = useLocation();
const navigate = useNavigate();
const { addToast } = useContext(ToastContext);
const { setAuthenticated } = useContext(AuthContext);
const { mutateAsync: login } = useMutation(
async (data: IForm) => await axios.post("/sessions/", data),
);
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
try {
await login(data);
setAuthenticated(true);
navigate((location.state as any)?.from ?? "/");
} catch (error: any) {
if (error.response?.status === 401) {
setFieldError("email", "Invalid credentials");
setFieldError("password", "Invalid credentials");
} else {
addToast("Try again", "error");
}
}
};
};
const validationSchema = yup.object({
email: yup.string().email("Email invalid").required("Required"),
password: yup.string().required("Required"),
});
const Login = () => {
const onSubmit = useLogin();
const location = useLocation();
return (
<>
<Title title="Login" />
<Formik<IForm>
initialValues={{
email: (location.state as any)?.email ?? "",
password: "",
}}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting, values }) => (
<Form>
<EmailField fullWidth label="Email" name="email" required />
<PasswordField
autoComplete="password"
fullWidth
label="Password"
name="password"
required
/>
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label="Login"
links={[
{
label: "Reset password",
to: "/forgotten-password/",
state: { email: values.email },
},
{
label: "Register",
to: "/register/",
state: { email: values.email },
},
]}
/>
</Form>
)}
</Formik>
</>
);
};
export default Login;

View File

@@ -0,0 +1,99 @@
import axios from "axios";
import { Form, Formik, FormikHelpers } from "formik";
import { useContext } from "react";
import { useNavigate } from "react-router";
import { useLocation } from "react-router-dom";
import * as yup from "yup";
import EmailField from "src/components/EmailField";
import FormActions from "src/components/FormActions";
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
import Title from "src/components/Title";
import { ToastContext } from "src/ToastContext";
import { useMutation } from "src/query";
interface IForm {
email: string;
password: string;
}
const useRegister = () => {
const navigate = useNavigate();
const { addToast } = useContext(ToastContext);
const { mutateAsync: register } = useMutation(
async (data: IForm) => await axios.post("/members/", data),
);
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
try {
await register(data);
addToast("Registered", "success");
navigate("/login/", { state: { email: data.email } });
} catch (error: any) {
if (
error.response?.status === 400 &&
error.response?.data.code === "WEAK_PASSWORD"
) {
setFieldError("password", "Password is too weak");
} else {
addToast("Try again", "error");
}
}
};
};
const validationSchema = yup.object({
email: yup.string().email("Email invalid").required("Required"),
password: yup.string().required("Required"),
});
const Register = () => {
const location = useLocation();
const onSubmit = useRegister();
return (
<>
<Title title="Register" />
<Formik<IForm>
initialValues={{
email: (location.state as any)?.email ?? "",
password: "",
}}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting, values }) => (
<Form>
<EmailField fullWidth label="Email" name="email" required />
<LazyPasswordWithStrengthField
autoComplete="new-password"
fullWidth
label="Password"
name="password"
required
/>
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label="Register"
links={[
{
label: "Login",
to: "/login/",
state: { email: values.email },
},
{
label: "Reset password",
to: "/forgotten-password/",
state: { email: values.email },
},
]}
/>
</Form>
)}
</Formik>
</>
);
};
export default Register;

View File

@@ -0,0 +1,90 @@
import axios from "axios";
import { Form, Formik, FormikHelpers } from "formik";
import { useContext } from "react";
import { useNavigate, useParams } from "react-router";
import * as yup from "yup";
import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField";
import FormActions from "src/components/FormActions";
import Title from "src/components/Title";
import { useMutation } from "src/query";
import { ToastContext } from "src/ToastContext";
interface IForm {
password: string;
}
interface IParams {
token?: string;
}
const useResetPassword = () => {
const navigate = useNavigate();
const params = useParams() as IParams;
const token = params.token ?? "";
const { addToast } = useContext(ToastContext);
const { mutateAsync: reset } = useMutation(
async (password: string) =>
await axios.put("/members/reset-password/", { password, token }),
);
return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => {
try {
await reset(data.password);
addToast("Success", "success");
navigate("/login/");
} catch (error: any) {
if (error.response?.status === 400) {
if (error.response?.data.code === "WEAK_PASSWORD") {
setFieldError("newPassword", "Password is too weak");
} else if (error.response?.data.code === "TOKEN_INVALID") {
addToast("Invalid token", "error");
} else if (error.response?.data.code === "TOKEN_EXPIRED") {
addToast("Token expired", "error");
}
} else {
addToast("Try again", "error");
}
}
};
};
const validationSchema = yup.object({
email: yup.string().email("Email invalid").required("Required"),
});
const ResetPassword = () => {
const onSubmit = useResetPassword();
return (
<>
<Title title="Reset password" />
<Formik<IForm>
initialValues={{ password: "" }}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting, values }) => (
<Form>
<LazyPasswordWithStrengthField
autoComplete="new-password"
fullWidth
label="Password"
name="password"
required
/>
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label="Reset password"
links={[{ label: "Login", to: "/login/" }]}
/>
</Form>
)}
</Formik>
</>
);
};
export default ResetPassword;

View File

@@ -0,0 +1,38 @@
import Fab from "@mui/material/Fab";
import List from "@mui/material/List";
import AddIcon from "@mui/icons-material/Add";
import { Link, Navigate } from "react-router-dom";
import Todo from "../components/Todo";
import { useTodosQuery } from "../queries";
const Todos = () => {
const { data: todos } = useTodosQuery();
if (todos?.length === 0) {
return <Navigate to="/todos/new/" />;
} else {
return (
<>
<List>
{todos !== undefined
? todos.map((todo) => <Todo key={todo.id} todo={todo} />)
: [1, 2, 3].map((id) => <Todo key={-id} />)}
</List>
<Fab
component={Link}
sx={{
bottom: (theme) => theme.spacing(2),
position: "fixed",
right: (theme) => theme.spacing(2),
}}
to="/todos/new/"
>
<AddIcon />
</Fab>
</>
);
}
};
export default Todos;

72
frontend/src/queries.ts Normal file
View File

@@ -0,0 +1,72 @@
import axios from "axios";
import { useQueryClient } from "@tanstack/react-query";
import { Todo } from "./models";
import { useMutation, useQuery } from "./query";
export const STALE_TIME = 1000 * 60 * 5; // 5 mins
export const useTodosQuery = () =>
useQuery<Todo[]>(
["todos"],
async () => {
const response = await axios.get("/todos/");
return response.data.todos.map((json: any) => new Todo(json));
},
{ staleTime: STALE_TIME },
);
export const useTodoQuery = (id: number) => {
const queryClient = useQueryClient();
return useQuery<Todo>(
["todos", id.toString()],
async () => {
const response = await axios.get(`/todos/${id}/`);
return new Todo(response.data);
},
{
initialData: () => {
return queryClient
.getQueryData<Todo[]>(["todos"])
?.filter((todo: Todo) => todo.id === id)[0];
},
staleTime: STALE_TIME,
},
);
};
export interface ITodoData {
complete: boolean;
due: Date | null;
task: string;
}
export const useCreateTodoMutation = () => {
const queryClient = useQueryClient();
return useMutation(
async (data: ITodoData) => await axios.post("/todos/", data),
{
onSuccess: () => queryClient.invalidateQueries(["todos"]),
},
);
};
export const useEditTodoMutation = (id: number) => {
const queryClient = useQueryClient();
return useMutation(
async (data: ITodoData) => await axios.put(`/todos/${id}/`, data),
{
onSuccess: () => queryClient.invalidateQueries(["todos"]),
},
);
};
export const useDeleteTodoMutation = () => {
const queryClient = useQueryClient();
return useMutation(
async (id: number) => await axios.delete(`/todos/${id}/`),
{
onSuccess: () => queryClient.invalidateQueries(["todos"]),
},
);
};

80
frontend/src/query.ts Normal file
View File

@@ -0,0 +1,80 @@
import axios, { AxiosError } from "axios";
import { useContext } from "react";
import {
MutationFunction,
QueryFunction,
QueryFunctionContext,
QueryKey,
useMutation as useReactMutation,
UseMutationOptions,
UseMutationResult,
useQuery as useReactQuery,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import { AuthContext } from "src/AuthContext";
const MAX_FAILURES = 2;
export function useQuery<
TQueryFnData = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(
queryKey: TQueryKey,
queryFn: QueryFunction<TQueryFnData, TQueryKey>,
options?: UseQueryOptions<TQueryFnData, AxiosError, TData, TQueryKey>,
): UseQueryResult<TData, AxiosError> {
const { setAuthenticated } = useContext(AuthContext);
return useReactQuery<TQueryFnData, AxiosError, TData, TQueryKey>(
queryKey,
async (context: QueryFunctionContext<TQueryKey>) => {
try {
return await queryFn(context);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
setAuthenticated(false);
}
throw error;
}
},
{
retry: (failureCount: number, error: AxiosError) =>
failureCount < MAX_FAILURES &&
(!error.response || error.response.status >= 500),
...options,
},
);
}
export function useMutation<
TData = unknown,
TVariables = void,
TContext = unknown,
>(
mutationFn: MutationFunction<TData, TVariables>,
options?: UseMutationOptions<TData, AxiosError, TVariables, TContext>,
): UseMutationResult<TData, AxiosError, TVariables, TContext> {
const { setAuthenticated } = useContext(AuthContext);
return useReactMutation<TData, AxiosError, TVariables, TContext>(
async (variables: TVariables) => {
try {
return await mutationFn(variables);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
setAuthenticated(false);
}
throw error;
}
},
{
retry: (failureCount: number, error: AxiosError) =>
failureCount < MAX_FAILURES &&
(!error.response || error.response.status >= 500),
...options,
},
);
}

View File

@@ -3,3 +3,7 @@
// expect(element).toHaveTextContent(/react/i) // expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom // learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
window.scrollTo = (x, y) => {
document.documentElement.scrollTop = y;
};

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;
}
};

4
hypercorn.toml Normal file
View File

@@ -0,0 +1,4 @@
accesslog = "-"
access_log_format = "%(t)s %(h)s %(f)s - %(S)s '%(r)s' %(s)s %(b)s %(D)s"
bind = "0.0.0.0:8080"
errorlog = "-"