From 7e2b32c50acbec3abd55507e68e239cd1c68678f Mon Sep 17 00:00:00 2001 From: Alyona Verbanova Date: Sat, 21 Dec 2024 10:55:37 +0100 Subject: [PATCH] create todo app --- README.md | 4 +- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 242 ++++++++++++++++++++++++++++++++--- src/api/todos.ts | 20 +++ src/commponents/Errors.tsx | 39 ++++++ src/commponents/Footer.tsx | 73 +++++++++++ src/commponents/Header.tsx | 99 ++++++++++++++ src/commponents/TodoCard.tsx | 143 +++++++++++++++++++++ src/commponents/TodoList.tsx | 46 +++++++ src/types/Errors.ts | 8 ++ src/types/Status.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 42 ++++++ 14 files changed, 717 insertions(+), 21 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/commponents/Errors.tsx create mode 100644 src/commponents/Footer.tsx create mode 100644 src/commponents/Header.tsx create mode 100644 src/commponents/TodoCard.tsx create mode 100644 src/commponents/TodoList.tsx create mode 100644 src/types/Errors.ts create mode 100644 src/types/Status.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..ad5e73d278 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ and implement the ability to toggle and rename todos. ## Toggling a todo status Toggle the `completed` status on `TodoStatus` change: + - Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - covered the todo with a loader overlay while waiting for API response; - the status should be changed on success; @@ -38,6 +39,7 @@ Implement the ability to edit a todo title on double click: - or the deletion error message if we tried to delete the todo. ## If you want to enable tests + - open `cypress/integration/page.spec.js` - replace `describe.skip` with `describe` for the root `describe` @@ -47,4 +49,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://AlyonaV22.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..bb8da80ab1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,238 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { UserWarning } from './UserWarning'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from './api/todos'; -const USER_ID = 0; +import { Header } from './commponents/Header'; +import { Footer } from './commponents/Footer'; +import { Errors } from './commponents/Errors'; +import { TodoCard } from './commponents/TodoCard'; + +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +import { ErrorType } from './types/Errors'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filterType, setFilterType] = useState(Status.All); + const [errorType, setErrorType] = useState(ErrorType.EmptyTitle); + const [tempTodo, setTempTodo] = useState(null); + const [todoLoading, setTodoLoading] = useState({}); + + useEffect(() => { + const timeoutId = setTimeout( + () => setErrorType(ErrorType.EmptyTitle), + 3000, + ); + + getTodos() + .then(setTodos) + .catch(() => { + setErrorType(ErrorType.UnableToLoad); + clearTimeout(timeoutId); + }); + + return () => clearTimeout(timeoutId); + }, []); + + interface Loading { + [key: number]: number; + } + + const loadingTodo = (todoList: Todo[]): Loading => { + return todoList.reduce((acc: Loading, todo: Todo): Loading => { + return { + ...acc, + [todo.id]: todo.id, + }; + }, {} as Loading); + }; + + const todoFilter = todos.filter(todo => { + switch (filterType) { + case Status.Active: + return !todo.completed; + case Status.Completed: + return todo.completed; + default: + return true; + } + }); + + const addNewTodo = (todoToAdd: Todo): void => { + setTempTodo(todoToAdd); + + addTodo(todoToAdd) + .then(todoNew => { + setTodos(currentTodos => [...currentTodos, todoNew]); + }) + .catch(() => { + setErrorType(ErrorType.UnableToAdd); + }) + .finally(() => { + setTempTodo(null); + }); + }; + + const deleteTodoItem = (todoId: number): void => { + deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorType(ErrorType.UnableToDelete); + }) + .finally(() => { + setTempTodo(null); + }); + }; + + const updateTodoItems = ( + updateTodoItem: Todo, + key: keyof Todo, + value: boolean | string, + ): Promise => { + return updateTodo({ ...updateTodoItem, [key]: value }) + .then(todoUpdated => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === updateTodoItem.id ? todoUpdated : todo, + ), + ); + + return false; + }) + .catch(() => { + setErrorType(ErrorType.UnableToUpdate); + + return true; + }); + }; + + const loadedDeleteTodo = (): void => { + const completedTodos = todos.filter(todo => todo.completed); + + setTodoLoading(loadingTodo(completedTodos)); + + Promise.allSettled( + completedTodos.map(todo => deleteTodo(todo.id).then(() => todo)), + ) + .then(values => { + values.forEach(val => { + if (val.status === 'rejected') { + setErrorType(ErrorType.UnableToDelete); + } else { + setTodos(currentTodos => { + const todoId = val.value as Todo; + + return currentTodos.filter(todo => todo.id !== todoId.id); + }); + } + }); + }) + .finally(() => setTodoLoading({})); + }; + + const handleAllCompleted = (): void => { + const completedAllTodos = ( + targetTodos: Todo[], + completed: boolean, + ): Promise => { + return Promise.all( + targetTodos.map(todo => updateTodo({ ...todo, completed })), + ) + .then(() => { + setTodos(currentTodos => + currentTodos.map(todo => + targetTodos.some(t => t.id === todo.id) + ? { ...todo, completed } + : todo, + ), + ); + }) + .catch(() => { + setErrorType(ErrorType.UnableToUpdate); + }) + .finally(() => { + setTodoLoading({}); + }); + }; + + const activeTodos = todos.filter(todo => !todo.completed); + + if (activeTodos.length) { + setTodoLoading(loadingTodo(activeTodos)); + completedAllTodos(activeTodos, true); + } else { + setTodoLoading(loadingTodo(todos)); + completedAllTodos(todos, false); + } + }; + + const lengthOfTodo = todos.length; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ +
+ {todoFilter.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ + {todos.length > 0 && ( +
+ )} +
+ + setErrorType(ErrorType.EmptyTitle)} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..01570be3d0 --- /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 = 2134; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (data: Omit) => { + return client.post('/todos', data); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = ({ id, ...todo }: Todo) => { + return client.patch(`/todos/${id}`, todo); +}; diff --git a/src/commponents/Errors.tsx b/src/commponents/Errors.tsx new file mode 100644 index 0000000000..69d232f20c --- /dev/null +++ b/src/commponents/Errors.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import cn from 'classnames'; + +interface Props { + errorType: string; + clearError: () => void; +} + +export const Errors: React.FC = props => { + const { errorType, clearError } = props; + + useEffect(() => { + const timeOut = setTimeout(clearError, 3000); + + return () => { + clearTimeout(timeOut); + }; + }, [errorType]); + + return ( +
+
+ ); +}; diff --git a/src/commponents/Footer.tsx b/src/commponents/Footer.tsx new file mode 100644 index 0000000000..3195893dd7 --- /dev/null +++ b/src/commponents/Footer.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from 'react'; +import cn from 'classnames'; +import { Status } from '../types/Status'; +import { Todo } from '../types/Todo'; + +interface LoadingTodo { + [key: number]: number; +} + +interface Props { + filterType: Status; + onFiltered: (filter: Status) => void; + todos: Todo[]; + loadedDeleteTodo: () => void; + setTodoLoading: React.Dispatch>; +} + +export const Footer: React.FC = props => { + const { filterType, onFiltered, todos, loadedDeleteTodo, setTodoLoading } = + props; + + const countTodo = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + + const onTodoCompleted = useMemo( + () => todos.some(todo => todo.completed), + [todos], + ); + let onCompletedDelete = false; + + const deleteCompletedTodo = () => { + setTodoLoading({}); + onCompletedDelete = true; + loadedDeleteTodo(); + }; + + const filtersValue = useMemo(() => Object.values(Status), []); + + return ( + + ); +}; diff --git a/src/commponents/Header.tsx b/src/commponents/Header.tsx new file mode 100644 index 0000000000..9cd392b090 --- /dev/null +++ b/src/commponents/Header.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { USER_ID } from '../api/todos'; +import cn from 'classnames'; +import { ErrorType } from '../types/Errors'; + +interface Props { + todos: Todo[]; + setErrorType: (errorType: ErrorType) => void; + onChangeTodoTask: (todoTask: Todo | null) => void; + tempTodo: Todo | null; + addNewTodo: (todo: Todo) => Promise; + handleAllCompleted: () => void; + lengthOfTodo: number; +} + +export const Header: React.FC = props => { + const { + todos, + setErrorType, + onChangeTodoTask, + tempTodo, + addNewTodo, + handleAllCompleted, + lengthOfTodo, + } = props; + + const [todoTask, setTodoTask] = useState(''); + + const todoUseRef = useRef(null); + + const completedTodos = todos.every(todo => todo.completed); + + useEffect(() => { + if (todoUseRef.current && tempTodo === null) { + todoUseRef.current.focus(); + } + }, [tempTodo, lengthOfTodo]); + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!todoTask.trim()) { + setErrorType(ErrorType.EmptyTitle); + + return; + } + + const itemTodo: Todo = { + id: 0, + userId: USER_ID, + title: todoTask.trim(), + completed: false, + }; + + let hasNewTodo = true; + + addNewTodo(itemTodo) + .catch(() => { + setErrorType(ErrorType.UnableToAdd); + hasNewTodo = false; + }) + .finally(() => { + if (hasNewTodo) { + setTodoTask(''); + } + + onChangeTodoTask(null); + }); + }; + + return ( + <> +
+ {!!todos.length && ( +
+ + ); +}; diff --git a/src/commponents/TodoCard.tsx b/src/commponents/TodoCard.tsx new file mode 100644 index 0000000000..27f82dd566 --- /dev/null +++ b/src/commponents/TodoCard.tsx @@ -0,0 +1,143 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState, useEffect, useRef } from 'react'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; + +interface LoadingTodo { + [key: number]: number; +} + +interface Props { + todo: Todo; + deleteTodoItem: (todoId: number) => Promise; + updateTodoItems: ( + updateTodoItem: Todo, + key: keyof Todo, + value: boolean | string, + ) => Promise; + todoLoading: LoadingTodo; +} + +export const TodoCard: React.FC = props => { + const { todo, deleteTodoItem, updateTodoItems, todoLoading } = props; + + const [hasLoading, setHasLoading] = useState(false); + const [todoNewTitle, setTodoNewTitle] = useState(todo.title); + const [hasEditing, sethasEditing] = useState(false); + + const todoTitleTask = useRef(null); + const hasActiveTask = + Object.hasOwn(todoLoading, todo.id) || hasLoading || todo.id === 0; + + useEffect(() => { + if (todoTitleTask.current && hasEditing) { + todoTitleTask.current.focus(); + } + }, [hasEditing]); + + const onDelete = () => { + setHasLoading(true); + deleteTodoItem(todo.id).finally(() => setHasLoading(false)); + }; + + const onUpdateTodo = () => { + setHasLoading(true); + updateTodoItems(todo, 'completed', !todo.completed).finally(() => + setHasLoading(false), + ); + }; + + const onSubmitTodo = () => { + if (todoNewTitle === todo.title) { + setHasLoading(false); + + return; + } + + if (!todoNewTitle.trim()) { + onDelete(); + + return; + } + + setHasLoading(true); + updateTodoItems(todo, 'title', todoNewTitle.trim()) + .then(char => sethasEditing(char)) + .finally(() => setHasLoading(false)); + }; + + const onKey = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + sethasEditing(false); + } + }; + + return ( + <> +
+ + + {hasEditing ? ( +
key === 'Escape' && sethasEditing(false)}> +
{ + event.preventDefault(); + onSubmitTodo(); + }} + > + setTodoNewTitle(event.target.value)} + onBlur={onSubmitTodo} + onKeyUp={onKey} + className="todo-title-field" + /> +
+
+ ) : ( + <> + sethasEditing(true)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ + ); +}; diff --git a/src/commponents/TodoList.tsx b/src/commponents/TodoList.tsx new file mode 100644 index 0000000000..824da1b16a --- /dev/null +++ b/src/commponents/TodoList.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoCard } from './TodoCard'; + +interface Props { + todos: Todo[]; + tempTodo: Todo | null; + todoLoading: { [key: number]: number }; + deleteTodoItem: (todoId: number) => Promise; + updateTodoItems: ( + updateTodoItem: Todo, + key: keyof Todo, + value: boolean | string, + ) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + todoLoading, + deleteTodoItem, + updateTodoItems, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 0000000000..45a2c614cf --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,8 @@ +export enum ErrorType { + EmptyTitle = '', + UnableToLoad = 'Unable to load todos', + TitleNotEmpty = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToDelete = 'Unable to delete a todo', + UnableToUpdate = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..dc864cc93b --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + 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..5be775084e --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + 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'), +};