From a0186c45b53f98677f9ea69f3ef0e59e6f51b638 Mon Sep 17 00:00:00 2001 From: Anna Agerone Date: Sun, 10 Nov 2024 12:24:40 -0600 Subject: [PATCH] Solution --- src/App.tsx | 341 +++++++++++++++++++++++++-- src/api/todos.ts | 24 ++ src/components/Footer/Footer.tsx | 52 ++++ src/components/Header/Header.tsx | 66 ++++++ src/components/TodoItem/TodoItem.tsx | 118 +++++++++ src/components/TodoList/TodoList.tsx | 58 +++++ src/types/ErrorNotification.ts | 7 + src/types/FilterStatus.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++ 10 files changed, 703 insertions(+), 20 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/ErrorNotification.ts create mode 100644 src/types/FilterStatus.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..fbcffcdd4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,327 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import React, { useCallback, useEffect, useState } from 'react'; +import { Todo } from './types/Todo'; +import { FilterStatus } from './types/FilterStatus'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { Footer } from './components/Footer/Footer'; +import { + createTodo, + deleteTodo, + updateTodo, + getTodos, + USER_ID, +} from './api/todos'; +import classNames from 'classnames'; +import { ErrorNotification } from './types/ErrorNotification'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [filteredTodos, setFilteredTodos] = useState([]); + const [filter, setFilter] = useState(FilterStatus.All); + const [errorMessage, setErrorMessage] = useState(''); + const [loading, setLoading] = useState(false); + const [title, setTitle] = useState(''); + const [processingTodoIds, setProcessingTodoIds] = useState([]); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage(ErrorNotification.loadingError); + setTimeout(() => setErrorMessage(''), 3000); + }); + }, []); + + const filterTodosByStatus = useCallback(() => { + let filtered; + + switch (filter) { + case FilterStatus.Active: + filtered = todos.filter(todo => !todo.completed); + break; + case FilterStatus.Completed: + filtered = todos.filter(todo => todo.completed); + break; + default: + filtered = todos; + } + + setFilteredTodos(filtered); + }, [todos, filter]); + + useEffect(() => { + filterTodosByStatus(); + }, [todos, filter, filterTodosByStatus]); + + const createTempTodo = (tempTitle: string): Todo => { + return { + title: tempTitle, + userId: USER_ID, + completed: false, + id: 0, + }; + }; + + const handleAddTodo = (newTitle: string) => { + const trimmedTitle = newTitle.trim(); + + setTempTodo(createTempTodo(newTitle)); + setLoading(true); + + if (trimmedTitle) { + createTodo(trimmedTitle) + .then(newTodoResponse => { + setTodos(prevTodos => [...prevTodos, newTodoResponse]); + setTempTodo(null); + setTitle(''); + }) + .catch(() => { + setErrorMessage(ErrorNotification.addError); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setTempTodo(null); + setLoading(false); + }); + } else { + setTempTodo(null); + setLoading(false); + setErrorMessage(ErrorNotification.titleError); + setTimeout(() => setErrorMessage(''), 3000); + } + }; + + const deleteSelectTodo = (todoId: number): Promise => { + setProcessingTodoIds(prevTodosIds => { + return [...prevTodosIds, todoId]; + }); + + return deleteTodo(todoId) + .then(() => { + setTodos((currentTodos: Todo[]) => + currentTodos.filter((todo: Todo) => todo.id !== todoId), + ); + }) + .catch(() => { + setTodos(todos); + setErrorMessage(ErrorNotification.deleteError); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setProcessingTodoIds([]); + }); + }; + + const handleClearComplete = () => { + const completedTodos = todos.filter((todo: Todo) => todo.completed); + + const deletePromises = completedTodos.map((completedTodo: Todo) => { + return deleteTodo(completedTodo.id); + }); + + Promise.allSettled(deletePromises) + .then(results => { + const successfulDeletes = completedTodos.filter( + (_, index) => results[index].status === 'fulfilled', + ); + + setTodos(currentTodos => + currentTodos.filter( + (todo: Todo) => !successfulDeletes.includes(todo), + ), + ); + + const errorResponse = results.find( + result => result.status === 'rejected', + ); + + if (errorResponse) { + setErrorMessage(ErrorNotification.deleteError); + } + }) + .catch(() => { + setErrorMessage(ErrorNotification.deleteError); + }); + }; + + const handleUpdateComplete = (todo: Todo) => { + const todoCompleted = { ...todo }; + + todoCompleted.completed = !todoCompleted.completed; + setProcessingTodoIds(prevLoadingIds => { + return [...prevLoadingIds, todo.id]; + }); + + updateTodo(todoCompleted) + .then(res => { + setTodos(prevTodos => + prevTodos.map((item: Todo) => (item.id === res.id ? res : item)), + ); + }) + .catch(() => { + setErrorMessage(ErrorNotification.updateError); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setProcessingTodoIds([]); + }); + }; + + const toggleAllTodosStatus = (currentTodos: Todo[]) => { + const activeTodos = currentTodos.filter(todo => !todo.completed); + let changeStatusPromises; + + if (activeTodos.length === 0) { + changeStatusPromises = currentTodos.map((todo: Todo) => { + setProcessingTodoIds(prevTodosIds => { + return [...prevTodosIds, todo.id]; + }); + + return updateTodo({ + ...todo, + completed: false, + }); + }); + } else { + changeStatusPromises = activeTodos.map((todo: Todo) => { + setProcessingTodoIds(prevLoadingIds => { + return [...prevLoadingIds, todo.id]; + }); + + return updateTodo({ + ...todo, + completed: !todo.completed, + }); + }); + } + + Promise.allSettled(changeStatusPromises) + .then(results => { + const successfulUpdates = results + .map((result, index) => { + if (result.status === 'fulfilled' && activeTodos.length === 0) { + return todos[index]; + } else if ( + result.status === 'fulfilled' && + activeTodos.length !== 0 + ) { + return activeTodos[index]; + } else { + return null; + } + }) + .filter(todo => todo !== null); + + setTodos(prevTodos => + prevTodos.map(todo => + successfulUpdates.some(updatedTodo => updatedTodo.id === todo.id) + ? { ...todo, completed: !todo.completed } + : todo, + ), + ); + + const failedUpdates = results.some( + result => result.status === 'rejected', + ); + + if (failedUpdates) { + setErrorMessage(ErrorNotification.updateError); + setTimeout(() => setErrorMessage(''), 3000); + } + }) + .catch(() => { + setErrorMessage(ErrorNotification.updateError); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setProcessingTodoIds([]); + }); + }; + + const handleTitleEdit = ( + newTitle: string, + todoToUpdate: Todo, + ): Promise => { + return new Promise(resolve => { + setProcessingTodoIds(prevTodosIds => { + return [...prevTodosIds, todoToUpdate.id]; + }); + + updateTodo({ + ...todoToUpdate, + title: newTitle, + }) + .then(res => { + setTodos(prevTodos => + prevTodos.map((item: Todo) => (item.id === res.id ? res : item)), + ); + + resolve(true); + }) + .catch(() => { + setErrorMessage(ErrorNotification.updateError); + setTimeout(() => setErrorMessage(''), 3000); + + resolve(false); + }) + .finally(() => { + setProcessingTodoIds([]); + }); + }); + }; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {!!todos.length && ( +
+ )} +
+
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..f3d4593449 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1816; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (title: string) => { + return client.post('/todos', { + userId: USER_ID, + title, + completed: false, + }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (todo: Partial) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..76fe63644a --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; +import { FilterStatus } from '../../types/FilterStatus'; +import { Todo } from '../../types/Todo'; + +type Props = { + filter: string; + setFilter: (status: FilterStatus) => void; + todos: Todo[]; + handleClearComplete: () => void; +}; + +export const Footer: React.FC = ({ + filter, + setFilter, + todos, + handleClearComplete, +}) => { + const activeTodosFiltered = todos.filter((todo: Todo) => !todo.completed); + const completedTodosFiltered = todos.filter((todo: Todo) => todo.completed); + + return ( +
+ + {`${activeTodosFiltered.length} ${activeTodosFiltered.length === 1 ? 'item' : 'items'} left`} + + + +
+ ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..6ecae66fdf --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +type Props = { + addTodo: (title: string) => void; + title: string; + setTitle: (value: string) => void; + todos: Todo[]; + errorMessage: string; + isLoading: boolean; + toggleAllTodosStatus: (todos: Todo[]) => void; +}; + +export const Header: React.FC = ({ + addTodo, + title, + setTitle, + todos, + errorMessage, + isLoading, + toggleAllTodosStatus: toggleAllTodosStatus, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [isLoading, errorMessage, todos]); + + const handleTitleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addTodo(title); + }; + + const areAllCompleted = todos.every((todo: Todo) => todo.completed); + + return ( +
+ {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..bfc72c0537 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,118 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { useState } from 'react'; + +type Props = { + todo: Todo; + deleteSelectTodo: (id: number) => void; + isLoadingById: boolean; + handleUpdateComplete: (todo: Todo) => void; + handleTitleEdit: (title: string, todo: Todo) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + deleteSelectTodo, + isLoadingById, + handleUpdateComplete, + handleTitleEdit, +}) => { + const [editedTitle, setEditedTitle] = useState(todo.title); + const [editingTodoId, setEditingTodoId] = useState(null); + + const handleSaveEditedTitle = async () => { + if (editedTitle === todo.title) { + setEditingTodoId(null); + + return; + } + + if (editedTitle.length === 0) { + deleteSelectTodo(todo.id); + } else if (editingTodoId && editedTitle.trim()) { + try { + const result = await handleTitleEdit(editedTitle.trim(), todo); + + if (result) { + setEditingTodoId(null); + } + } catch (error) { + setEditingTodoId(todo.id); + } + } + }; + + const handleEscapeKey = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setEditedTitle(todo.title); + setEditingTodoId(null); + } + }; + + return ( +
+ + {editingTodoId === todo.id ? ( +
{ + e.preventDefault(); + handleSaveEditedTitle(); + }} + > + setEditedTitle(e.target.value)} + onBlur={handleSaveEditedTitle} + onKeyUp={handleEscapeKey} + autoFocus + /> +
+ ) : ( + <> + setEditingTodoId(todo.id)} + > + {editedTitle.trim()} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..925952b4ab --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,58 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type Props = { + todos: Todo[]; + deleteSelectTodo: (id: number) => void; + tempTodo?: Todo | null; + handleUpdateComplete: (todo: Todo) => void; + selectedTodosIds: number[]; + handleTitleEdit: (title: string, todo: Todo) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + deleteSelectTodo, + tempTodo, + handleUpdateComplete, + selectedTodosIds, + handleTitleEdit, +}) => { + return ( +
+ {todos.map((todo: Todo) => ( + + ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/types/ErrorNotification.ts b/src/types/ErrorNotification.ts new file mode 100644 index 0000000000..edfd04c488 --- /dev/null +++ b/src/types/ErrorNotification.ts @@ -0,0 +1,7 @@ +export enum ErrorNotification { + loadingError = 'Unable to load todos', + titleError = 'Title should not be empty', + addError = 'Unable to add a todo', + deleteError = 'Unable to delete a todo', + updateError = 'Unable to update a todo', +} diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 0000000000..7ca17f289b --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + 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'), +};