diff --git a/src/App.tsx b/src/App.tsx index 81e011f43..ba8332c99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,206 @@ -/* eslint-disable max-len */ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + getTodos, + createTodo, + updateTodo, + deleteTodo, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { Footer } from './components/Footer'; +import { SelectedFilter } from './types/SelectedFilter'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { ErrorSection } from './components/ErrorSection'; +import { initialErrorMessage } from './constants/initialErrorMessage'; export const App: React.FC = () => { if (!USER_ID) { return ; } + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(initialErrorMessage); + const [selectedFilter, setSelectedFilter] = useState( + SelectedFilter.all, + ); + const [titleNewTodo, setTitleNewTodo] = useState(''); + const [receiving, setReceiving] = useState(false); + const [tempTodo, setTempTodo] = useState(''); + const [editingTitle, setEditingTitle] = useState(0); + const [ + updatingAndDeletingCompletedTodos, + setUpdatingAndDeletingCompletedTodos, + ] = useState([]); + + const visibleTodos = todos.filter(todo => { + switch (selectedFilter) { + case SelectedFilter.all: + return true; + + case SelectedFilter.active: + return !todo.completed; + + case SelectedFilter.completed: + return todo.completed; + } + }); + + const itemLeft: number = todos.filter(todo => !todo.completed).length; + const completedTodos: Todo[] = todos.filter(todo => todo.completed); + + function getAllTodos() { + getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage({ ...errorMessage, load: true }); + setInterval(() => { + setErrorMessage({ ...errorMessage, load: false }); + }, 3000); + }); + } + + function addTodo({ title, completed, userId }: Omit) { + if (!title) { + setErrorMessage({ ...errorMessage, emptyTitle: true }); + setInterval(() => { + setErrorMessage({ ...errorMessage, emptyTitle: false }); + }, 3000); + + return; + } + + setReceiving(true); + + setTempTodo(title); + + createTodo({ title, completed, userId }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setTitleNewTodo(''); + }) + .catch(() => { + setErrorMessage({ ...errorMessage, create: true }); + setInterval(() => { + setErrorMessage({ ...errorMessage, create: false }); + }, 3000); + }) + .finally(() => { + setReceiving(false); + setTempTodo(''); + }); + } + + function changeTodo(updatedTodo: Todo) { + updateTodo(updatedTodo) + .then(todo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(task => task.id === updatedTodo.id); + + newTodos.splice(index, 1, todo); + + setEditingTitle(0); + + return newTodos; + }); + }) + .catch(() => { + setErrorMessage({ ...errorMessage, updating: true }); + setInterval(() => { + setErrorMessage({ ...errorMessage, updating: false }); + }, 3000); + }) + .finally(() => { + setUpdatingAndDeletingCompletedTodos( + updatingAndDeletingCompletedTodos?.filter( + todo => todo !== updatedTodo, + ), + ); + }); + } + + function removeTodo(todoId: number) { + deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage({ ...errorMessage, delete: true }); + setInterval(() => { + setErrorMessage({ ...errorMessage, delete: false }); + }, 3000); + }); + } + + useEffect(() => { + getAllTodos(); + }, []); + 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 && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..ff5d26ea3 --- /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 = 1844; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = ({ title, completed, userId }: Omit) => { + return client.post(`/todos`, { title, completed, userId }); +}; + +export const updateTodo = ({ id, title, completed, userId }: Todo) => { + return client.patch(`/todos/${id}`, { title, completed, userId }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/ErrorSection.tsx b/src/components/ErrorSection.tsx new file mode 100644 index 000000000..5a728d121 --- /dev/null +++ b/src/components/ErrorSection.tsx @@ -0,0 +1,46 @@ +import classNames from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; +import { initialErrorMessage } from '../constants/initialErrorMessage'; + +type Props = { + errorMessage: ErrorMessage; + setErrorMessage: (errorMessage: ErrorMessage) => void; +}; + +export const ErrorSection: React.FC = ({ + errorMessage, + setErrorMessage, +}) => ( +
+
+); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..24b0e9eaa --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,64 @@ +import classNames from 'classnames'; +import { SelectedFilter } from '../types/SelectedFilter'; +import { Todo } from '../types/Todo'; + +type Props = { + itemLeft: number; + selectedFilter: SelectedFilter; + setSelectedFilter: (filter: SelectedFilter) => void; + completedTodos: Todo[]; + removeTodo: (todoId: number) => void; + setUpdatingAndDeletingCompletedTodos: (completedTodos: Todo[] | []) => void; +}; + +export const Footer: React.FC = ({ + itemLeft, + selectedFilter, + setSelectedFilter, + completedTodos, + removeTodo, + setUpdatingAndDeletingCompletedTodos: setDeletingCompletedTodos, +}) => { + function deleteCompletedTodos() { + setDeletingCompletedTodos(completedTodos); + completedTodos.forEach(todo => removeTodo(todo.id)); + } + + return ( +
+ + {itemLeft} items left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + +
+ ); +}; diff --git a/src/components/FormEditingTitle.tsx b/src/components/FormEditingTitle.tsx new file mode 100644 index 000000000..65e295957 --- /dev/null +++ b/src/components/FormEditingTitle.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + titleEdit: string; + setTitleEdit: (titleEdit: string) => void; + handleChangeSubmit: (todo: Todo) => void; + setEditingTitle: (editingTitle: number) => void; +}; + +export const FormEditingTitle: React.FC = ({ + todo, + titleEdit, + setTitleEdit, + handleChangeSubmit, + setEditingTitle, +}) => { + useEffect(() => { + function handleKeyPress(event: KeyboardEvent) { + if (event.key === 'Escape') { + setEditingTitle(0); + } + } + + window.addEventListener('keyup', handleKeyPress); + + return () => { + window.removeEventListener('keyup', handleKeyPress); + }; + }); + + return ( +
{ + event.preventDefault(); + handleChangeSubmit(todo); + }} + > + handleChangeSubmit(todo)} + autoFocus + onChange={event => { + setTitleEdit(event.target.value); + }} + /> +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..030619592 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,103 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { USER_ID } from '../api/todos'; +import { Todo } from '../types/Todo'; +import { ErrorMessage } from '../types/ErrorMessage'; +import { useEffect, useRef } from 'react'; +import classNames from 'classnames'; + +type Props = { + todos: Todo[]; + visibleTodos: Todo[]; + title: string; + setTitle: (title: string) => void; + addTodo: ({ title, completed, userId }: Omit) => void; + errorMessage: ErrorMessage; + setErrorMessage: (errorMessage: ErrorMessage) => void; + receiving: boolean; + setUpdatingAndDeletingCompletedTodos: ( + deletingCompletedTodos: Todo[] | [], + ) => void; + changeTodo: (todo: Todo) => void; + editingTitle: number; +}; + +export const Header: React.FC = ({ + todos, + visibleTodos, + title, + setTitle, + addTodo, + errorMessage, + setErrorMessage, + receiving, + setUpdatingAndDeletingCompletedTodos, + changeTodo, + editingTitle, +}) => { + const titleField = useRef(null); + + useEffect(() => { + if (titleField.current && !editingTitle) { + titleField.current.focus(); + } + }, [visibleTodos]); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + addTodo({ title: title.trim(), completed: false, userId: USER_ID }); + } + + const allTodosCompleted = visibleTodos.every(todo => todo.completed); + + function toggleAllTodos() { + if (allTodosCompleted) { + setUpdatingAndDeletingCompletedTodos(visibleTodos); + + visibleTodos.forEach(todo => { + changeTodo({ ...todo, completed: !todo.completed }); + }); + + return; + } + + const notCompletedTodos = visibleTodos.filter(todo => !todo.completed); + + setUpdatingAndDeletingCompletedTodos(notCompletedTodos); + + notCompletedTodos.forEach(todo => { + changeTodo({ ...todo, completed: !todo.completed }); + }); + } + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TempTodoItem.tsx b/src/components/TempTodoItem.tsx new file mode 100644 index 000000000..ecdc2003e --- /dev/null +++ b/src/components/TempTodoItem.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; + +type Props = { + tempTodo: string; +}; + +export const TempTodoItem: React.FC = ({ tempTodo }) => { + return ( +
+ + + + {tempTodo} + + + {/* Remove button appears only on hover */} + + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..cf972db3f --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,100 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { FormEditingTitle } from './FormEditingTitle'; + +type Props = { + todo: Todo; + changeTodo: (todo: Todo) => void; + updatingAndDeletingCompletedTodos: Todo[] | []; + setUpdatingAndDeletingCompletedTodos: ( + deletingCompletedTodos: Todo[] | [], + ) => void; + editingTitle: number; + setEditingTitle: (editingTitle: number) => void; + titleEdit: string; + setTitleEdit: (titleEdit: string) => void; + handleChangeSubmit: (todo: Todo) => void; + deleteTodo: (todo: Todo) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + changeTodo, + updatingAndDeletingCompletedTodos, + setUpdatingAndDeletingCompletedTodos, + editingTitle, + setEditingTitle, + titleEdit, + setTitleEdit, + handleChangeSubmit, + deleteTodo, +}) => { + const { id, completed, title } = todo; + + return ( +
+ + + {editingTitle === id ? ( + + ) : ( + <> + { + setEditingTitle(id); + setTitleEdit(title); + }} + > + {title} + + + + + )} + +
task.id === todo.id, + ), + })} + > +
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..05f7f73e8 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TempTodoItem } from './TempTodoItem'; +import { TodoItem } from './TodoItem'; + +type Props = { + visibleTodos: Todo[]; + removeTodo: (todoId: number) => void; + tempTodo: string; + updatingAndDeletingCompletedTodos: Todo[] | []; + setUpdatingAndDeletingCompletedTodos: ( + deletingCompletedTodos: Todo[] | [], + ) => void; + changeTodo: (todo: Todo) => void; + editingTitle: number; + setEditingTitle: (editingTitle: number) => void; +}; + +export const TodoList: React.FC = ({ + visibleTodos, + removeTodo, + tempTodo, + updatingAndDeletingCompletedTodos, + setUpdatingAndDeletingCompletedTodos, + changeTodo, + editingTitle, + setEditingTitle, +}) => { + const [titleEdit, setTitleEdit] = useState(''); + + function deleteTodo(todo: Todo) { + removeTodo(todo.id); + setUpdatingAndDeletingCompletedTodos([todo]); + } + + function handleChangeSubmit(todo: Todo) { + if (todo.title === titleEdit) { + setEditingTitle(0); + + return; + } + + if (!titleEdit) { + deleteTodo(todo); + + return; + } + + changeTodo({ ...todo, title: titleEdit.trim() }); + setUpdatingAndDeletingCompletedTodos([todo]); + } + + return ( +
+ {visibleTodos.map(todo => { + return ( + + ); + })} + + {tempTodo && } +
+ ); +}; diff --git a/src/constants/initialErrorMessage.ts b/src/constants/initialErrorMessage.ts new file mode 100644 index 000000000..dbac8aa1f --- /dev/null +++ b/src/constants/initialErrorMessage.ts @@ -0,0 +1,9 @@ +import { ErrorMessage } from '../types/ErrorMessage'; + +export const initialErrorMessage: ErrorMessage = { + load: false, + create: false, + delete: false, + emptyTitle: false, + updating: false, +}; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index ad28bcb2f..bb3b88cd5 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -78,6 +78,29 @@ } } + &__edit-todo { + width: 100%; + padding: 13px 14px 13px 16px; + + font-size: 22.5px; + line-height: 1.4em; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + border: none; + background: rgba(0, 0, 0, 0.01); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); + + &::placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; + } + } + &__main { border-top: 1px solid #e6e6e6; } diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 000000000..0a482556c --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export interface ErrorMessage { + load: boolean; + create: boolean; + delete: boolean; + emptyTitle: boolean; + updating: boolean; +} diff --git a/src/types/SelectedFilter.ts b/src/types/SelectedFilter.ts new file mode 100644 index 000000000..61582f572 --- /dev/null +++ b/src/types/SelectedFilter.ts @@ -0,0 +1,5 @@ +export enum SelectedFilter { + all = 'All', + active = 'Active', + completed = 'Completed', +} 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/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..708ac4c17 --- /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'), +};