diff --git a/README.md b/README.md index 8f93235bf6..b7e41e003e 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,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://taras-bilas.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 5749bdf784..21da353e4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,176 @@ -/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { TodoItem } from './components/TodoItem'; +import { TodoFooter } from './components/TodoFooter'; +import { deleteTodos, getTodos, updateTodos } from './api/todos'; +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +import { Error } from './types/Error'; +import { Errors } from './components/Errors'; -const USER_ID = 0; +const USER_ID = 11142; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [status, setStatus] = useState(Status.all); + const [error, setError] = useState(Error.without); + const [tempTodo, setTempTodo] = useState(null); + const [deletingIds, setDeletingIds] = useState([]); + const [processing, setProcessing] = useState(false); + const numberOfAllTodos = todos.length; + const numberOfCompletedTodos = todos.filter(todo => todo.completed).length; + const numberOfActiveTodos = todos.filter(todo => !todo.completed).length; + const areAllCompleted = todos.every(todo => todo.completed); + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch(() => setError(Error.load)); + }, []); + + const toggleTodo = useCallback((todo: Todo) => { + const changedStatus = { completed: !todo.completed }; + + setDeletingIds((ids) => [...ids, todo.id]); + + updateTodos(todo.id, changedStatus) + .then(() => { + return getTodos(USER_ID) + .then(setTodos) + .catch(() => setError(Error.load)); + }) + .catch(() => setError(Error.update)) + .finally(() => { + setDeletingIds((ids) => ids.filter(id => id !== todo.id)); + }); + }, [todos]); + + const toggleAll = () => { + if (areAllCompleted) { + todos.forEach(todo => { + toggleTodo(todo); + }); + } + + todos + .filter(todo => !todo.completed) + .forEach(todo => toggleTodo(todo)); + }; + + const renameTodo = useCallback((todoId: number, newTitle: string) => { + if (newTitle.trim().length === 0) { + setError(Error.update); + + return; + } + + const newData = { title: newTitle }; + + setDeletingIds((ids) => [...ids, todoId]); + + updateTodos(todoId, newData) + .then(() => { + return getTodos(USER_ID) + .then(setTodos) + .catch(() => setError(Error.load)); + }) + .catch(() => { + setError(Error.update); + }) + .finally(() => { + setDeletingIds((ids) => ids.filter(id => id !== todoId)); + }); + }, [todos]); + + const deleteTodo = useCallback((todoId: number) => { + setDeletingIds((ids) => [...ids, todoId]); + + deleteTodos(todoId) + .then(() => setTodos( + currentTodos => currentTodos.filter(todo => todo.id !== todoId), + )) + .catch(() => setError(Error.delete)) + .finally(() => { + setDeletingIds((ids) => ids.filter(id => id !== todoId)); + }); + }, [todos]); + + const visibleTodos = useMemo(() => { + if (todos) { + return todos + .filter(todo => { + switch (status) { + case Status.completed: + return todo.completed; + + case Status.active: + return !todo.completed; + + default: + return true; + } + }); + } + + return []; + }, [todos, status]); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + + {numberOfAllTodos !== 0 && ( + + )} + + {tempTodo && ( + + )} + + {numberOfAllTodos !== 0 && ( + + )} +
+ + {error !== Error.without && ( + + )} +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..e14f79c17c --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const addTodos = (userId: number, data: any) => { + return client.post(`/todos?userId=${userId}`, data); +}; + +export const deleteTodos = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodos = (todoId: number, data: any) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/Errors.tsx b/src/components/Errors.tsx new file mode 100644 index 0000000000..cd6e22d69e --- /dev/null +++ b/src/components/Errors.tsx @@ -0,0 +1,41 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +/* eslint-disable quote-props */ +import { useEffect } from 'react'; +import cn from 'classnames'; +import { Error } from '../types/Error'; + +type Props = { + error: Error, + onClearErrors: React.Dispatch>, +}; + +export const Errors: React.FC = ({ error, onClearErrors }) => { + useEffect(() => { + setTimeout(() => onClearErrors(Error.without), 3000); + }, [error]); + + return ( +
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 0000000000..e3598589df --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,85 @@ +/* eslint-disable quote-props */ +import cn from 'classnames'; +import { Status } from '../types/Status'; +import { Todo } from '../types/Todo'; + +type Props = { + numberOfActiveTodos: number, + numberOfCompletedTodos: number, + onStatusChange: React.Dispatch>, + status: Status, + onDeleteTodo: (todoId: number) => void, + todos: Todo[], +}; + +export const TodoFooter: React.FC = ({ + numberOfActiveTodos, + numberOfCompletedTodos, + onStatusChange, + status, + onDeleteTodo, + todos, +}) => { + const deleteCompleted = () => { + todos.filter(todo => todo.completed).forEach((todo) => { + onDeleteTodo(todo.id); + }); + }; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 0000000000..a61c50a90b --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,89 @@ +/* eslint-disable quote-props */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import { useState } from 'react'; +import cn from 'classnames'; +import { addTodos, getTodos } from '../api/todos'; +import { Todo } from '../types/Todo'; +import { Error } from '../types/Error'; + +type Props = { + userId: number, + setTempTodo: React.Dispatch>, + setError: React.Dispatch>, + setTodos: React.Dispatch>, + processing: boolean, + setProcessing: React.Dispatch>, + areAllCompleted: boolean, + toggleAll: () => void, + +}; + +export const TodoHeader: React.FC = ({ + userId, + setTempTodo, + setError, + setTodos, + processing, + setProcessing, + areAllCompleted, + toggleAll, +}) => { + const [todoTitle, setTodoTitle] = useState(''); + + const submitHandler = (event: React.FormEvent) => { + event.preventDefault(); + + if (todoTitle.trim().length === 0) { + setError(Error.empty); + + return; + } + + const newTodo = { + title: todoTitle, + completed: false, + userId, + }; + + setTodoTitle(''); + setProcessing(true); + + addTodos(userId, newTodo) + .then(() => { + return getTodos(userId) + .then(setTodos) + .catch(() => setError(Error.load)); + }) + .catch(() => setError(Error.add)) + .finally(() => { + setTempTodo(null); + setProcessing(false); + }); + + setTempTodo({ + id: 0, + ...newTodo, + }); + }; + + return ( +
+
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..37ab359eb4 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,28 @@ +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + tempTodo: Todo | null, + processing: boolean, +}; + +export const TodoItem: React.FC = ({ tempTodo, processing }) => { + return ( + <> +
+ + {tempTodo?.title} +
+
+
+
+
+ + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..6433605825 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,34 @@ +/* eslint-disable max-len */ +/* eslint-disable quote-props */ +import { Todo } from '../types/Todo'; +import { TodoRow } from './TodoRow'; + +type Props = { + todos: Todo[], + toggleTodo: (todo: Todo) => void, + onRenameTodo: (todoId: number, title: string) => void, + onDeleteTodo: (todoId: number) => void, + deletingIds: number[], +}; + +export const TodoList: React.FC = ({ + todos, + toggleTodo, + onRenameTodo, + onDeleteTodo, + deletingIds, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoRow.tsx b/src/components/TodoRow.tsx new file mode 100644 index 0000000000..9a65aaa44e --- /dev/null +++ b/src/components/TodoRow.tsx @@ -0,0 +1,98 @@ +/* eslint-disable quote-props */ +import { useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo, + toggleTodo: (todo: Todo) => void, + onRenameTodo: (todoId: number, title: string) => void, + onDeleteTodo: (todoId: number) => void, + deletingIds: number[], +}; + +export const TodoRow: React.FC = ({ + todo, + toggleTodo, + onRenameTodo, + onDeleteTodo, + deletingIds, +}) => { + const [editing, setEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditing(false); + setTitle(todo.title); + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (title) { + onRenameTodo(todo.id, title); + } else { + onDeleteTodo(todo.id); + } + + setEditing(false); + }; + + return ( +
+ + {editing ? ( +
+ setTitle(event.target.value)} + /> +
+ ) : ( + <> + { + setEditing(true); + setTitle(todo.title); + }} + > + {todo.title} + +
+
+
+
+ + + )} +
+ ); +}; diff --git a/src/types/Error.ts b/src/types/Error.ts new file mode 100644 index 0000000000..0faed8bd5e --- /dev/null +++ b/src/types/Error.ts @@ -0,0 +1,8 @@ +export enum Error { + without = '', + empty = 'empty', + load = 'load', + add = 'add', + update = 'update', + delete = 'delete', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..66910400f2 --- /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..42421feae0 --- /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', + }; + } + + // we wait for testing purpose to see loaders + return wait(300) + .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'), +};