diff --git a/README.md b/README.md index 47a1add059..538c3774bf 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://SemenVodolazskij.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..511279ae76 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": "^1.9.12", "@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": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "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..005692edf7 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": "^1.9.12", "@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..46aa14689d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,293 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + addTodos, + deleteTodos, + getTodos, + updateTodos as updateTodo, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import classNames from 'classnames'; -const USER_ID = 0; +import { Filter } from './types/FilterButton'; +import { Error } from './types/ErrorMessage'; +import { CompletedTodo } from './components/CompletedTodo'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [error, setError] = useState(Error.Reset); + const [filter, setFilter] = useState(Filter.All); + const [inputValue, setInputValue] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const inputRef = useRef(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [isToogleAll, setIsToogleAll] = useState(false); + const [editingTodoId, setEditingTodoId] = useState(null); + + const handleTodoDoubleClick = (id: number | null) => { + setEditingTodoId(id); + }; + + const onDeleteTodo = async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + try { + await deleteTodos(todoId); + + setTodos(item => item.filter(todo => todo.id !== todoId)); + } catch (err) { + setError(Error.Delete); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + } + }; + + const onAddTodo = async (todoTitle: string) => { + setTempTodo({ id: 0, title: todoTitle, completed: false, userId: USER_ID }); + try { + const newTodo = await addTodos({ title: todoTitle, completed: false }); + + setTodos(item => [...item, newTodo]); + } catch (err) { + setError(Error.Add); + throw err; + } finally { + setTempTodo(null); + } + }; + + const onClearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + completedTodos.forEach(todo => { + onDeleteTodo(todo.id); + }); + }; + + const onUpdateTodo = async (todoUpdate: Todo) => { + const oldTodos = [...todos]; + + setLoadingTodoIds(prev => [...prev, todoUpdate.id]); + + try { + await updateTodo(todoUpdate); + setTodos(currentTodos => + currentTodos.map(todo => { + return todo.id === todoUpdate.id ? todoUpdate : todo; + }), + ); + } catch (err) { + setTodos(oldTodos); + setError(Error.Update); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoUpdate.id)); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (inputValue.trim() === '') { + setError(Error.Empty); + + return; + } + + try { + await onAddTodo(inputValue.trim()); + setInputValue(''); + } catch (err) {} + }; + + const inputFocus = () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + useEffect(() => { + if (todos && !editingTodoId) { + inputFocus(); + } + }, [error, todos, editingTodoId]); + + useEffect(() => { + const timer = setTimeout(() => setError(Error.Reset), 3000); + + return () => clearTimeout(timer); + }, [error]); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setError(Error.Load)); + }, []); + + const filteredTodos = todos.filter(todo => { + if (filter === Filter.Active) { + return !todo.completed; + } + + if (filter === Filter.Completed) { + return todo.completed; + } + + return Filter.All; + }); + if (!USER_ID) { return ; } + const unCompletedTodosCounter = todos.filter(todo => !todo.completed).length; + const todosCompletedNum = todos.filter(todo => todo.completed).length; + const allTodosCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + + const onToggleAll = () => { + if (unCompletedTodosCounter > 0) { + const activeTodos = todos.filter(todo => !todo.completed); + + activeTodos.forEach(todo => { + onUpdateTodo({ ...todo, completed: true }); + }); + setIsToogleAll(true); + } else { + todos.forEach(todo => { + onUpdateTodo({ ...todo, completed: false }); + }); + setIsToogleAll(false); + } + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {todos.length > 0 && ( +
+ +
+ {filteredTodos.map(todo => ( + + ))} + {tempTodo && ( + + )} +
+ + {todos.length > 0 && ( +
+ + {unCompletedTodosCounter} items left + + + + +
+ )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..93978be6f0 --- /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 = 2171; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodos = (newTodo: Omit) => { + return client.post(`/todos`, { ...newTodo, userId: USER_ID }); +}; + +export const deleteTodos = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodos = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/components/CompletedTodo.tsx b/src/components/CompletedTodo.tsx new file mode 100644 index 0000000000..4eef9a4944 --- /dev/null +++ b/src/components/CompletedTodo.tsx @@ -0,0 +1,155 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; +import { Error } from '../types/ErrorMessage'; + +type Props = { + todo: Todo; + isLoading?: boolean; + onDelete: (todoId: number) => Promise; + onUpdateTodo: (todoUpdate: Todo) => Promise; + handleTodoDoubleClick: (id: number | null) => void; + editingTodoId: number | null; + setEditingTodoId: React.Dispatch>; + error: Error; +}; + +type FormEventSubmit = FocusEvent | React.FormEvent; + +export const CompletedTodo: React.FC = props => { + const { + todo, + isLoading, + onDelete, + onUpdateTodo, + handleTodoDoubleClick, + editingTodoId, + setEditingTodoId, + error, + } = props; + + const [todoInputValue, setTodoInputValue] = useState(todo.title); + const inputRef = useRef(null); + + useEffect(() => { + if (editingTodoId === todo.id && inputRef.current) { + inputRef.current.focus(); + } + }, [editingTodoId, todo.id]); + + const onCheckTodo = () => { + const toUpdateTodo = { ...todo, completed: !todo.completed }; + + onUpdateTodo(toUpdateTodo); + }; + + const focusTodoInput = () => { + if (error) { + inputRef.current?.focus(); + } + }; + + const handleSubmitEditingTodo = async (event: FormEventSubmit) => { + event.preventDefault(); + + const trimmedTodo = todoInputValue.trim(); + + setTodoInputValue(trimmedTodo); + + try { + if (trimmedTodo === todo.title) { + setEditingTodoId(null); + + return; + } + + if (trimmedTodo) { + await onUpdateTodo({ ...todo, title: trimmedTodo }); + setEditingTodoId(null); + } else { + await onDelete(todo.id); + setEditingTodoId(null); + } + + handleTodoDoubleClick(null); + } catch (err) { + focusTodoInput(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditingTodoId(null); + } + }; + + return ( +
+ + + {editingTodoId === todo.id ? ( +
{ + handleSubmitEditingTodo(event); + }} + > + setTodoInputValue(event.target.value)} + ref={inputRef} + onKeyDown={handleKeyDown} + /> +
+ ) : ( + { + event.preventDefault(); + handleTodoDoubleClick(todo.id); + }} + > + {todo.title} + + )} + + {editingTodoId !== todo.id ? ( + + ) : ( + '' + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/LoadingTodo.tsx b/src/components/LoadingTodo.tsx new file mode 100644 index 0000000000..8901a18a2c --- /dev/null +++ b/src/components/LoadingTodo.tsx @@ -0,0 +1,24 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +export const LoadingTodo = () => { + return ( +
+ + + + Todo is being saved now + + + + + {/* 'is-active' class puts this modal on top of the todo */} +
+
+
+
+
+ ); +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..3b7c5f4e93 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum Error { + Reset = '', + Empty = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', + Load = 'Unable to load todos', +} diff --git a/src/types/FilterButton.ts b/src/types/FilterButton.ts new file mode 100644 index 0000000000..174408fd69 --- /dev/null +++ b/src/types/FilterButton.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} 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'), +};