diff --git a/README.md b/README.md index 47a1add059..dce71dfe50 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://A1daros.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..da537b9871 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,81 @@ -/* 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, { useState } from 'react'; + import { UserWarning } from './UserWarning'; +import { TodoList } from './components/TodoList'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotificaton'; + +import { FilterStatus } from './types/types'; +import { USER_ID } from './api/todos'; -const USER_ID = 0; +import { useErrorMessage } from './hooks/useErrorMessage'; +import { useTodos } from './hooks/useTodos'; +import { getVisibleTodos } from './utils/todoUtils'; export const App: React.FC = () => { + const [filter, setFilter] = useState(FilterStatus.All); + const [error, setError] = useErrorMessage(); + + const { + todos, + tempTodo, + deletingId, + loadingId, + addTodo, + removeTodo, + renameTodo, + clearCompleted, + toggleAll, + toggleTodo, + isAllCompleted, + } = useTodos(setError); + + const visibleTodos = getVisibleTodos(todos, filter); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
0} + isAllCompleted={isAllCompleted} + onToggleAll={toggleAll} + hasTodos={todos.length > 0} + /> + + {(!!todos.length || tempTodo) && ( + + )} + + {todos.length > 0 && ( +
+ )} +
+ + setError(null)} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..5aa60f523c --- /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 = 4182; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (data: Omit) => { + return client.post('/todos', data); +}; + +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/ErrorNotificaton.tsx b/src/components/ErrorNotificaton.tsx new file mode 100644 index 0000000000..3f0e0bc5f5 --- /dev/null +++ b/src/components/ErrorNotificaton.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import cn from 'classnames'; + +type Props = { + error: string | null; + onErrorClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ error, onErrorClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..23dcf39447 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; +import { FilterStatus } from '../types/types'; +import { FILTERS } from '../constants'; + +type Props = { + todos: Todo[]; + filter: FilterStatus; + onFilterChange: (status: FilterStatus) => void; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + todos, + filter, + onFilterChange, + onClearCompleted, +}) => { + if (todos.length === 0) { + return null; + } + + const activeTodos = todos.filter(todo => !todo.completed).length; + + const handleFilterClick = + (status: FilterStatus) => (event: React.MouseEvent) => { + event.preventDefault(); + onFilterChange(status); + }; + + return ( +
+ + {`${activeTodos} items left`} + + + + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..5bf8fe264f --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; +import { ErrorMessage } from '../types/types'; + +type Props = { + onAdd: (title: string) => Promise; + onError: (message: string) => void; + loading: boolean; + isAllCompleted: boolean; + onToggleAll: () => void; + hasTodos: boolean; +}; + +export const Header: React.FC = ({ + onAdd, + onError, + loading, + isAllCompleted, + onToggleAll, + hasTodos, +}) => { + const [title, setTitle] = useState(''); + const [disabled, setDisabled] = useState(false); + + const inputRef = React.useRef(null); + + useEffect(() => { + if (!loading) { + inputRef.current?.focus(); + } + }, [loading]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + onError(ErrorMessage.EmptyTitle); + + return; + } + + setDisabled(true); + onError(''); + + try { + await onAdd(trimmedTitle); + setTitle(''); + } catch { + } finally { + setDisabled(false); + inputRef.current?.focus(); + } + }; + + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..e16b8dc6b4 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; + +type Props = { + todo: Todo; + isDeleting?: boolean; + isLoading?: boolean; + onDelete?: (id: number) => void; + onToggle?: (id: number) => void; + onRename?: (id: number, title: string) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isDeleting = false, + isLoading = false, + onDelete, + onToggle, + onRename, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + const editInputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + } + }, [isEditing]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDelete?.(todo.id); + + return; + } + + try { + await onRename?.(todo.id, trimmedTitle); + setIsEditing(false); + } catch {} + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setNewTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + {!isLoading && ( + + )} + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..efb5760348 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + deletingId: number[]; + loadingId: number[]; + onDelete: (id: number) => void; + onToggle: (id: number) => void; + onRename: (id: number, title: string) => void; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + deletingId, + loadingId, + onDelete, + onToggle, + onRename, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && } +
+ ); +}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000000..839f21b124 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,22 @@ +import { FilterStatus } from './types/types'; + +export const FILTERS = [ + { + id: 'all' as FilterStatus, + title: 'All', + url: '#/', + cy: 'FilterLinkAll', + }, + { + id: 'active' as FilterStatus, + title: 'Active', + url: '#/active', + cy: 'FilterLinkActive', + }, + { + id: 'completed' as FilterStatus, + title: 'Completed', + url: '#/completed', + cy: 'FilterLinkCompleted', + }, +]; diff --git a/src/hooks/useErrorMessage.ts b/src/hooks/useErrorMessage.ts new file mode 100644 index 0000000000..69ac9353cf --- /dev/null +++ b/src/hooks/useErrorMessage.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useErrorMessage = (delay = 3000) => { + const [error, setError] = useState(null); + + useEffect(() => { + if (!error) { + return; + } + + const timer = setTimeout(() => setError(null), delay); + + return () => clearTimeout(timer); + }, [error, delay]); + + return [error, setError] as const; +}; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 0000000000..92ab9bfc4f --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { ErrorMessage } from '../types/types'; +import { + createTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from '../api/todos'; + +export const useTodos = (onError: (message: string) => void) => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [deletingId, setDeletingId] = useState([]); + const [loadingId, setLoadingId] = useState([]); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => onError(ErrorMessage.LoadTodo)); + }, [onError]); + + const addTodo = async (title: string) => { + const trimmedTitle = title.trim(); + + setTempTodo({ + id: 0, + title: trimmedTitle, + completed: false, + userId: USER_ID, + }); + + try { + const newTodo = await createTodo({ + title: trimmedTitle, + completed: false, + userId: USER_ID, + }); + + setTodos(prev => [...prev, newTodo]); + } catch { + onError(ErrorMessage.AddTodo); + throw new Error(); + } finally { + setTempTodo(null); + } + }; + + const removeTodo = async (todoId: number) => { + setDeletingId(prev => [...prev, todoId]); + + try { + await deleteTodo(todoId); + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch { + onError(ErrorMessage.DeleteTodo); + } finally { + setDeletingId(prev => prev.filter(id => id !== todoId)); + } + }; + + const renameTodo = async (todoId: number, title: string) => { + setLoadingId(prev => [...prev, todoId]); + + try { + await updateTodo(todoId, { title }); + setTodos(prev => + prev.map(todo => (todo.id === todoId ? { ...todo, title } : todo)), + ); + } catch { + onError(ErrorMessage.UpdateTodo); + throw new Error(); + } finally { + setLoadingId(prev => prev.filter(id => id !== todoId)); + } + }; + + const clearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + await Promise.all(completedTodos.map(todo => removeTodo(todo.id))); + }; + + const isAllCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + + const toggleAll = async () => { + const areAllCompleted = todos.every(todo => todo.completed); + const targetStatus = !areAllCompleted; + const todosToUpdate = todos.filter(todo => todo.completed !== targetStatus); + + const idToUpdate = todosToUpdate.map(todo => todo.id); + + setLoadingId(prev => [...prev, ...idToUpdate]); + + try { + await Promise.all( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed: targetStatus }), + ), + ); + setTodos(prev => + prev.map(todo => + idToUpdate.includes(todo.id) + ? { ...todo, completed: targetStatus } + : todo, + ), + ); + } catch { + onError(ErrorMessage.UpdateTodo); + } finally { + setLoadingId(prev => prev.filter(id => !idToUpdate.includes(id))); + } + }; + + const toggleTodo = async (todoId: number) => { + const todo = todos.find(TODO => TODO.id === todoId); + + if (!todo) { + return; + } + + const newCompleted = !todo.completed; + + setLoadingId(prev => [...prev, todo.id]); + + try { + await updateTodo(todo.id, { completed: newCompleted }); + setTodos(prev => + prev.map(t => + t.id === todo.id ? { ...t, completed: newCompleted } : t, + ), + ); + } catch { + onError(ErrorMessage.UpdateTodo); + } finally { + setLoadingId(prev => prev.filter(id => id !== todo.id)); + } + }; + + return { + todos, + tempTodo, + deletingId, + isAllCompleted, + loadingId, + addTodo, + removeTodo, + clearCompleted, + toggleAll, + toggleTodo, + renameTodo, + setTodos, + }; +}; diff --git a/src/styles/todo.scss b/src/styles/todo.scss index c7f93ff6b9..b26146533c 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -15,13 +15,13 @@ &__status-label { cursor: pointer; - background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: center left; } &.completed &__status-label { - background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E"); + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); } &__status { @@ -40,6 +40,14 @@ text-decoration: line-through; } + &__edit { + width: 100%; + padding: 10px; + font-size: inherit; + line-height: inherit; + box-sizing: border-box; + } + &__remove { position: absolute; right: 12px; 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/types.ts b/src/types/types.ts new file mode 100644 index 0000000000..719a0780a9 --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,13 @@ +export enum FilterStatus { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +export enum ErrorMessage { + LoadTodo = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + AddTodo = 'Unable to add a todo', + DeleteTodo = 'Unable to delete a todo', + UpdateTodo = 'Unable to update a todo', +} 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'), +}; diff --git a/src/utils/todoUtils.ts b/src/utils/todoUtils.ts new file mode 100644 index 0000000000..4f6f0842b9 --- /dev/null +++ b/src/utils/todoUtils.ts @@ -0,0 +1,13 @@ +import { Todo } from '../types/Todo'; +import { FilterStatus } from '../types/types'; + +export const getVisibleTodos = (todos: Todo[], filter: FilterStatus) => { + switch (filter) { + case FilterStatus.Active: + return todos.filter(todo => !todo.completed); + case FilterStatus.Completed: + return todos.filter(todo => todo.completed); + default: + return todos; + } +};