diff --git a/.env b/.env index 2accbba818..dae6759dc5 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ ESLINT_NO_DEV_ERRORS=true +BROWSER=chrome diff --git a/README.md b/README.md index 47a1add059..224beb9bce 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,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://Visol.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..ce37ee868d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,237 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { FormEvent, useEffect, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { createTodo, deleteTodo, getTodos, USER_ID } from './api/todos'; +import { ErrorMessage, Status, Todo } from './types/Todo'; +import classNames from 'classnames'; +import { getActiveTodos, getFilteredTodos } from './utils/helper'; +import { TodoItem } from './components'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [statusFilter, setStatusFilter] = useState(Status.all); + const [newTitle, setNewTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [waitingDeleteTodos, setWaitingDeleteTodos] = useState([]); + const [keyToForm, setKeyToForm] = useState(0); + const [formDisabled, setFormDisabled] = useState(false); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessage.Load)) + .finally(); + }, []); + + useEffect(() => { + if (!errorMessage) { + return; + } + + const timerId = setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => clearTimeout(timerId); + }, [errorMessage]); + if (!USER_ID) { return ; } + const handleDeleteTodo = (id: number) => { + setWaitingDeleteTodos(current => [...current, id]); + deleteTodo(id) + .then(() => { + setTodos(current => current.filter(todo => todo.id !== id)); + }) + .catch(() => { + setErrorMessage(ErrorMessage.Delete); + }) + .finally(() => { + setWaitingDeleteTodos(current => current.filter(item => item !== id)); + setKeyToForm(current => current + 1); + }); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const normalizedTitle = newTitle.trim(); + + if (!normalizedTitle) { + setErrorMessage(ErrorMessage.TitleEmpty); + + return; + } + + setTempTodo({ + id: 0, + title: normalizedTitle, + completed: false, + userId: USER_ID, + }); + + setFormDisabled(true); + + createTodo(normalizedTitle) + .then(todo => { + setTodos(current => [...current, todo]); + setNewTitle(''); + }) + .catch(() => setErrorMessage(ErrorMessage.Add)) + .finally(() => { + setTempTodo(null); + setKeyToForm(current => current + 1); + setFormDisabled(false); + }); + }; + + const handleBundleDelete = () => { + for (const todo of todos) { + if (todo.completed) { + handleDeleteTodo(todo.id); + } + } + }; + + // const showTodosAndFooter = todos.length > 0; + const showToggleAllButton = todos.length > 0; + const activeTodos = getActiveTodos(todos); + const filteredTodos = getFilteredTodos(todos, statusFilter); + const isAllCompleted = todos.length > 0 && activeTodos.length === 0; + const completedTodos = todos.length - activeTodos.length; + return ( -
-

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

+
+

todos

+ +
+
+ {/* this button should have `active` class only if all todos are completed */} +
+ +
+ {filteredTodos.map(todo => ( + + ))} + + {tempTodo && ( + {}} /> + )} +
+ + {/* Hide the footer if there are no todos */} + {showToggleAllButton && ( +
+ setStatusFilter(Status.active)} + > + Active + + + setStatusFilter(Status.completed)} + > + Completed + + + + {/* this button should be disabled if there are no completed todos */} + + + )} + + + {/* DON'T use conditional rendering to hide the notification */} + {/* Add the 'hidden' class to hide the message smoothly */} +
+
+ ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..ae59876604 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,22 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4234; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (title: string) => { + const data: Omit = { + userId: Number(USER_ID), + title, + completed: false, + }; + + return client.post(`/todos`, data); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000000..278ccf2633 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1 @@ +export * from './todoItem'; diff --git a/src/components/todoItem.tsx b/src/components/todoItem.tsx new file mode 100644 index 0000000000..0f7630b211 --- /dev/null +++ b/src/components/todoItem.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type TodoItemProps = { + todo: Todo; + loader?: boolean; + onDelete: (id: number) => void; +}; + +export function TodoItem({ todo, loader = false, onDelete }: TodoItemProps) { + const todoId = `todo-status-${todo.id}`; + + return ( +
+ + + + {todo.title} + + + {/* Remove button appears only on hover */} + + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..9e94a04cef --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,20 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} + +export enum Status { + all = 'all', + completed = 'completed', + active = 'active', +} + +export enum ErrorMessage { + Load = 'Unable to load todos', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', + TitleEmpty = 'Title should not be empty', +} 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/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000000..f5966fedd5 --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,18 @@ +import { Status, Todo } from '../types/Todo'; + +export function getFilteredTodos(allTodos: Todo[], statusFilter: Status) { + return allTodos.filter(todo => { + switch (statusFilter) { + case Status.active: + return !todo.completed; + case Status.completed: + return todo.completed; + default: + return true; + } + }); +} + +export function getActiveTodos(allTodos: Todo[]) { + return allTodos.filter(todo => !todo.completed); +}