diff --git a/README.md b/README.md index d3c3756ab9..c4ad68b56f 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://OkMoroz.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..5260689a8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,186 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +import React, { useState, useEffect } from 'react'; +import { addTodo, deleteTodo, getTodos, updateTodo } from './api/todos'; -const USER_ID = 0; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { Errors } from './components/Errors'; + +import { loadingObject } from './utils/loadingObject'; +import { filteredTodos } from './utils/filteredTodos'; + +import { Todo } from './types/Todo'; +import { ErrorMessage } from './types/enum/Errors'; +import { Loading } from './types/Loading'; +import { Filters } from './types/enum/Filters'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState( + ErrorMessage.Default, + ); + const [tempTodo, setTempTodo] = useState(null); + const [filter, setFilter] = useState(Filters.All); + const [loadingId, setLoadingId] = useState({}); + + useEffect(() => { + const timeoutId = setTimeout( + () => setErrorMessage(ErrorMessage.Default), + 3000, + ); + + getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToLoad); + clearTimeout(timeoutId); + }); + + return () => clearTimeout(timeoutId); + }, []); + + const handleAdd = (newTodo: Todo): Promise => { + setTempTodo(newTodo); + + return addTodo(newTodo).then(newTodoRes => { + setTodos(currentTodos => [...currentTodos, newTodoRes]); + }); + }; + + const updateCompleted = ( + updatedTodo: Todo, + key: keyof Todo, + value: boolean | string, + ) => { + return updateTodo({ ...updatedTodo, [key]: value }) + .then((updatedTodoFromServer: Todo) => { + setTodos(currentTodos => { + return currentTodos.map(todo => + todo.id === updatedTodo.id ? updatedTodoFromServer : todo, + ); + }); + + return false; + }) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToUpdate); + + return true; + }); + }; + + const handleToggleAll = () => { + const activeTodos = todos.filter(todo => !todo.completed); + const activeTodosIds = loadingObject(activeTodos); + + if (activeTodos.length) { + setLoadingId(activeTodosIds); + + Promise.all( + activeTodos.map(todo => updateTodo({ ...todo, completed: true })), + ) + .then(() => + setTodos(currentTodos => { + return currentTodos.map(todo => { + if (Object.hasOwn(activeTodosIds, todo.id)) { + return { ...todo, completed: true }; + } else { + return todo; + } + }); + }), + ) + .catch(() => setErrorMessage(ErrorMessage.UnableToUpdate)) + .finally(() => setLoadingId({})); + + return; + } + + setLoadingId(loadingObject(todos)); + Promise.all(todos.map(todo => updateTodo({ ...todo, completed: false }))) + .then(() => + setTodos(prevTodos => { + return prevTodos.map(todo => ({ ...todo, completed: false })); + }), + ) + .catch(() => setErrorMessage(ErrorMessage.UnableToUpdate)) + .finally(() => setLoadingId({})); + }; + + const handleDeleteCompleted = () => { + const completedTodos = todos.filter(todo => todo.completed); + + setLoadingId(loadingObject(completedTodos)); + + Promise.allSettled( + completedTodos.map(todo => deleteTodo(todo.id).then(() => todo)), + ) + .then(values => { + values.map(val => { + if (val.status === 'rejected') { + setErrorMessage(ErrorMessage.UnableToDelete); + } else { + setTodos(currentTodos => { + const todoId = val.value as Todo; + + return currentTodos.filter(todo => todo.id !== todoId.id); + }); + } + }); + }) + .finally(() => setLoadingId({})); + }; + + const handleDelete = (todoId: number): Promise => { + return deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToDelete); + }) + .finally(() => setTempTodo(null)); + }; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {!!todos.length && ( +
+ )} +
+ + setErrorMessage(ErrorMessage.Default)} + /> +
); }; diff --git a/src/api/constants.ts b/src/api/constants.ts new file mode 100644 index 0000000000..408473d637 --- /dev/null +++ b/src/api/constants.ts @@ -0,0 +1,2 @@ +export const USER_ID = 2132; +export const TODOS_ENDPOINT = '/todos'; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..b03020c46c --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,19 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; +import { USER_ID, TODOS_ENDPOINT } from './constants'; + +export const getTodos = () => { + return client.get(`${TODOS_ENDPOINT}?userId=${USER_ID}`); +}; + +export const addTodo = (data: Omit) => { + return client.post(TODOS_ENDPOINT, data); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`${TODOS_ENDPOINT}/${todoId}`); +}; + +export const updateTodo = ({ id, ...todo }: Todo) => { + return client.patch(`${TODOS_ENDPOINT}/${id}`, todo); +}; diff --git a/src/components/Errors.tsx b/src/components/Errors.tsx new file mode 100644 index 0000000000..a7342f7f5b --- /dev/null +++ b/src/components/Errors.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import cn from 'classnames'; + +type Props = { + message: string; + clearError: () => void; +}; + +export const Errors: React.FC = props => { + const { message, clearError } = props; + + useEffect(() => { + const timeOut = setTimeout(clearError, 3000); + + return () => { + clearTimeout(timeOut); + }; + }, [message]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..ed35f8f9ea --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import cn from 'classnames'; +import { Filters } from '../types/enum/Filters'; +import { Todo } from '../types/Todo'; +import { Loading } from '../types/Loading'; + +type Props = { + todos: Todo[]; + selectedFilter: Filters; + onFilteredStatus: (filter: Filters) => void; + onDeleteCompleted: () => void; + setLoadingId: React.Dispatch>; +}; + +export const Footer: React.FC = props => { + const { + todos, + selectedFilter, + onFilteredStatus, + onDeleteCompleted, + setLoadingId, + } = props; + // const [isLoading, setIsLoading] = useState(false); + + const filtersValue = useMemo(() => Object.values(Filters), []); + const activeTodosCount = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + const isCompleted = useMemo( + () => todos.some(todo => todo.completed), + [todos], + ); + let isDeleteCompleted = false; + + const handleDeleteCompleted = () => { + setLoadingId({}); + isDeleteCompleted = true; + onDeleteCompleted(); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..29a84a640a --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect, useRef } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { ErrorMessage } from '../types/enum/Errors'; +import { USER_ID } from '../api/constants'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + onToggleAll: () => void; + onChangeTempTodo: (tempdo: Todo | null) => void; + setErrorMessage: (message: ErrorMessage) => void; + onSubmit: (todo: Todo) => Promise; +}; + +export const Header: React.FC = props => { + const { + tempTodo, + todos, + onToggleAll, + onChangeTempTodo, + setErrorMessage, + onSubmit, + } = props; + + const [value, setValue] = useState(''); + const isAllCompleted = todos.every(todo => todo.completed); + const titleField = useRef(null); + + useEffect(() => { + if (titleField.current && tempTodo === null) { + titleField.current.focus(); + } + }, [tempTodo, todos.length]); + + const addingTodo = (event: React.FormEvent) => { + event.preventDefault(); + + if (!value.trim()) { + setErrorMessage(ErrorMessage.EmptyTitle); + + return; + } + + const newTodo = { + id: 0, + userId: USER_ID, + title: value.trim(), + completed: false, + }; + + let todoCreatedSuccessfully = true; + + onSubmit(newTodo) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToAdd); + todoCreatedSuccessfully = false; + }) + .finally(() => { + if (todoCreatedSuccessfully) { + setValue(''); + } + + onChangeTempTodo(null); + }); + }; + + return ( + <> +
+ {!!todos.length && ( +
+ + ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..21bbe71bc7 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,145 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState, useEffect, useRef } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { Loading } from '../types/Loading'; + +enum TodoKey { + Title = 'title', + Completed = 'completed', +} + +type Props = { + todo: Todo; + loadingId: Loading; + onEdit: ( + todo: Todo, + key: keyof Todo, + value: boolean | string, + ) => Promise; + onDelete: (todoID: number) => Promise; +}; + +export const TodoItem: React.FC = props => { + const { todo, loadingId, onEdit, onDelete } = props; + + const [isEditing, setIsEditing] = useState(false); + const [isLoading, setLoading] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const titleForm = useRef(null); + const isActive = + Object.hasOwn(loadingId, todo.id) || isLoading || todo.id === 0; + + useEffect(() => { + if (titleForm.current && isEditing) { + titleForm.current.focus(); + } + }, [isEditing]); + + const handleDelete = () => { + setLoading(true); + + onDelete(todo.id).finally(() => setLoading(false)); + }; + + const handleEdit = () => { + setLoading(true); + + onEdit(todo, TodoKey.Completed, !todo.completed).finally(() => + setLoading(false), + ); + }; + + const handleSubmitForm = () => { + if (newTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!newTitle.trim()) { + handleDelete(); + + return; + } + + setLoading(true); + onEdit(todo, TodoKey.Title, newTitle.trim()) + .then(res => setIsEditing(res)) + .finally(() => setLoading(false)); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + } + }; + + return ( +
+ + + {isEditing ? ( +
key === 'Escape' && setIsEditing(false)}> +
{ + event.preventDefault(); + handleSubmitForm(); + }} + > + setNewTitle(event.target.value)} + onBlur={handleSubmitForm} + onKeyUp={handleKeyUp} + /> +
+
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..81d6fef25c --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,45 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { TodoItem } from '../components/TodoItem'; +import { Todo } from '../types/Todo'; +import { Loading } from '../types/Loading'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + loadingId: Loading; + onEdit: ( + todo: Todo, + key: keyof Todo, + value: boolean | string, + ) => Promise; + onDelete: (todoID: number) => Promise; +}; + +export const TodoList: React.FC = props => { + const { todos, tempTodo, loadingId, onEdit, onDelete } = props; + + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/types/Loading.ts b/src/types/Loading.ts new file mode 100644 index 0000000000..1d568954d5 --- /dev/null +++ b/src/types/Loading.ts @@ -0,0 +1,3 @@ +export interface Loading { + [key: number]: number; +} 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/enum/Errors.ts b/src/types/enum/Errors.ts new file mode 100644 index 0000000000..db21566cf7 --- /dev/null +++ b/src/types/enum/Errors.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + Default = '', + UnableToLoad = 'Unable to load todos', + EmptyTitle = '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/enum/Filters.ts b/src/types/enum/Filters.ts new file mode 100644 index 0000000000..6c26945612 --- /dev/null +++ b/src/types/enum/Filters.ts @@ -0,0 +1,5 @@ +export enum Filters { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/enum/Status.ts b/src/types/enum/Status.ts new file mode 100644 index 0000000000..30ad355656 --- /dev/null +++ b/src/types/enum/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + Active = 'Active', + Completed = 'Completed', + All = 'All', +} 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'), +}; diff --git a/src/utils/filteredClent.ts b/src/utils/filteredClent.ts new file mode 100644 index 0000000000..0c30f35afd --- /dev/null +++ b/src/utils/filteredClent.ts @@ -0,0 +1,18 @@ +import { Filters } from '../types/enum/Filters'; +import { Todo } from '../types/Todo'; + +export const filteredTodos = (todos: Todo[], filter: Filters) => { + const filterTodos = [...todos]; + + switch (filter) { + case Filters.Active: + return filterTodos.filter(todo => !todo.completed); + + case Filters.Completed: + return filterTodos.filter(todo => todo.completed); + + case Filters.All: + default: + return filterTodos; + } +}; diff --git a/src/utils/filteredTodos.ts b/src/utils/filteredTodos.ts new file mode 100644 index 0000000000..0c30f35afd --- /dev/null +++ b/src/utils/filteredTodos.ts @@ -0,0 +1,18 @@ +import { Filters } from '../types/enum/Filters'; +import { Todo } from '../types/Todo'; + +export const filteredTodos = (todos: Todo[], filter: Filters) => { + const filterTodos = [...todos]; + + switch (filter) { + case Filters.Active: + return filterTodos.filter(todo => !todo.completed); + + case Filters.Completed: + return filterTodos.filter(todo => todo.completed); + + case Filters.All: + default: + return filterTodos; + } +}; diff --git a/src/utils/loadingObject.ts b/src/utils/loadingObject.ts new file mode 100644 index 0000000000..d9ec8c9a5b --- /dev/null +++ b/src/utils/loadingObject.ts @@ -0,0 +1,11 @@ +import { Loading } from '../types/Loading'; +import { Todo } from '../types/Todo'; + +export const loadingObject = (todos: Todo[]): Loading => { + return todos.reduce((acc: Loading, todo: Todo): Loading => { + return { + ...acc, + [todo.id]: todo.id, + }; + }, {} as Loading); +};