import { useEffect, useRef, useState } from "react";
import { listTodos, createTodo, updateTodo, deleteTodo } from "./src/api/api";
import Pager from "./src/components/Pager";
const PAGE_SIZE = 5;
const FILTERS = ["all", "active", "completed"];
function readUrl() {
const params = new URLSearchParams(window.location.search);
const filter = params.get("filter");
return {
page: Math.max(1, Number.parseInt(params.get("page"), 10) || 1),
filter: FILTERS.includes(filter) ? filter : "all",
q: params.get("q") ?? "",
};
}
export default function App() {
const [initial] = useState(readUrl);
const [page, setPage] = useState(initial.page);
const [filter, setFilter] = useState(initial.filter);
const [search, setSearch] = useState(initial.q);
const [query, setQuery] = useState(initial.q);
const [data, setData] = useState({ items: [], total: 0 });
const [loading, setLoading] = useState(true);
const [reloads, setReloads] = useState(0);
const [draft, setDraft] = useState("");
const [failNext, setFailNext] = useState(false);
const [toast, setToast] = useState("");
const tempIds = useRef(0);
const totalPages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
useEffect(() => {
if (search === query) return;
const timer = setTimeout(() => {
setQuery(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search, query]);
useEffect(() => {
let ignore = false;
setLoading(true);
listTodos({ page, pageSize: PAGE_SIZE, filter, q: query }).then((res) => {
if (ignore) return;
const last = Math.max(1, Math.ceil(res.total / PAGE_SIZE));
if (page > last) return setPage(last);
setData(res);
setLoading(false);
});
return () => {
ignore = true;
};
}, [page, filter, query, reloads]);
useEffect(() => {
const params = new URLSearchParams();
if (page > 1) params.set("page", String(page));
if (filter !== "all") params.set("filter", filter);
if (query) params.set("q", query);
const qs = params.toString();
window.history.replaceState(
null,
"",
qs ? "?" + qs : window.location.pathname,
);
}, [page, filter, query]);
const refresh = () => setReloads((n) => n + 1);
const patchItems = (fn) => setData((d) => ({ ...d, items: fn(d.items) }));
function takeFailure() {
if (failNext) setFailNext(false);
return failNext;
}
async function addTodo(event) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
const fail = takeFailure();
const tempId = "temp-" + ++tempIds.current;
setToast("");
setDraft("");
setData((d) => ({
items: [{ id: tempId, text, done: false, pending: true }, ...d.items],
total: d.total + 1,
}));
try {
await createTodo(text, { fail });
refresh();
} catch {
setData((d) => ({
items: d.items.filter((t) => t.id !== tempId),
total: d.total - 1,
}));
setDraft((current) => current || text);
setToast("Could not add “" + text + "”. Nothing was saved.");
}
}
async function toggleTodo(todo) {
const fail = takeFailure();
const done = !todo.done;
setToast("");
patchItems((items) =>
items.map((t) => (t.id === todo.id ? { ...t, done, pending: true } : t)),
);
try {
await updateTodo(todo.id, { done }, { fail });
refresh();
} catch {
patchItems((items) =>
items.map((t) =>
t.id === todo.id ? { ...t, done: !done, pending: false } : t,
),
);
setToast("Could not update “" + todo.text + "”. Change undone.");
}
}
async function removeTodo(todo) {
const fail = takeFailure();
const index = data.items.findIndex((t) => t.id === todo.id);
setToast("");
setData((d) => ({
items: d.items.filter((t) => t.id !== todo.id),
total: d.total - 1,
}));
try {
await deleteTodo(todo.id, { fail });
refresh();
} catch {
setData((d) => {
const items = [...d.items];
items.splice(Math.min(index, items.length), 0, todo);
return { items, total: d.total + 1 };
});
setToast("Could not delete “" + todo.text + "”. It is back.");
}
}
function changeFilter(name) {
setFilter(name);
setPage(1);
}
return (
<main className="app">
<h1>Todos</h1>
<form className="row" onSubmit={addTodo}>
<input
data-testid="new-todo"
aria-label="New task"
placeholder="Add a task"
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<button data-testid="add" type="submit">
Add
</button>
</form>
<div className="row">
<input
data-testid="search"
type="search"
aria-label="Search tasks"
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="filters" role="group" aria-label="Filter">
{FILTERS.map((name) => (
<button
key={name}
type="button"
data-filter={name}
aria-pressed={filter === name}
onClick={() => changeFilter(name)}
>
{name}
</button>
))}
</div>
</div>
<label className="fail">
<input
type="checkbox"
data-testid="fail-next"
checked={failNext}
onChange={(e) => setFailNext(e.target.checked)}
/>
Make the next save fail
</label>
{toast && (
<p role="alert" data-testid="toast" className="toast">
{toast}{" "}
<button
type="button"
aria-label="Dismiss"
onClick={() => setToast("")}
>
✕
</button>
</p>
)}
<p data-testid="summary" className="muted" aria-live="polite">
{data.total} {data.total === 1 ? "task" : "tasks"} · page {page} of{" "}
{totalPages}
{loading ? " · loading…" : ""}
</p>
<ul className="list" aria-busy={loading}>
{data.items.map((todo) => (
<li
key={todo.id}
data-testid="todo"
className={todo.pending ? "pending" : ""}
>
<label>
<input
type="checkbox"
checked={todo.done}
disabled={todo.pending}
onChange={() => toggleTodo(todo)}
/>
<span className={todo.done ? "text done" : "text"}>
{todo.text}
</span>
</label>
{todo.pending && <span className="saving">Saving…</span>}
<button
type="button"
data-action="delete"
aria-label={"Delete " + todo.text}
disabled={todo.pending}
onClick={() => removeTodo(todo)}
>
✕
</button>
</li>
))}
</ul>
{!loading && data.items.length === 0 && (
<p data-testid="empty" className="muted">
No tasks match.
</p>
)}
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</main>
);
}