From c94bd371238019a56664b7ca22428c309e2ba86f Mon Sep 17 00:00:00 2001 From: Vadim Zhuk Date: Thu, 9 Jan 2025 16:26:31 +0200 Subject: [PATCH] add task solution --- src/App.tsx | 224 +++++++++++++++++++++++++++++++--- src/Components/Error.tsx | 41 +++++++ src/Components/TodoFooter.tsx | 54 ++++++++ src/Components/TodoHeader.tsx | 65 ++++++++++ src/Components/TodoItem.tsx | 127 +++++++++++++++++++ src/api/todos.ts | 24 ++++ src/styles/index.scss | 53 +++++++- src/types/Todo.ts | 6 + src/types/TodoErrorType.ts | 8 ++ src/types/TodoFilter.ts | 5 + src/utils/fetchClient.ts | 46 +++++++ 11 files changed, 635 insertions(+), 18 deletions(-) create mode 100644 src/Components/Error.tsx create mode 100644 src/Components/TodoFooter.tsx create mode 100644 src/Components/TodoHeader.tsx create mode 100644 src/Components/TodoItem.tsx create mode 100644 src/api/todos.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodoErrorType.ts create mode 100644 src/types/TodoFilter.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..6d26ca2715 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,220 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useState, useMemo, useRef } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + USER_ID, + getTodos, + createTodo, + deleteTodo, + updateTodo, +} from './api/todos'; +import { TodoHeader } from './Components/TodoHeader'; +import { TodoFooter } from './Components/TodoFooter'; +import { Todo } from './types/Todo'; +import { TodoFilter } from './types/TodoFilter'; +import { TodoItem } from './Components/TodoItem'; +import { Error } from './Components/Error'; +import { TodoErrorType } from './types/TodoErrorType'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; export const App: React.FC = () => { + const [currentFilter, setCurrentFilter] = useState( + TodoFilter.All, + ); + const [todoList, setTodoList] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [todoError, setTodoError] = useState(TodoErrorType.None); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [isAddingLoading, setIsAddingLoading] = useState(false); + + const todoTitleRef = useRef(null); + + const uncompletedTodos = useMemo( + () => todoList.filter(todo => !todo.completed), + [todoList], + ); + + const completedTodos = useMemo( + () => todoList.filter(todo => todo.completed), + [todoList], + ); + + const isAllTodoCompleted = useMemo(() => { + return completedTodos.length === todoList.length; + }, [completedTodos, todoList]); + + const filteredTodos = useMemo(() => { + switch (currentFilter) { + case TodoFilter.Active: + return uncompletedTodos; + case TodoFilter.Completed: + return completedTodos; + default: + return todoList; + } + }, [completedTodos, currentFilter, todoList, uncompletedTodos]); + + const handleAddTodo = async (title: string): Promise => { + if (!title) { + setTodoError(TodoErrorType.EmptyTitle); + + return false; + } + + setTempTodo({ + id: 0, + title, + userId: USER_ID, + completed: false, + }); + + setIsAddingLoading(true); + try { + const createdTodo = await createTodo(title); + + setTodoList(prevTodoList => [...prevTodoList, createdTodo]); + + return true; + } catch (error) { + setTodoError(TodoErrorType.UnableToAddTodo); + + return false; + } finally { + setTempTodo(null); + setIsAddingLoading(false); + } + }; + + const handleUpdateTodo = async (todoToUpdate: Todo): Promise => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + + try { + const updatedTodo = await updateTodo(todoToUpdate); + + setTodoList(prevTodoList => + prevTodoList.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ), + ); + + return true; + } catch (error) { + setTodoError(TodoErrorType.UnableToUpdateTodo); + + return false; + } finally { + setLoadingTodoIds(prev => + prev.filter(todoId => todoId !== todoToUpdate.id), + ); + } + }; + + const handleDeleteTodo = async (id: number): Promise => { + setLoadingTodoIds(prev => [...prev, id]); + + try { + await deleteTodo(id); + setTodoList(prevTodoList => prevTodoList.filter(todo => todo.id !== id)); + todoTitleRef.current?.focus(); + + return true; + } catch (error) { + setTodoError(TodoErrorType.UnableToDeleteTodo); + + return false; + } finally { + setLoadingTodoIds(prev => prev.filter(todoId => todoId !== id)); + } + }; + + const handleDeleteCompletedTodos = () => { + completedTodos.forEach(todo => handleDeleteTodo(todo.id)); + }; + + const handleToggleAllTodos = () => { + if (uncompletedTodos.length > 0) { + uncompletedTodos.forEach(todo => { + handleUpdateTodo({ ...todo, completed: true }); + }); + } else { + todoList.forEach(todo => { + handleUpdateTodo({ ...todo, completed: false }); + }); + } + }; + + useEffect(() => { + const loadTodosFromServer = async () => { + try { + const todosFromServer = await getTodos(); + + setTodoList(todosFromServer); + } catch (error) { + setTodoError(TodoErrorType.UnableToLoadTodos); + } + }; + + loadTodosFromServer(); + }, []); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + +
+ + {filteredTodos.map(todo => ( + + + + ))} + + {tempTodo && ( + + + + )} + +
+ + {!!todoList.length && ( + + )} +
+ + +
); }; diff --git a/src/Components/Error.tsx b/src/Components/Error.tsx new file mode 100644 index 0000000000..5758ad129f --- /dev/null +++ b/src/Components/Error.tsx @@ -0,0 +1,41 @@ +import React, { Dispatch, SetStateAction, useEffect } from 'react'; +import cn from 'classnames'; +import { TodoErrorType } from '../types/TodoErrorType'; + +type Props = { + error: TodoErrorType; + setError: Dispatch>; +}; + +export const Error: React.FC = ({ error, setError }) => { + useEffect(() => { + if (!error) { + return; + } + + const timerId = setTimeout(() => { + setError(TodoErrorType.None); + }, 3000); + + return () => { + clearTimeout(timerId); + }; + }, [error, setError]); + + return ( +
+
+ ); +}; diff --git a/src/Components/TodoFooter.tsx b/src/Components/TodoFooter.tsx new file mode 100644 index 0000000000..2bb06b6b5a --- /dev/null +++ b/src/Components/TodoFooter.tsx @@ -0,0 +1,54 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import cn from 'classnames'; +import { TodoFilter } from '../types/TodoFilter'; + +type Props = { + currentFilter: TodoFilter; + onFilterChange: Dispatch>; + todosLeft: number; + completedTodos: number; + onDeleteCompletedTodos: () => void; +}; + +export const TodoFooter: React.FC = ({ + currentFilter, + onFilterChange, + todosLeft, + completedTodos, + onDeleteCompletedTodos, +}) => { + return ( +
+ + {todosLeft} items left + + + {/* Active link should have the 'selected' class */} + + + +
+ ); +}; diff --git a/src/Components/TodoHeader.tsx b/src/Components/TodoHeader.tsx new file mode 100644 index 0000000000..e032676cd0 --- /dev/null +++ b/src/Components/TodoHeader.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react'; +import cn from 'classnames'; + +type Props = { + todoTitleRef: React.RefObject | null; + onAddTodo: (title: string) => Promise; + onToggleAllTodos: () => void; + isLoading: boolean; + isAllTodoCompleted: boolean; + isTodoListNotEmpty: boolean; +}; + +export const TodoHeader: React.FC = ({ + todoTitleRef, + onAddTodo, + onToggleAllTodos, + isLoading, + isAllTodoCompleted, + isTodoListNotEmpty, +}) => { + const [todoTitle, setTodoTitle] = useState(''); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = todoTitle.trim(); + + const success = await onAddTodo(trimmedTitle); + + if (success) { + setTodoTitle(''); + } + }; + + useEffect(() => { + if (!isLoading) { + todoTitleRef?.current?.focus(); + } + }, [todoTitleRef, isLoading]); + + return ( +
+ {isTodoListNotEmpty && ( +
+ ); +}; diff --git a/src/Components/TodoItem.tsx b/src/Components/TodoItem.tsx new file mode 100644 index 0000000000..e071e4eb3e --- /dev/null +++ b/src/Components/TodoItem.tsx @@ -0,0 +1,127 @@ +import React, { useState, useRef } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + onUpdateTodo: (todo: Todo) => Promise; + onDeleteTodo: (id: number) => Promise; + isLoading: boolean; +}; + +export const TodoItem: React.FC = ({ + todo, + onUpdateTodo, + onDeleteTodo, + isLoading, +}) => { + const { id, completed, title } = todo; + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(title); + + const editTitleRef = useRef(null); + + const handleEditTodo = async () => { + const trimmedTitle = editTitle.trim(); + + if (todo.title === trimmedTitle) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + const success = await onDeleteTodo(todo.id); + + if (!success) { + editTitleRef.current?.focus(); + } + + return; + } + + const success = await onUpdateTodo({ ...todo, title: trimmedTitle }); + + if (success) { + setIsEditing(false); + } else { + editTitleRef.current?.focus(); + } + }; + + const handleToggleTodoStatus = () => { + const todoToUpdate = { ...todo, completed: !todo.completed }; + + onUpdateTodo(todoToUpdate); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleEditTodo(); + } + + if (event.key === 'Escape') { + setEditTitle(title); + setIsEditing(false); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {isEditing ? ( + setEditTitle(event.target.value)} + onBlur={handleEditTodo} + onKeyUp={handleKeyUp} + ref={editTitleRef} + autoFocus + /> + ) : ( + <> + setIsEditing(true)} + > + {title} + + + + )} +
+
+
+
+
+ ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..f17580b418 --- /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 = 1794; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (title: string) => { + return client.post(`/todos`, { + title: title, + userId: USER_ID, + completed: false, + }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..a5997bb111 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,53 @@ body { pointer-events: none; } -@import "./todoapp"; -@import "./todo"; -@import "./filter"; +@import './todoapp'; +@import './todo'; +@import './filter'; + +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} 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/types/TodoErrorType.ts b/src/types/TodoErrorType.ts new file mode 100644 index 0000000000..4b1d4620e7 --- /dev/null +++ b/src/types/TodoErrorType.ts @@ -0,0 +1,8 @@ +export enum TodoErrorType { + UnableToLoadTodos = 'Unable to load todos', + UnableToAddTodo = 'Unable to add a todo', + UnableToDeleteTodo = 'Unable to delete a todo', + UnableToUpdateTodo = 'Unable to update a todo', + EmptyTitle = 'Title should not be empty', + None = '', +} diff --git a/src/types/TodoFilter.ts b/src/types/TodoFilter.ts new file mode 100644 index 0000000000..6b7e03b793 --- /dev/null +++ b/src/types/TodoFilter.ts @@ -0,0 +1,5 @@ +export enum TodoFilter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} 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'), +};