Skip to content

Commit 1f6a462

Browse files
committed
Implement TodoMVC with all its actions
1 parent c501cc5 commit 1f6a462

File tree

10 files changed

+449
-1
lines changed

10 files changed

+449
-1
lines changed
Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,94 @@
1+
import { RouterProvider, createHashRouter } from "react-router-dom";
2+
import { TodoListPage } from "./TodoListPage";
3+
import { loader } from "../logic/todo-loader";
4+
import { pushTodo, toggleTodo, toggleAll, removeTodo, removeCompleted, setText, populate } from "../logic/todo-model";
5+
6+
const router = createHashRouter([
7+
{
8+
path: "/:filter?",
9+
element: <TodoListPage />,
10+
loader,
11+
},
12+
{
13+
path: "/api/todos",
14+
children: [
15+
{
16+
path: "new",
17+
action: async ({ request }) => {
18+
const formData = await request.formData();
19+
const todoText = formData.get("todoText");
20+
if (todoText) {
21+
return pushTodo(todoText as string);
22+
}
23+
return null;
24+
},
25+
},
26+
{
27+
path: "benchmark/populate",
28+
action: async () => {
29+
populate();
30+
return null;
31+
},
32+
},
33+
{
34+
path: "toggleAll",
35+
action: async ({ request }) => {
36+
const formData = await request.formData();
37+
const value = formData.get("value") as string;
38+
if (!["true", "false"].includes(value!)) {
39+
throw new Error(`Invalid parameter "${value}", it must be "true" or "false".`);
40+
}
41+
toggleAll(value === "true");
42+
return null;
43+
},
44+
},
45+
{
46+
path: "resetAll",
47+
action: async () => {
48+
toggleAll(false);
49+
return null;
50+
},
51+
},
52+
{
53+
path: "removeCompleted",
54+
action: async () => {
55+
removeCompleted();
56+
return null;
57+
},
58+
},
59+
{
60+
path: ":todoId/toggle",
61+
action: async ({ params: { todoId } }) => {
62+
toggleTodo(+todoId!);
63+
return null;
64+
},
65+
},
66+
{
67+
path: ":todoId/remove",
68+
action: async ({ params: { todoId } }) => {
69+
removeTodo(+todoId!);
70+
return null;
71+
},
72+
},
73+
{
74+
path: ":todoId/setText",
75+
action: async ({ params: { todoId }, request }) => {
76+
const formData = await request.formData();
77+
const text = formData.get("text") as string;
78+
79+
setText(+todoId!, text);
80+
return null;
81+
},
82+
},
83+
],
84+
},
85+
]);
86+
187
export function App() {
288
return (
3-
<h1>todos</h1>
89+
<>
90+
<h1>todos</h1>
91+
<RouterProvider router={router} />
92+
</>
493
);
594
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Box, Button } from "@mui/material";
2+
import { useLoaderData, Form } from "react-router-dom";
3+
import type { LoaderReturnValue } from "../logic/todo-loader";
4+
export function TodoExtraActions() {
5+
const { allTodos } = useLoaderData() as LoaderReturnValue;
6+
const hasCompletedItems = allTodos.some((todo) => todo.done);
7+
8+
return hasCompletedItems ? (
9+
<Box sx={{ width: "100%", textAlign: "right" }}>
10+
<Form method="post" action="/api/todos/removeCompleted" navigate={false}>
11+
<Button type="submit">Clear completed items</Button>
12+
</Form>
13+
</Box>
14+
) : null;
15+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { TextField, IconButton, Box } from "@mui/material";
2+
3+
// Note: we're importing the icons using a deep import to work around an issue
4+
// with Firefox devtools in development mode.
5+
import AddIcon from "@mui/icons-material/Add";
6+
import KeyboardDoubleArrowDownIcon from "@mui/icons-material/KeyboardDoubleArrowDown";
7+
8+
import { useFetcher, useLoaderData } from "react-router-dom";
9+
import type { LoaderReturnValue } from "../logic/todo-loader";
10+
11+
export function TodoInput() {
12+
const { allTodos } = useLoaderData() as LoaderReturnValue;
13+
const hasTodos = allTodos.length > 0;
14+
const areAllCompleted = allTodos.every((todo) => todo.done);
15+
const fetcher = useFetcher();
16+
return (
17+
<Box sx={{ display: "flex", alignItems: "center" }}>
18+
{hasTodos ? (
19+
<fetcher.Form action="/api/todos/toggleAll" method="post">
20+
<IconButton type="submit" aria-label={areAllCompleted ? "Reset all entries" : "Mark all entries as completed"} sx={areAllCompleted ? { color: "black" } : null} name="value" value={String(!areAllCompleted)}>
21+
<KeyboardDoubleArrowDownIcon />
22+
</IconButton>
23+
</fetcher.Form>
24+
) : null}
25+
<fetcher.Form
26+
method="POST"
27+
action="/api/todos/new"
28+
style={{ display: "flex", alignItems: "center", flex: "auto" }}
29+
onSubmit={(e) => {
30+
const form = e.currentTarget;
31+
const { todoText } = form.elements as unknown as { todoText: HTMLInputElement };
32+
33+
setTimeout(() => {
34+
todoText.value = "";
35+
});
36+
}}
37+
>
38+
<TextField name="todoText" placeholder="What needs to be done?" sx={{ flex: "auto" }} />
39+
<IconButton aria-label="add" type="submit">
40+
<AddIcon />
41+
</IconButton>
42+
</fetcher.Form>
43+
</Box>
44+
);
45+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Input, ListItem, ListItemIcon, ListItemButton, ListItemText, Checkbox, IconButton } from "@mui/material";
2+
3+
// Note: we're importing the icons using a deep import to work around an issue
4+
// with Firefox devtools in development mode.
5+
import EditIcon from "@mui/icons-material/Edit";
6+
import DeleteIcon from "@mui/icons-material/Delete";
7+
8+
import { useState, memo } from "react";
9+
10+
interface TodoItemProps {
11+
todoId: number;
12+
todoText: string;
13+
todoDone: boolean;
14+
onToggle: (todoId: number) => unknown;
15+
}
16+
17+
export const TodoItem = memo(function ({ todoId, todoText, todoDone, onToggle }: TodoItemProps) {
18+
const [edit, setEdit] = useState(false);
19+
const labelId = `checkbox-list-label-${todoId}`;
20+
21+
return (
22+
<ListItem
23+
secondaryAction={
24+
<>
25+
<IconButton edge="end" aria-label="edit" onClick={() => setEdit(!edit)}>
26+
<EditIcon />
27+
</IconButton>
28+
<IconButton edge="end" aria-label="remove" type="submit" formAction={`/api/todos/${todoId}/remove`}>
29+
<DeleteIcon />
30+
</IconButton>
31+
</>
32+
}
33+
disablePadding
34+
>
35+
{edit ? (
36+
<Input
37+
defaultValue={todoText}
38+
autoFocus={true}
39+
onKeyDown={(e) => {
40+
if (e.code === "Escape") setEdit(false);
41+
}}
42+
sx={{ marginLeft: "72px", height: "58px" }}
43+
name="text"
44+
/>
45+
) : (
46+
<ListItemButton role={undefined} sx={{ marginRight: "40px" /* The margin compensates for the 2 icons in secondaryAction */ }} onClick={() => onToggle(todoId)}>
47+
<ListItemIcon>
48+
<Checkbox edge="start" checked={todoDone} tabIndex={-1} inputProps={{ "aria-labelledby": labelId }} />
49+
</ListItemIcon>
50+
<ListItemText id={labelId} primary={todoText} />
51+
</ListItemButton>
52+
)}
53+
</ListItem>
54+
);
55+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useCallback } from "react";
2+
import { useLoaderData, useFetcher } from "react-router-dom";
3+
import { LoaderReturnValue } from "../logic/todo-loader";
4+
import { TodoItem } from "./TodoItem";
5+
import { List } from "@mui/material";
6+
7+
export function TodoList() {
8+
const { filteredTodos } = useLoaderData() as LoaderReturnValue;
9+
const fetcher = useFetcher();
10+
/* The eslint rule doesn't seem to support this case. */
11+
/* eslint-disable-next-line react-hooks/exhaustive-deps */
12+
const onTodoToggle = useCallback((todoId: number) => fetcher.submit(null, { action: `/api/todos/${todoId}/toggle`, method: "POST" }), [fetcher.submit]);
13+
return (
14+
<List>
15+
{filteredTodos.map((todo) => (
16+
<fetcher.Form key={todo.id} action={`/api/todos/${todo.id}/setText`} method="POST">
17+
{/* This submit button is present first, so that it's the default submit button for this form for the purpose of the
18+
implicit submission algorithm. */}
19+
<input type="submit" style={{ display: "none" }} />
20+
{/* We use todo.text as key, so that the element is remounted when the text is changed, and the edit box is hidden.
21+
The alternative would have been to listen for the Enter key inside ListItem, and hide the input in a setTimeout, but I thought this was more fragile. */}
22+
<TodoItem key={todo.text} todoId={todo.id} todoText={todo.text} todoDone={todo.done} onToggle={onTodoToggle} />
23+
</fetcher.Form>
24+
))}
25+
</List>
26+
);
27+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Paper } from "@mui/material";
2+
import { TodoTestActions } from "./TodoTestActions";
3+
import { TodoInput } from "./TodoInput";
4+
import { TodoList } from "./TodoList";
5+
import { TodoNav } from "./TodoNav";
6+
import { TodoExtraActions } from "./TodoExtraActions";
7+
8+
export function TodoListPage() {
9+
return (
10+
<>
11+
<TodoTestActions />
12+
<Paper elevation={1}>
13+
<TodoInput />
14+
<TodoList />
15+
<TodoExtraActions />
16+
<TodoNav />
17+
</Paper>
18+
</>
19+
);
20+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { BottomNavigation, BottomNavigationAction, Paper, Badge } from "@mui/material";
2+
3+
// Note: we're importing the icons using a deep import to work around an issue
4+
// with Firefox devtools in development mode.
5+
import DoneIcon from "@mui/icons-material/Done";
6+
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
7+
import AlarmIcon from "@mui/icons-material/Alarm";
8+
9+
import { Link as RouterLink, useLoaderData } from "react-router-dom";
10+
import type { LoaderReturnValue } from "../logic/todo-loader";
11+
12+
export function TodoNav() {
13+
const { filter, allTodos } = useLoaderData() as LoaderReturnValue;
14+
if (!allTodos.length) {
15+
return null;
16+
}
17+
18+
let completedCount = 0;
19+
let pendingCount = 0;
20+
for (const todo of allTodos) {
21+
if (todo.done) {
22+
completedCount++;
23+
} else {
24+
pendingCount++;
25+
}
26+
}
27+
28+
return (
29+
<Paper elevation={1}>
30+
<BottomNavigation showLabels value={filter}>
31+
<BottomNavigationAction value="all" label="All" icon={<FormatListBulletedIcon />} component={RouterLink} to="/" />
32+
<BottomNavigationAction
33+
value="active"
34+
label="Active"
35+
icon={
36+
<Badge badgeContent={pendingCount} color="primary">
37+
<AlarmIcon aria-label={`${pendingCount} pending items`} />
38+
</Badge>
39+
}
40+
component={RouterLink}
41+
to="/active"
42+
/>
43+
<BottomNavigationAction
44+
value="completed"
45+
label="Completed"
46+
icon={
47+
<Badge badgeContent={completedCount} color="success">
48+
<DoneIcon aria-label={`${completedCount} completed items`} />
49+
</Badge>
50+
}
51+
component={RouterLink}
52+
to="/completed"
53+
/>
54+
</BottomNavigation>
55+
</Paper>
56+
);
57+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Form } from "react-router-dom";
2+
import { Box, Button } from "@mui/material";
3+
export function TodoTestActions() {
4+
return (
5+
<Box sx={{ padding: 1 }}>
6+
<Form action="/api/todos/benchmark/populate" method="post" navigate={false}>
7+
<Button variant="contained" type="submit" className="add-lots-of-items-button">
8+
Add lots of items
9+
</Button>
10+
</Form>
11+
</Box>
12+
);
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Params } from "react-router-dom";
2+
import { getTodos } from "./todo-model";
3+
4+
export function loader({ params }: { params: Params<"filter"> }) {
5+
const filter = params.filter ?? "all";
6+
if (!["all", "active", "completed"].includes(filter)) {
7+
throw new Error(`Invalid filter ${filter}, should be one of all, done or pending`);
8+
}
9+
10+
return {
11+
filter,
12+
filteredTodos: getTodos(filter as "all" | "active" | "completed"),
13+
allTodos: getTodos(),
14+
};
15+
}
16+
17+
export type LoaderReturnValue = Awaited<ReturnType<typeof loader>>;

0 commit comments

Comments
 (0)