From 5c3f19cf175ce06215da96a39af1b7c99edec836 Mon Sep 17 00:00:00 2001 From: Oleksii Pishchuhin Date: Sat, 16 Sep 2023 16:58:56 +0200 Subject: [PATCH] initial commit --- README.md | 2 +- src/App.tsx | 198 ++++++++++++++++-- src/api/todos.ts | 19 ++ src/components/Footer/Footer.scss | 10 + src/components/Footer/Footer.tsx | 95 +++++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 89 ++++++++ src/components/Header/index.ts | 1 + .../Notifications/Notifications.tsx | 91 ++++++++ src/components/Notifications/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 129 ++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.scss | 46 ++++ src/components/TodoList/TodoList.tsx | 61 ++++++ src/components/TodoList/index.ts | 1 + src/types/ErrorsType.ts | 7 + src/types/Todo.ts | 6 + src/types/TodoFilter.ts | 5 + src/utils/fetchClient.ts | 46 ++++ 19 files changed, 796 insertions(+), 13 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Footer/Footer.scss create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/Notifications/Notifications.tsx create mode 100644 src/components/Notifications/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.scss create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/types/ErrorsType.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodoFilter.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index c7bfa3dd3..e8b8fcb0c 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://aliliolek.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 5749bdf78..89fcc2c57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,198 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + createTodo, + deleteTodo, + editTodo, + getTodos, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { TodoFilter } from './types/TodoFilter'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { Notifications } from './components/Notifications'; -const USER_ID = 0; +const USER_ID = 11399; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [visibleTodos, setVisibleTodos] = useState([]); + const [errors, setErrors] = useState({ + load: false, + delete: false, + empty: false, + add: false, + edit: false, + }); + const [currentFilter, setCurrentFilter] = useState(TodoFilter.All); + const [loadingId, setLoadingId] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + + const filterTodos = (initTodos?: Todo[]) => { + let filtered; + + if (initTodos) { + filtered = [...initTodos]; + } else { + filtered = [...todos]; + } + + switch (currentFilter) { + case TodoFilter.Active: + filtered = filtered.filter(todo => !todo.completed); + break; + case TodoFilter.Completed: + filtered = filtered.filter(todo => todo.completed); + break; + default: + break; + } + + setVisibleTodos(filtered); + }; + + const loadTodos = () => { + getTodos(USER_ID).then(items => { + setTodos([...items]); + filterTodos(items); + }).catch(() => { + setErrors(prevErrors => ({ ...prevErrors, load: true })); + }); + }; + + const removeTodo = (todoId: number) => { + setLoadingId(prevTodoId => [...prevTodoId, todoId]); + + deleteTodo(todoId) + .then(() => { + setTodos(prevTodos => prevTodos + .filter(todo => todo.id !== todoId)); + setLoadingId([]); + }) + .catch(() => { + setErrors(prevErrors => ({ + ...prevErrors, + delete: true, + })); + setLoadingId([]); + }); + }; + + const addTodo = (newTitle: string) => { + const maxId = Math.max(...todos.map(todo => todo.id)); + + setTempTodo({ + id: 0, + title: newTitle, + completed: false, + userId: USER_ID, + }); + + createTodo({ + id: maxId + 1, + title: newTitle, + completed: false, + userId: USER_ID, + }) + .then((newTodo) => { + setTodos(prevTodos => [...prevTodos, newTodo]); + setTempTodo(null); + }) + .catch(() => { + setErrors(prevErrors => ({ + ...prevErrors, + add: true, + })); + setTempTodo(null); + }); + }; + + const changeTodo = ( + property: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + todoId: number, + ) => { + setLoadingId(prevTodoId => [...prevTodoId, todoId]); + const oldTodo = todos.find((todo) => todo.id === todoId); + + if (oldTodo) { + const updatedTodo: Todo = { ...oldTodo, [property]: value }; + + editTodo(updatedTodo, todoId) + .then(editedTodo => { + setTodos(prevTodos => { + const newTodos = [...prevTodos]; + + const index = newTodos.findIndex(todo => todo.id === todoId); + + newTodos.splice(index, 1, editedTodo); + + return newTodos; + }); + setLoadingId([]); + }) + .catch(() => { + setErrors(prevErrors => ({ + ...prevErrors, + edit: true, + })); + setLoadingId([]); + }); + } + }; + + useEffect(() => { + loadTodos(); + }, []); + + useEffect(() => { + filterTodos(); + }, [currentFilter, todos]); + 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 && ( + + )} + + {todos.length > 0 && ( +
+ )} +
+ + {/* Notification is shown in case of any error */} + {/* 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 000000000..7b1655651 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,19 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +// Add more methods here +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const createTodo = (todoData: Todo) => { + return client.post('/todos', todoData); +}; + +export const editTodo = (todoData: Todo, todoId: number) => { + return client.patch(`/todos/${todoId}`, todoData); +}; diff --git a/src/components/Footer/Footer.scss b/src/components/Footer/Footer.scss new file mode 100644 index 000000000..b82f80491 --- /dev/null +++ b/src/components/Footer/Footer.scss @@ -0,0 +1,10 @@ +.todoapp__clear-completed { + opacity: 1; + cursor: pointer; + + &--hidden { + opacity: 0; + cursor: default; + transition: all 0.3s ease-in-out; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..53a8b211b --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,95 @@ +import classNames from 'classnames'; +import { TodoFilter } from '../../types/TodoFilter'; +import { Todo } from '../../types/Todo'; +import './Footer.scss'; + +type FooterProps = { + currentFilter: TodoFilter; + setCurrentFilter: React.Dispatch>; + todos: Todo[]; + removeTodo: (todoId: number) => void; +}; + +export const Footer: React.FC = ({ + currentFilter, + setCurrentFilter, + todos, + removeTodo, +}) => { + const counterActive = todos.filter(todo => !todo.completed).length || 0; + const counterCompleted = todos.length - counterActive; + + const handleFilter = (filterParam: TodoFilter) => { + setCurrentFilter(filterParam); + }; + + const clearCompleted = () => { + todos.forEach(todo => { + if (todo.completed) { + removeTodo(todo.id); + } + }); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..ddcc5a9cd --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..ce409d43c --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,89 @@ +import classNames from 'classnames'; +import { useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { ErrorsType } from '../../types/ErrorsType'; + +type HeaderProps = { + addTodo: (newTitle: string) => void; + todos: Todo[]; + changeTodo: ( + property: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + todoId: number, + ) => void; + setErrors: React.Dispatch>; +}; + +export const Header: React.FC = ({ + addTodo, + todos, + changeTodo, + setErrors, +}) => { + const [newTitle, setNewTitle] = useState(''); + const isAllCompleted = todos.every(todo => todo.completed === true); + + const clearForm = () => { + setNewTitle(''); + }; + + const handleChangeTitle = (event: React.ChangeEvent) => { + setNewTitle(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (newTitle.trim()) { + addTodo(newTitle); + } else if (newTitle.trim() === '') { + setErrors(prevErrors => ({ + ...prevErrors, + empty: true, + })); + } + + clearForm(); + }; + + const toggleAll = () => { + if (isAllCompleted) { + todos.forEach(todo => { + changeTodo('completed', false, todo.id); + }); + } else { + todos.forEach(todo => { + changeTodo('completed', true, todo.id); + }); + } + }; + + return ( +
+ {/* this buttons is active only if there are some active todos */} +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..266dec8a1 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/Notifications/Notifications.tsx b/src/components/Notifications/Notifications.tsx new file mode 100644 index 000000000..f12953dd5 --- /dev/null +++ b/src/components/Notifications/Notifications.tsx @@ -0,0 +1,91 @@ +import classNames from 'classnames'; +import { useEffect } from 'react'; +import { ErrorsType } from '../../types/ErrorsType'; + +type NotificationsProps = { + errors: ErrorsType; + setErrors: React.Dispatch>; +}; + +export const Notifications: React.FC = ({ + errors, + setErrors, +}) => { + const handleCLoseNotifications = () => { + setErrors({ + load: false, + delete: false, + empty: false, + add: false, + edit: false, + }); + }; + + useEffect(() => { + // hide after 3 seconds + const hideNotificationTimer = setTimeout(() => { + handleCLoseNotifications(); + }, 3000); + + // clean up the timer + return () => { + clearTimeout(hideNotificationTimer); + }; + }, [errors]); + + const checkErrors + = !errors.load && !errors.delete + && !errors.empty && !errors.add && !errors.edit; + + return ( +
+
+ ); +}; diff --git a/src/components/Notifications/index.ts b/src/components/Notifications/index.ts new file mode 100644 index 000000000..e7825351a --- /dev/null +++ b/src/components/Notifications/index.ts @@ -0,0 +1 @@ +export * from './Notifications'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..9036c55ee --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,129 @@ +import classNames from 'classnames'; +import { useState } from 'react'; +import { Todo } from '../../types/Todo'; + +type TodoItemProps = { + todo: Todo; + loadingId: number[]; + removeTodo: (todoId: number) => void; + changeTodo: ( + property: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + todoId: number, + ) => void; +}; + +export const TodoItem: React.FC = ({ + todo: { id, title, completed }, + loadingId, + removeTodo, + changeTodo, +}) => { + const [isEditingId, setIsEditingId] = useState(null); + const [inputValue, setInputValue] = useState(title); + + const handleDoubleClick = () => { + setIsEditingId(id); + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + const currentValue = event.target.value; + + setInputValue(currentValue); + }; + + const saveChanges = () => { + if (inputValue.trim() && inputValue.trim() !== title.trim()) { + changeTodo('title', inputValue, id); + setIsEditingId(null); + } else if (inputValue.trim() === '') { + removeTodo(id); + } else { + setIsEditingId(null); + } + }; + + const handleSubmitEditing = (event: React.FormEvent) => { + event.preventDefault(); + + saveChanges(); + }; + + const handleBlur = () => { + saveChanges(); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditingId(null); + } + }; + + const handleToggle = () => { + changeTodo('completed', !completed, id); + }; + + return ( +
+ + + {(isEditingId === id) ? ( +
+ +
+ ) : ( + + {loadingId.includes(id) ? ( + inputValue + ) : ( + title + )} + {/* {title} */} + + )} + + {/* Remove button appears only on hover */} + + + {/* overlay will cover the todo while it is being updated */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..21f4abac3 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.scss b/src/components/TodoList/TodoList.scss new file mode 100644 index 000000000..04f531837 --- /dev/null +++ b/src/components/TodoList/TodoList.scss @@ -0,0 +1,46 @@ +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..83ad5327f --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,61 @@ +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import './TodoList.scss'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem'; + +type TodoListProps = { + todos: Todo[]; + loadingId: number[]; + removeTodo: (todoId: number) => void; + tempTodo: Todo | null; + changeTodo: ( + property: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + todoId: number, + ) => void; +}; + +export const TodoList: React.FC = ({ + todos, + loadingId, + removeTodo, + tempTodo, + changeTodo, +}) => { + return ( +
+ + {todos.map(todo => ( + + + + ))} + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/types/ErrorsType.ts b/src/types/ErrorsType.ts new file mode 100644 index 000000000..663d4500b --- /dev/null +++ b/src/types/ErrorsType.ts @@ -0,0 +1,7 @@ +export type ErrorsType = { + load: boolean; + delete: boolean; + empty: boolean; + add: boolean; + edit: boolean; +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /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/TodoFilter.ts b/src/types/TodoFilter.ts new file mode 100644 index 000000000..6b7e03b79 --- /dev/null +++ b/src/types/TodoFilter.ts @@ -0,0 +1,5 @@ +export enum TodoFilter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..450b7d53c --- /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(800) + .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'), +};