diff --git a/README.md b/README.md index d3c3756ab9..b2f9d94938 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://VikaChereushenko.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 19701e8788..511279ae76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index b6062525ab..005692edf7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..1d293f7397 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,76 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; + import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { TempTodo } from './components/TempTodo/TempTodo'; +import { Footer } from './components/Footer/Fotter'; +import { Error } from './components/Error/Erros'; +import { getTodos } from './api/todos'; +import { filterTodos } from './components/Helpers/Helpers'; -const USER_ID = 0; +import { Todo } from './types/Todo'; +import { TodoStatus } from './types/Status'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [status, setStatus] = useState(TodoStatus.all); + const [tempTodo, setTempTodo] = useState(null); + const [processedIds, setprocessedIds] = useState([]); + const noTodos = todos.length === 0; + const filteredTodos = filterTodos(todos, status); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load 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

+ +
+
+ + + + {tempTodo && } + + {!noTodos && ( +
setStatus(value)} + updateProcessedIds={setprocessedIds} + onError={setErrorMessage} + /> + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..58ce7e8dc5 --- /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 = 2042; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (todoData: Omit) => { + return client.post('/todos', todoData); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, todoData: Partial) => { + return client.patch(`/todos/${todoId}`, todoData); +}; + +// Add more methods here diff --git a/src/components/Error/Erros.tsx b/src/components/Error/Erros.tsx new file mode 100644 index 0000000000..d24c2a3860 --- /dev/null +++ b/src/components/Error/Erros.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + hideError: (errorMessage: string) => void; +}; + +export const Error: React.FC = ({ errorMessage, hideError }) => { + useEffect(() => { + setTimeout(() => hideError(''), 3000); + }); + + return ( +
+
+ ); +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/Footer/Fotter.tsx b/src/components/Footer/Fotter.tsx new file mode 100644 index 0000000000..ffc1ececb6 --- /dev/null +++ b/src/components/Footer/Fotter.tsx @@ -0,0 +1,96 @@ +import React, { useCallback } from 'react'; +import { useMemo } from 'react'; +import classNames from 'classnames'; + +import { deleteTodo } from '../../api/todos'; +import { capitalizeFirstLetter, filterOptions } from '../Helpers/Helpers'; + +import { Todo } from '../../types/Todo'; +import { TodoStatus } from '../../types/Status'; + +type Props = { + todoList: Todo[]; + updateTodolist: React.Dispatch>; + status: TodoStatus; + onStatusChange: (status: TodoStatus) => void; + updateProcessedIds: React.Dispatch>; + onError: React.Dispatch>; +}; + +export const Footer: React.FC = ({ + todoList, + updateTodolist, + status, + onStatusChange, + updateProcessedIds, + onError, +}) => { + const activeTodos = useMemo( + () => todoList.filter(todo => !todo.completed), + [todoList], + ); + const isAnyCompleted = useMemo( + () => todoList.some(todo => todo.completed), + [todoList], + ); + + const handleDeleteCompletedTodos = useCallback(() => { + todoList.forEach(todo => { + if (todo.completed) { + updateProcessedIds(existing => [...existing, todo.id]); + deleteTodo(todo.id) + .then(() => + updateTodolist(existing => + existing.filter(current => current.id !== todo.id), + ), + ) + .catch(() => onError('Unable to delete a todo')) + .finally(() => { + updateProcessedIds(existing => + existing.filter(id => id !== todo.id), + ); + }); + } + }); + }, [todoList]); + + return ( +
+ + {`${activeTodos.length} 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/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..f715ec48b3 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import classNames from 'classnames'; + +import { addTodo, updateTodo } from '../../api/todos'; +import { Todo } from '../../types/Todo'; + +type Props = { + todoList: Todo[]; + onError: React.Dispatch>; + updateTodoList: React.Dispatch>; + updateTempTodo: React.Dispatch>; + updateProcessedIds: React.Dispatch>; +}; + +export const Header: React.FC = ({ + todoList, + onError, + updateTodoList, + updateTempTodo, + updateProcessedIds, +}) => { + const inputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [title, setTitle] = useState(''); + const allTodosCompleted = useMemo( + () => todoList.every(todo => todo.completed), + [todoList], + ); + const noTodos = todoList.length === 0; + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + const normalizeTitle = title.trim(); + + if (!normalizeTitle) { + onError('Title should not be empty'); + + return; + } + + setLoading(true); + const newTodo = { + userId: 2042, + title: normalizeTitle, + completed: false, + }; + + updateTempTodo({ + id: 0, + ...newTodo, + }); + + addTodo(newTodo) + .then(response => { + setTitle(''); + updateTodoList(existing => [...existing, response]); + setLoading(false); + }) + .catch(() => onError('Unable to add a todo')) + .finally(() => { + setLoading(false); + updateTempTodo(null); + }); + }, + [title], + ); + + const handleTotalStatusUpdate = useCallback(() => { + if (!allTodosCompleted) { + todoList.forEach(todo => { + if (!todo.completed) { + updateProcessedIds(existing => [...existing, todo.id]); + const toUpdate = { completed: true }; + + updateTodo(todo.id, toUpdate) + .then(() => { + updateTodoList(existing => + existing.map(el => + el.id === todo.id + ? { ...el, completed: toUpdate.completed } + : el, + ), + ); + }) + .catch(() => onError('Unable to update a todo')) + .finally(() => { + updateProcessedIds(existing => + existing.filter(id => id !== todo.id), + ); + }); + } + }); + } + + if (allTodosCompleted) { + todoList.forEach(todo => { + setLoading(true); + updateProcessedIds(existing => [...existing, todo.id]); + const toUpdate = { completed: false }; + + updateTodo(todo.id, toUpdate) + .then(() => { + updateTodoList(existing => + existing.map(el => + el.id === todo.id + ? { ...el, completed: toUpdate.completed } + : el, + ), + ); + }) + .catch(() => onError('Unable to update a todo')) + .finally(() => { + setLoading(false); + updateProcessedIds(existing => + existing.filter(id => id !== todo.id), + ); + }); + }); + } + }, [todoList]); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todoList, loading]); + + return ( +
+ {!noTodos && ( +
+ ); +}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/Helpers/Helpers.tsx b/src/components/Helpers/Helpers.tsx new file mode 100644 index 0000000000..75bcc08e86 --- /dev/null +++ b/src/components/Helpers/Helpers.tsx @@ -0,0 +1,21 @@ +import { Todo } from '../../types/Todo'; +import { TodoStatus } from '../../types/Status'; + +export const filterTodos = (todos: Todo[], status: TodoStatus) => { + const todosCopy = [...todos]; + + switch (status) { + case TodoStatus.active: + return todosCopy.filter(todo => !todo.completed); + case TodoStatus.completed: + return todosCopy.filter(todo => todo.completed); + case TodoStatus.all: + return todosCopy; + } +}; + +export const capitalizeFirstLetter = (value: TodoStatus) => { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +}; + +export const filterOptions = Object.values(TodoStatus); diff --git a/src/components/TempTodo/TempTodo.tsx b/src/components/TempTodo/TempTodo.tsx new file mode 100644 index 0000000000..53739df757 --- /dev/null +++ b/src/components/TempTodo/TempTodo.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TempTodo: React.FC = ({ todo }) => { + const { title } = todo; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + {title} + + + + + {/* 'is-active' class puts this modal on top of the todo */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TempTodo/index.tsx b/src/components/TempTodo/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..56548c0899 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,190 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { deleteTodo, updateTodo } from '../../api/todos'; + +type Props = { + todoId: number; + completed: boolean; + title: string; + updateTodolist: React.Dispatch>; + onError: React.Dispatch>; + todoList: Todo[]; + processedIds: number[]; + updateProcessedIds: React.Dispatch>; +}; +export const TodoItem: React.FC = ({ + todoId, + completed, + title, + updateTodolist, + onError, + todoList, + processedIds, + updateProcessedIds, +}) => { + const [newTitle, setNewTitle] = useState(title); + const [updatingTitle, setUpdatingTitle] = useState(false); + const inputRef = useRef(null); + + const handleDeleteOneTodo = useCallback( + (id: number) => { + updateProcessedIds(existing => [...existing, id]); + deleteTodo(id) + .then(() => { + updateTodolist(existing => + existing.filter(current => current.id !== id), + ); + }) + .catch(() => onError('Unable to delete a todo')) + .finally(() => { + updateProcessedIds([]); + }); + }, + [todoId], + ); + + const handleTitleUpdate = useCallback( + (id: number, e?: React.FormEvent) => { + e?.preventDefault(); + + const normalizeNewTitle = newTitle.trim(); + const isTitleChanged = title !== normalizeNewTitle; + + if (!normalizeNewTitle) { + handleDeleteOneTodo(id); + + return; + } + + if (!isTitleChanged) { + setUpdatingTitle(false); + + return; + } + + const changeItem = todoList.find(todo => todo.id === id); + const toUpdate = { title: normalizeNewTitle }; + + if (changeItem) { + updateProcessedIds(existing => [...existing, id]); + updateTodo(id, toUpdate) + .then(() => { + updateTodolist(existing => + existing.map(el => + el.id === id ? { ...el, title: toUpdate.title } : el, + ), + ); + setUpdatingTitle(false); + }) + .catch(() => onError('Unable to update a todo')) + .finally(() => { + updateProcessedIds([]); + }); + } + }, + [newTitle], + ); + + const handleKeyUp = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setUpdatingTitle(false); + + return; + } + }, + [], + ); + + const handleStatusUpdate = useCallback( + (id: number) => { + updateProcessedIds(existing => [...existing, id]); + + const changeItem = todoList.find(todo => todo.id === id); + + if (changeItem) { + const toUpdate = { completed: !changeItem.completed }; + + updateTodo(id, toUpdate) + .then(() => { + updateTodolist(existing => + existing.map(el => + el.id === id ? { ...el, completed: toUpdate.completed } : el, + ), + ); + }) + .catch(() => onError('Unable to update a todo')) + .finally(() => { + updateProcessedIds([]); + }); + } + }, + [completed], + ); + + useEffect(() => { + if (updatingTitle && inputRef.current) { + inputRef.current.focus(); + } + }); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {updatingTitle ? ( +
handleTitleUpdate(todoId, event)}> + handleTitleUpdate(todoId)} + onKeyUp={event => handleKeyUp(event)} + onChange={event => setNewTitle(event.target.value)} + /> +
+ ) : ( + <> + setUpdatingTitle(true)} + > + {title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.tsx b/src/components/TodoItem/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..f01513a3f2 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { TodoItem } from '../TodoItem/TodoItem'; + +import { Todo } from '../../types/Todo'; + +type Props = { + filteredTodos: Todo[]; + updateTodolist: React.Dispatch>; + onError: React.Dispatch>; + todoList: Todo[]; + processedIds: number[]; + updateProcessedIds: React.Dispatch>; +}; + +export const TodoList: React.FC = ({ + filteredTodos, + updateTodolist, + onError, + todoList, + processedIds, + updateProcessedIds, +}) => { + return ( +
+ {filteredTodos.map(todo => { + const { id, completed, title } = todo; + + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/TodoList/index.tsx b/src/components/TodoList/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/types/Status.tsx b/src/types/Status.tsx new file mode 100644 index 0000000000..5ab1d4bd7e --- /dev/null +++ b/src/types/Status.tsx @@ -0,0 +1,5 @@ +export enum TodoStatus { + 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..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'), +};