From 55d6ec827f1bb1c35e8e999d5aa5b0661679a62f Mon Sep 17 00:00:00 2001 From: Evg3n22 Date: Tue, 10 Mar 2026 11:50:08 +0200 Subject: [PATCH] add solution --- README.md | 2 +- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 285 +++++++++++++++++- src/api/todos.ts | 20 ++ .../ErrorNotification/ErrorNotification.tsx | 66 ++++ src/components/ErrorNotification/index.ts | 1 + src/components/TodoFooter/TodoFooter.tsx | 92 ++++++ src/components/TodoFooter/index.ts | 1 + src/components/TodoHeader/TodoHeader.tsx | 84 ++++++ src/components/TodoHeader/index.ts | 1 + src/components/TodoList/TodoList.tsx | 199 ++++++++++++ src/components/TodoList/index.ts | 1 + src/styles/index.scss | 4 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++ 16 files changed, 798 insertions(+), 21 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/ErrorNotification/index.ts create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoFooter/index.ts create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoHeader/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index 47a1add059..8c1cc915ec 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ Implement the ability to edit a todo title on double click: - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_todo-app-with-api/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://Evg3n22.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 19701e8788..0dbc74a8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index b6062525ab..6d0f20adcc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..262cdd2d71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,281 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + addTodos, + deleteTodos, + getTodos, + updateTodos, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { ErrorNotification } from './components/ErrorNotification'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoList } from './components/TodoList/TodoList'; -const USER_ID = 0; +// type Errors = 'upload' | 'title' | 'add' | 'delete' | 'update' | ''; +enum Errors { + Upload = 'upload', + Title = 'title', + Add = 'add', + Delete = 'delete', + Update = 'update', + None = '', +} + +enum FiltersParam { + All = 'All', + Completed = 'Completed', + Active = 'Active', +} export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [loadTodos, setLoadTodos] = useState(false); + const [hasError, setHasError] = useState(Errors.None); + const [filter, setFilter] = useState(FiltersParam.All); + // const [completedTodos, setCompletedTodos] = useState([]); + // const [allTodosCount, setAllTodosCount] = useState(0); + const [tempTodo, setTempTodo] = useState(null); + const [todoTitle, setTodoTitle] = useState(''); + const [processings, setProcessings] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [focusTrigger, setFocusTrigger] = useState(0); + const [errorTimestamp, setErrorTimestamp] = useState(0); + const [updateLoad, setUpdateLoad] = useState(false); + + const filteredTodos = useMemo(() => { + switch (filter) { + case FiltersParam.Completed: + return todos.filter(t => t.completed); + case FiltersParam.Active: + return todos.filter(t => !t.completed); + default: + return todos; + } + }, [todos, filter]); + + const completedTodos = useMemo(() => todos.filter(t => t.completed), [todos]); + const allTodosCount = useMemo(() => todos.length, [todos]); + + function showError(error: Errors) { + setHasError(error); + setErrorTimestamp(Date.now()); + } + + useEffect(() => { + setLoadTodos(true); + setHasError(Errors.None); + + getTodos() + .then(data => { + setTodos(data); + }) + .catch(error => { + setHasError(Errors.Upload); + throw error; + }) + .finally(() => { + setLoadTodos(false); + }); + }, []); + if (!USER_ID) { return ; } + function onCreateTodo(title: string) { + setIsSubmitting(true); + setHasError(Errors.None); + + const newTemp = { + id: 0, + userId: USER_ID, + title: title.trim(), + completed: false, + }; + + setTempTodo(newTemp); + + addTodos(newTemp) + .then((created: Todo | Todo[]) => { + const createdTodo: Todo | undefined = Array.isArray(created) + ? created[0] + : created; + + if (!createdTodo) { + setHasError(Errors.Add); + + return; + } + + setTodos(prev => [...prev, createdTodo]); + setTodoTitle(''); + // setAllTodosCount(prev => prev + 1); + }) + .catch(error => { + setHasError(Errors.Add); + throw error; + }) + .finally(() => { + setTempTodo(null); + setIsSubmitting(false); + }); + } + + function onDeleteTodo(id: number) { + setProcessings(prev => [...prev, id]); + + return deleteTodos(id) + .then(() => { + setTodos(prev => prev.filter(todo => todo.id !== id)); + // setAllTodosCount(prev => prev - 1); + setFocusTrigger(prev => prev + 1); + }) + .catch(error => { + setHasError(Errors.Delete); + throw error; + }) + .finally(() => { + setProcessings(prev => prev.filter(pid => pid !== id)); + }); + } + + function onDeleteCompletedTodos() { + const completedIds = todos.filter(t => t.completed).map(t => t.id); + + if (completedIds.length === 0) { + return; + } + + const promises = completedIds.map(id => { + setProcessings(prev => [...prev, id]); + + return deleteTodos(id) + .then(() => { + setTodos(prev => prev.filter(t => t.id !== id)); + // setAllTodosCount(prev => prev - 1); + // setCompletedTodos(prev => prev.filter(t => t.id !== id)); // ← оновлюємо completedTodos + setFocusTrigger(prev => prev + 1); + }) + .catch(() => { + return Promise.reject(id); + }) + .finally(() => { + setProcessings(prev => prev.filter(pid => pid !== id)); + }); + }); + + Promise.allSettled(promises).then(results => { + if (results.some(r => r.status === 'rejected')) { + setHasError(Errors.Delete); + } + }); + } + + function onUpdateTodo(id: number, title?: string) { + setHasError(Errors.None); + setProcessings(prev => [...prev, id]); + setUpdateLoad(true); + const selected = todos.find(todo => todo.id === id); + + if (selected) { + const updated: Todo = title + ? { ...selected, title: title !== undefined ? title : selected.title } + : { ...selected, completed: !selected.completed }; + + return updateTodos(updated) + .then((updatedTodo: Todo) => { + setTodos(prev => + prev.map(t => (t.id === updatedTodo.id ? updatedTodo : t)), + ); + }) + .catch(error => { + setHasError(Errors.Update); + throw error; + }) + .finally(() => { + setUpdateLoad(false); + setProcessings(prev => prev.filter(pid => pid !== id)); + }); + } + } + + function onToggleHandle() { + const allCompleted = todos.every(t => t.completed); + const targetCompleted = !allCompleted; + const toUpdate = todos.filter(t => t.completed !== targetCompleted); + + if (toUpdate.length === 0) { + return; + } + + const toUpdateIds = toUpdate.map(t => t.id); + + setProcessings(prev => [...prev, ...toUpdateIds]); // ✅ використовуємо існуючий стан + + const requests = toUpdate.map(t => + updateTodos({ ...t, completed: targetCompleted }), + ); + + Promise.all(requests) + .then(updatedTodosArray => { + const updatedMap = new Map(updatedTodosArray.map(u => [u.id, u])); + + setTodos(prev => prev.map(t => updatedMap.get(t.id) ?? t)); + // setCompletedTodos( + // todos.map(t => updatedMap.get(t.id) ?? t).filter(t => t.completed), + // ); + }) + .catch(() => { + setHasError(Errors.Update); // ✅ показує помилку при 503 + }) + .finally(() => { + setProcessings(prev => prev.filter(id => !toUpdateIds.includes(id))); // ✅ прибираємо loading + }); + } + return ( -
-

- Copy all you need from the prev task: -
- - React Todo App - Add and Delete - -

- -

Styles are already copied

-
+
+

todos

+ +
+ setTodoTitle(newTitle)} + onCreateTodo={(title: string) => onCreateTodo(title)} + setHasError={(error: Errors) => setHasError(error)} + isSubmitting={isSubmitting} + focusTrigger={focusTrigger} + onToggleHandle={onToggleHandle} + /> + + onDeleteTodo(id)} + onUpdateTodo={(id: number, title?: string) => onUpdateTodo(id, title)} + /> + + setFilter(newFilter)} + onDeleteCompletedTodos={onDeleteCompletedTodos} + /> +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..2c48738b1f --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4056; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here + +export const addTodos = (newTodo: Todo) => + client.post(`/todos/`, newTodo); + +export const deleteTodos = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodos = (newTodo: Todo) => + client.patch(`/todos/${newTodo.id}`, newTodo); diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..72cf6c97d6 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,66 @@ +import cn from 'classnames'; +import { useEffect } from 'react'; + +// type Errors = 'upload' | 'title' | 'add' | 'delete' | 'update' | ''; +enum Errors { + Upload = 'upload', + Title = 'title', + Add = 'add', + Delete = 'delete', + Update = 'update', + None = '', +} + +type Props = { + hasError: Errors; + loadTodos: boolean; + errorTimestamp: number; + setHasError: (errorMsg: Errors) => void; +}; + +export const ErrorNotification: React.FC = ({ + hasError, + loadTodos, + errorTimestamp, + setHasError, +}) => { + const ERROR_MESSAGES: Record = { + [Errors.Upload]: 'Unable to load todos', + [Errors.Add]: 'Unable to add a todo', + [Errors.Delete]: 'Unable to delete a todo', + [Errors.Update]: 'Unable to update a todo', + [Errors.Title]: 'Title should not be empty', + [Errors.None]: '', + }; + + useEffect(() => { + if (hasError !== Errors.None) { + const id = setTimeout(() => setHasError(Errors.None), 3000); + + return () => clearTimeout(id); + } + }, [hasError, errorTimestamp]); + + return ( + <> + {/* DON'T use conditional rendering to hide the notification */} + {/* Add the 'hidden' class to hide the message smoothly */} +
+
+ + ); +}; diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 0000000000..a7db61e34c --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,92 @@ +import cn from 'classnames'; + +enum FiltersParam { + All = 'All', + Completed = 'Completed', + Active = 'Active', +} + +type Props = { + allTodosCount: number; + todoLeft: number; + filter: FiltersParam; + setFilter: (newFilter: FiltersParam) => void; + onDeleteCompletedTodos: () => void; +}; + +export const TodoFooter: React.FC = ({ + allTodosCount, + todoLeft, + filter, + setFilter, + onDeleteCompletedTodos, +}) => { + const filterItems = [ + { + label: 'All', + href: '#/', + value: FiltersParam.All, + dataCy: 'FilterLinkAll', + }, + { + label: 'Active', + href: '#/active', + value: FiltersParam.Active, + dataCy: 'FilterLinkActive', + }, + { + label: 'Completed', + href: '#/completed', + value: FiltersParam.Completed, + dataCy: 'FilterLinkCompleted', + }, + ]; + + function handleDeleteCompletedTodos() { + onDeleteCompletedTodos(); + } + + return ( + <> + {/* Hide the footer if there are no todos */} + {allTodosCount !== 0 && ( +
+ + {todoLeft} items left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + +
+ )} + + ); +}; diff --git a/src/components/TodoFooter/index.ts b/src/components/TodoFooter/index.ts new file mode 100644 index 0000000000..544d07114e --- /dev/null +++ b/src/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 0000000000..bc06303eca --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,84 @@ +import cn from 'classnames'; +import { FormEvent, useEffect, useRef } from 'react'; + +enum Errors { + Upload = 'upload', + Title = 'title', + Add = 'add', + Delete = 'delete', + Update = 'update', + None = '', +} + +type Props = { + allTodosCount: number; + completedCount: number; + todoTitle: string; + setTodoTitle: (todoTitle: string) => void; + onCreateTodo: (title: string) => void; + setHasError: (error: Errors) => void; + isSubmitting: boolean; + focusTrigger: number; + onToggleHandle: () => void; +}; + +export const TodoHeader: React.FC = ({ + allTodosCount, + completedCount, + todoTitle, + setTodoTitle, + onCreateTodo, + setHasError, + isSubmitting, + focusTrigger, + onToggleHandle, +}) => { + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (todoTitle.trim()) { + onCreateTodo(todoTitle.trim()); + } else { + setHasError(Errors.Title); + } + } + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [isSubmitting, focusTrigger]); + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {!isSubmitting && allTodosCount > 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 0000000000..c4db4bc408 --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..4dc4f79146 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,199 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { FormEvent, useEffect, useRef, useState, useCallback } from 'react'; +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + processings: number[]; + updateLoad: boolean; + onDeleteTodo: (id: number) => void; + onUpdateTodo: (id: number, title?: string) => void; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + processings, + updateLoad, + onDeleteTodo, + onUpdateTodo, +}) => { + const [editingTitle, setEditingTitle] = useState(''); + const [editingId, setEditingId] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [editingId]); + + async function handleSubmit(event: FormEvent, todo: Todo) { + event.preventDefault(); + const title = editingTitle.trim(); + + if (title === todo.title) { + setEditingId(0); + + return; + } + + try { + if (title === '') { + await onDeleteTodo(todo.id); + } else { + await onUpdateTodo(todo.id, title); + } + + setEditingId(0); // ✅ тільки при успіху + } catch { + // форма залишається відкритою + } + } + + async function finishEdit(todo: Todo) { + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + const title = editingTitle.trim(); + + if (title === todo.title) { + setEditingId(0); + setIsSubmitting(false); + + return; + } + + try { + if (title === '') { + await onDeleteTodo(todo.id); + } else { + await onUpdateTodo(todo.id, title); + } + + setEditingId(0); + } catch { + } finally { + setIsSubmitting(false); + } + } + + const handleDocumentKeyUp = useCallback((event: KeyboardEvent) => { + if (event.key === 'Escape') { + setEditingId(0); + } + }, []); + + useEffect(() => { + document.addEventListener('keyup', handleDocumentKeyUp); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener('keyup', handleDocumentKeyUp); + }; + }, [handleDocumentKeyUp]); // Depend on the memoized handler + + return ( +
+ {todos?.map(todo => ( +
+ + + {editingId === todo.id ? ( +
handleSubmit(event, todo)}> + setEditingTitle(e.target.value)} + ref={inputRef} + onBlur={() => setTimeout(() => finishEdit(todo), 0)} + /> +
+ ) : ( + <> + { + setEditingId(todo.id); + setEditingTitle(todo.title); + }} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..12aac0a01a 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,10 @@ body { pointer-events: none; } +.hidden { + display: none; +} + @import "./todoapp"; @import "./todo"; @import "./filter"; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..3f52a5fdde --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..708ac4c17b --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +// returns a promise resolved after a given delay +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +// To have autocompletion and avoid mistypes +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, // we can send any data to the server +): Promise { + const options: RequestInit = { method }; + + if (data) { + // We add body and Content-Type only for the requests with data + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + // DON'T change the delay it is required for tests + return wait(100) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +};