diff --git a/cypress/integration/page.spec.js b/cypress/integration/page.spec.js index fcac2e3d12..1d1c697657 100644 --- a/cypress/integration/page.spec.js +++ b/cypress/integration/page.spec.js @@ -519,7 +519,7 @@ describe('', () => { }); // this test may be flaky - it.skip('should replace loader with a created todo', () => { + it('should replace loader with a created todo', () => { page.flushJSTimers(); todos.assertCount(6); todos.assertNotLoading(5); @@ -1515,7 +1515,7 @@ describe('', () => { }); // It depend on your implementation - it.skip('should stay while waiting', () => { + it('should stay while waiting', () => { page.mockUpdate(257334); todos.title(0).trigger('dblclick'); @@ -1694,7 +1694,7 @@ describe('', () => { }); // this test may be unstable - it.skip('should hide loader on fail', () => { + it('should hide loader on fail', () => { // to prevent Cypress from failing the test on uncaught exception cy.once('uncaught:exception', () => false); diff --git a/package-lock.json b/package-lock.json index 19701e8788..0dbc74a8e1 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": "^2.1.3", "@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": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "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..6d0f20adcc 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": "^2.1.3", "@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..4587640441 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,297 @@ -/* 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, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + addTodos, + changeTodos, + deleteTodos, + getTodos, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { TodoList } from './Components/TodoList/TodoList'; +import { Footer } from './Components/Footer/Footer'; +import { SortType } from './types/SortType'; +/* eslint-disable-next-line max-len */ +import { ErrorNotification } from './Components/ErrorNotification/ErrorNotification'; +import { Header } from './Components/Header/Header'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [sortType, setSortType] = useState(SortType.all); + const [tempTodo, setTempTodo] = useState(null); + const [changePostsId, setChangePostsId] = useState([]); + const inputRef = useRef(null); + + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + setErrorMessage(''); + + getTodos() + .then(todosFromServer => setTodos(todosFromServer)) + .catch(() => { + setErrorMessage('Unable to load todos'); + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + throw new Error(); + }); + }, []); + + const filteredTodos = todos.filter(todo => { + switch (sortType) { + case SortType.Active: { + return !todo.completed; + } + + case SortType.Completed: { + return todo.completed; + } + + case SortType.all: + default: + return todo; + } + }); + + function addDataToServer(listValue: string) { + setErrorMessage(''); + const normalValue = listValue.trim(); + + if (!normalValue) { + setErrorMessage('Title should not be empty'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return Promise.resolve(false); + } + + const newTodoData = { + title: listValue, + completed: false, + userId: USER_ID, + }; + + setTempTodo({ + id: 0, + ...newTodoData, + }); + + return addTodos(newTodoData) + .then(newTodo => { + setTodos(currentTodo => [...currentTodo, newTodo]); + + return true; + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return false; + }) + .finally(() => { + setTempTodo(null); + }); + } + + function deleteDataFromServer(postId: number) { + setErrorMessage(''); + setChangePostsId(currentIds => [...currentIds, postId]); + + if (!postId) { + setErrorMessage('Title should not be empty to delete'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return Promise.reject(); // <--- ДОДАНО: повертаємо відхилений проміс + } + + return deleteTodos(postId) // <--- ДОДАНО: return, щоб проміс пішов наверх + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== postId), + ); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + throw new Error(); // <--- ДОДАНО: прокидаємо помилку далі + }) + .finally(() => { + setChangePostsId(idOfPosts => idOfPosts.filter(id => id !== postId)); + inputRef.current?.focus(); + }); + } + + function deleteAllCompletedFromServer() { + const completedId = todos + .filter(todoFromArray => todoFromArray.completed) + .map(todo => todo.id); + + for (const id of completedId) { + deleteDataFromServer(id); + } + } + + function changeStatusToServer(changeId: number) { + setErrorMessage(''); + setChangePostsId(currentIds => [...currentIds, changeId]); + const searcData = todos.find(todo => todo.id === changeId) || null; + + if (!searcData) { + setErrorMessage('Unable to update a todo'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return; + } + + const changePost = { + ...searcData, + completed: !searcData.completed, + }; + + changeTodos(changePost) + .then(updatedTodo => + setTodos(currentPosts => { + return currentPosts.map(post => + post.id === updatedTodo.id ? updatedTodo : post, + ); + }), + ) + .catch(() => { + setErrorMessage('Unable to update a todo'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + }) + .finally(() => { + setChangePostsId(currentIds => + currentIds.filter(currentId => currentId !== changeId), + ); + }); + } + + function changeAllStatusToServer() { + let targetTodos = todos.filter(todo => !todo.completed); + + if (targetTodos.length === 0) { + targetTodos = todos; + } + + const changesId = targetTodos.map(todo => todo.id); + + for (const id of changesId) { + changeStatusToServer(id); + } + } + + function renameTodoData(todoId: number, newTitle: string) { + setErrorMessage(''); + setChangePostsId(currentIds => [...currentIds, todoId]); + + const searchData = todos.find(todo => todo.id === todoId); + + if (!searchData) { + setErrorMessage('Unable to update a todo'); + setTimeout(() => setErrorMessage(''), 3000); + + return Promise.reject(); + } + + const updatedPost = { + ...searchData, + title: newTitle, + }; + + return changeTodos(updatedPost) + .then(updatedTodo => + setTodos(currentPosts => { + return currentPosts.map(post => + post.id === updatedTodo.id ? updatedTodo : post, + ); + }), + ) + .catch(() => { + setErrorMessage('Unable to update a todo'); + + setTimeout(() => { + setErrorMessage(''); + }, 3000); + + throw new Error(); + }) + .finally(() => { + setChangePostsId(currentIds => + currentIds.filter(currentId => currentId !== todoId), + ); + }); + } + + const activeTodoCount = todos.filter(todo => !todo.completed).length; + + const hasCompletedTodos = todos.length > activeTodoCount; + 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 && ( + <> + + +
+ + )} +
+ + setErrorMessage(catchError)} + /> +
); }; diff --git a/src/Components/ErrorNotification/ErrorNotification.tsx b/src/Components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..c900661df6 --- /dev/null +++ b/src/Components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,22 @@ +import classNames from 'classnames'; +import { ErrorProps } from '../../types/ErrorProps'; + +export const ErrorNotification = ({ error, setError }: ErrorProps) => { + return ( +
+
+ ); +}; diff --git a/src/Components/Footer/Footer.tsx b/src/Components/Footer/Footer.tsx new file mode 100644 index 0000000000..71d31f888c --- /dev/null +++ b/src/Components/Footer/Footer.tsx @@ -0,0 +1,64 @@ +import classNames from 'classnames'; +import { SortType } from '../../types/SortType'; +import { FooterProps } from '../../types/FooterProps'; + +export const Footer = ({ + activeTodosCount, + currentSortType, + hasCompletedTodos, + onSortChange, + deletedAllCompleted, +}: FooterProps) => { + return ( + + ); +}; diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx new file mode 100644 index 0000000000..b3d6e18b13 --- /dev/null +++ b/src/Components/Header/Header.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import { useState } from 'react'; +import { HeaderProps } from '../../types/HeaderProps'; + +export const Header = ({ + todos, + active, + onChange, + inputRef, + changeAll, +}: HeaderProps) => { + const [listValue, setListValue] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isAllActive = !active ? true : false; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const normalValue = listValue.trim(); + + setIsSubmitting(true); + + onChange(normalValue) + .then(isSuccess => { + if (isSuccess) { + setListValue(''); + } + }) + .finally(() => { + setIsSubmitting(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/Components/TodoList/TodoList.tsx b/src/Components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..88c0086419 --- /dev/null +++ b/src/Components/TodoList/TodoList.tsx @@ -0,0 +1,202 @@ +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import React, { useRef, useState } from 'react'; +import { TodoListProps } from '../../types/TodoListProps'; + +export const TodoList = ({ + filteredTodos, + tempTodo, + deleteId, + deleteData, + changeStatusData, + renameData, +}: TodoListProps) => { + const [newTitle, setNewTitle] = useState(''); + const [editingId, setEditingId] = useState(null); + + const editInputRef = useRef(null); + const isSubmittingEdit = useRef(false); + const isCancelling = useRef(false); + + const handleDelete = (id: number) => { + deleteData(id); + }; + + const statusSubmit = (id: number) => { + changeStatusData(id); + }; + + const handleDoubleClick = (todo: Todo) => { + isCancelling.current = false; + setEditingId(todo.id); + setNewTitle(todo.title); + }; + + const cancelEditing = () => { + isCancelling.current = true; + setEditingId(null); + setNewTitle(''); + }; + + const handleSubmit = (todo: Todo) => { + if (isCancelling.current || isSubmittingEdit.current) { + return; + } + + const normalizedTitle = newTitle.trim(); + + if (normalizedTitle === todo.title) { + cancelEditing(); + + return; + } + + if (normalizedTitle === '') { + isSubmittingEdit.current = true; + + deleteData(todo.id) + .then(() => { + cancelEditing(); + }) + .catch(() => { + editInputRef.current?.focus(); + }) + .finally(() => { + isSubmittingEdit.current = false; + }); + + return; + } + + isSubmittingEdit.current = true; + + renameData(todo.id, normalizedTitle) + .then(() => { + cancelEditing(); + }) + .catch(() => { + editInputRef.current?.focus(); + }) + .finally(() => { + isSubmittingEdit.current = false; + }); + }; + + const handleKeyUp = ( + event: React.KeyboardEvent, + todo: Todo, + ) => { + if (event.key === 'Escape') { + cancelEditing(); + } else if (event.key === 'Enter') { + handleSubmit(todo); + } + }; + + return ( +
+ {filteredTodos.map(todo => ( +
+ + {editingId === todo.id ? ( + setNewTitle(inputEvent.target.value)} + onBlur={() => handleSubmit(todo)} + onKeyUp={e => handleKeyUp(e, todo)} + autoFocus + /> + ) : ( + <> + handleDoubleClick(todo)} + > + {todo.title} + + + + + )} + +
+ {/* eslint-disable-next-line max-len */} +
+
+
+
+ ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..070e7d93e4 --- /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 = 4356; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodos = (data: Omit) => { + return client.post(`/todos`, data); +}; + +export const deleteTodos = (postId: number) => { + return client.delete(`/todos/${postId}`); +}; + +export const changeTodos = (data: Todo) => { + return client.patch(`/todos/${data.id}`, data); +}; diff --git a/src/types/ErrorProps.ts b/src/types/ErrorProps.ts new file mode 100644 index 0000000000..34e0651b19 --- /dev/null +++ b/src/types/ErrorProps.ts @@ -0,0 +1,4 @@ +export interface ErrorProps { + error: string; + setError: (cathcError: string) => void; +} diff --git a/src/types/FooterProps.ts b/src/types/FooterProps.ts new file mode 100644 index 0000000000..dd010dd3c1 --- /dev/null +++ b/src/types/FooterProps.ts @@ -0,0 +1,9 @@ +import { SortType } from "./SortType"; + +export interface FooterProps { + activeTodosCount: number; + currentSortType: SortType; + hasCompletedTodos: boolean; + onSortChange: (value: SortType) => void; + deletedAllCompleted: () => void; +} diff --git a/src/types/HeaderProps.ts b/src/types/HeaderProps.ts new file mode 100644 index 0000000000..5d06df1cd8 --- /dev/null +++ b/src/types/HeaderProps.ts @@ -0,0 +1,9 @@ +import { Todo } from "./Todo"; + +export interface HeaderProps { + todos: Todo[]; + active: number; + onChange: (value: string) => Promise; + inputRef: React.RefObject; + changeAll: () => void; +} diff --git a/src/types/SortType.ts b/src/types/SortType.ts new file mode 100644 index 0000000000..47d9fffd18 --- /dev/null +++ b/src/types/SortType.ts @@ -0,0 +1,5 @@ +export enum SortType { + 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/types/TodoListProps.ts b/src/types/TodoListProps.ts new file mode 100644 index 0000000000..649cd402da --- /dev/null +++ b/src/types/TodoListProps.ts @@ -0,0 +1,10 @@ +import { Todo } from "./Todo"; + +export interface TodoListProps { + filteredTodos: Todo[]; + tempTodo: Todo | null; + deleteId: number[]; + deleteData: (id: number) => Promise; + changeStatusData: (id: number) => void; + renameData: (id: number, newTitle: string) => Promise; +} 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'), +};