diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..63f2a8cd11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,252 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + USER_ID, + getTodos, + addTodo, + deleteTodo, + updateTodo, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +import { ErrorMessage } from './types/ErrorMessage'; +import { TodoList } from './components/TodoList'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(Status.All); + const [errorMessage, setErrorMessage] = useState(''); + + const [title, setTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [processingIds, setProcessingIds] = useState([]); + + const titleField = useRef(null); + + const showError = (message: string) => { + setErrorMessage(message); + setTimeout(() => { + setErrorMessage(''); + }, 3000); + }; + + useEffect(() => { + if (!USER_ID) { + return; + } + + getTodos() + .then(setTodos) + .catch(() => showError(ErrorMessage.Load)); + }, []); + + useEffect(() => { + if (titleField.current) { + titleField.current.focus(); + } + }, [todos.length]); + + const handleAddTodo = (e: React.FormEvent) => { + e.preventDefault(); + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + showError(ErrorMessage.EmptyTitle); + + return; + } + + setTempTodo({ + id: 0, + title: normalizedTitle, + completed: false, + userId: USER_ID, + }); + + addTodo(normalizedTitle) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setTitle(''); + }) + .catch(() => { + showError(ErrorMessage.Add); + }) + .finally(() => { + setTempTodo(null); + setTimeout(() => { + titleField.current?.focus(); + }, 0); + }); + }; + + const handleUpdateTodo = (todoId: number, data: Partial) => { + setProcessingIds(current => [...current, todoId]); + + return updateTodo(todoId, data) + .then(updatedTodo => { + setTodos(current => + current.map(todo => (todo.id === todoId ? updatedTodo : todo)), + ); + }) + .catch(() => { + showError(ErrorMessage.Update); + throw new Error('Update failed'); + }) + .finally(() => { + setProcessingIds(current => current.filter(id => id !== todoId)); + }); + }; + + const handleToggleAll = () => { + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const shouldComplete = activeTodosCount > 0; + + const todosToUpdate = todos.filter( + todo => todo.completed !== shouldComplete, + ); + const idsToUpdate = todosToUpdate.map(todo => todo.id); + + setProcessingIds(current => [...current, ...idsToUpdate]); + + Promise.all( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed: shouldComplete }) + .then(updatedTodo => updatedTodo) + .catch(() => { + showError(ErrorMessage.Update); + + return null; + }), + ), + ) + .then(results => { + const successfulUpdates = results.filter((r): r is Todo => r !== null); + + setTodos(current => + current.map(todo => { + const updated = successfulUpdates.find(u => u.id === todo.id); + + return updated || todo; + }), + ); + }) + .finally(() => { + setProcessingIds(current => + current.filter(id => !idsToUpdate.includes(id)), + ); + }); + }; + + const handleDeleteTodo = (todoId: number) => { + setProcessingIds(current => [...current, todoId]); + + deleteTodo(todoId) + .then(() => { + setTodos(current => current.filter(todo => todo.id !== todoId)); + }) + .catch(() => { + showError(ErrorMessage.Delete); + }) + .finally(() => { + setProcessingIds(current => current.filter(id => id !== todoId)); + }); + }; + + const handleClearCompleted = () => { + const completedTodos = todos.filter(todo => todo.completed); + const idsToDelete = completedTodos.map(todo => todo.id); + + setProcessingIds(current => [...current, ...idsToDelete]); + + Promise.all( + completedTodos.map(todo => + deleteTodo(todo.id) + .then(() => todo.id) + .catch(() => { + showError(ErrorMessage.Delete); + + return null; + }), + ), + ) + .then(results => { + const successfulIds = results.filter(id => id !== null); + + setTodos(current => + current.filter(todo => !successfulIds.includes(todo.id)), + ); + }) + .finally(() => { + setProcessingIds(current => + current.filter(id => !idsToDelete.includes(id)), + ); + }); + }; + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case Status.Active: + return !todo.completed; + case Status.Completed: + return todo.completed; + case Status.All: + default: + return true; + } + }); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = todos.some(todo => todo.completed); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(todos.length > 0 || tempTodo !== null) && ( + + )} + + {todos.length > 0 && ( +
+ )} +
+ + setErrorMessage('')} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..285b030e60 --- /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 = 4213; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (title: string) => { + return client.post('/todos', { + title, + completed: false, + userId: USER_ID, + }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, data: Partial) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..e5d28892d3 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + onClose, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 0000000000..d028679409 --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Status } from '../types/Status'; + +type Props = { + filter: Status; + onFilterChange: (status: Status) => void; +}; + +export const Filter: React.FC = ({ filter, onFilterChange }) => { + return ( + + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..279805c884 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Filter } from './Filter'; +import { Status } from '../types/Status'; + +type Props = { + activeTodosCount: number; + filter: Status; + onFilterChange: (status: Status) => void; + hasCompletedTodos: boolean; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + activeTodosCount, + filter, + onFilterChange, + hasCompletedTodos, + onClearCompleted, +}) => { + return ( +
+ + {activeTodosCount} items left + + + + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..ab9d0d8a51 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + title: string; + setTitle: (value: string) => void; + onAddTodo: (e: React.FormEvent) => void; + activeTodosCount: number; + todosLength: number; + tempTodo: Todo | null; + titleField: React.RefObject; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + title, + setTitle, + onAddTodo, + activeTodosCount, + todosLength, + tempTodo, + titleField, + onToggleAll, +}) => { + return ( +
+ {todosLength > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..a18f8b9b6f --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,129 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + isLoading?: boolean; + onDelete?: (id: number) => void; + onUpdate?: (id: number, data: Partial) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onDelete, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + const editField = useRef(null); + + useEffect(() => { + if (isEditing && editField.current) { + editField.current.focus(); + } + }, [isEditing]); + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault(); + + if (isLoading) { + return; + } + + const normalizedTitle = newTitle.trim(); + + if (normalizedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!normalizedTitle) { + onDelete?.(todo.id); + + return; + } + + onUpdate?.(todo.id, { title: normalizedTitle }) + ?.then(() => { + setIsEditing(false); + }) + ?.catch(() => { + editField.current?.focus(); + }); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + setNewTitle(todo.title); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + setIsEditing(true)} + > + {todo.title} + + )} + + {!isEditing && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..7058ddcc8c --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + onDelete?: (id: number) => void; + onUpdate?: (id: number, data: Partial) => Promise; + processingIds?: number[]; + tempTodo?: Todo | null; +}; + +export const TodoList: React.FC = ({ + todos, + onDelete, + onUpdate, + processingIds = [], + tempTodo, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && } +
+ ); +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..4d4347ca85 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + Load = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..2ec4b87141 --- /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..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'), +};