diff --git a/README.md b/README.md index 47a1add059..705dfdf87b 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://SerhiyShimko.github.io/react_todo-app-with-api/) and add it to the PR description. 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..7c56e66fa9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,36 @@ -/* 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 { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import React, { useState } from 'react'; +import './styles/todoapp.scss'; +import classNames from 'classnames'; +import { TodoApp } from './components/TodoApp'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [error, setError] = useState(''); return ( -
-

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

+
+

todos

+ + setError(prev)} /> -

Styles are already copied

-
+
+
+ ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..570be35d10 --- /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 = 4185; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = ({ userId, title, completed }: Omit) => { + return client.post(`/todos`, { userId, title, completed }); +}; + +export const updateTodo = ({ id, ...data }: Todo) => { + return client.patch(`/todos/${id}`, data); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 0000000000..8de25e1c4d --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,146 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; + +type Props = { + todosFromServer: Todo[] | null; + setTodos: (todos: Todo[]) => void; + deleteTodo: (id: number, todo: Todo) => void; +}; + +enum TypeFilter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} + +function getCount(serverTodos: Todo[] | null) { + let count = 0; + + if (serverTodos && serverTodos.length > 0) { + serverTodos.forEach(todo => { + if (todo.completed === false) { + count++; + } + }); + } + + return count; +} + +type ArrayButtons = { + title: string; + href: string; + typeFilter: TypeFilter; + id: number; + link: string; +}; + +const arrayButtons: ArrayButtons[] = [ + { + title: 'All', + href: '#/', + typeFilter: TypeFilter.All, + id: 1, + link: 'FilterLinkAll', + }, + { + title: 'Active', + href: '#/active', + typeFilter: TypeFilter.Active, + id: 2, + link: 'FilterLinkActive', + }, + { + title: 'Completed', + href: '#/completed', + typeFilter: TypeFilter.Completed, + id: 3, + link: 'FilterLinkCompleted', + }, +]; + +export const Filter: React.FC = ({ + setTodos, + todosFromServer, + deleteTodo, +}) => { + const [selectedFilter, setSelectedFilter] = useState(TypeFilter.All); + + const filtrationTodos = useCallback( + (buttonType: TypeFilter) => { + if (todosFromServer) { + if (buttonType === TypeFilter.Active) { + const newTodos: Todo[] = todosFromServer.filter((todo: Todo) => { + return todo.completed === false; + }); + + setTodos(newTodos); + } else if (buttonType === TypeFilter.Completed) { + const newTodos: Todo[] = todosFromServer.filter((todo: Todo) => { + return todo.completed === true; + }); + + setTodos(newTodos); + } else { + setTodos(todosFromServer); + } + } + }, + [todosFromServer, setTodos], + ); + + useEffect(() => { + filtrationTodos(selectedFilter); + }, [todosFromServer]); + + const clearCompleted = useCallback(async () => { + if (todosFromServer) { + const needDelete = todosFromServer + .filter(todo => todo.completed === true) + .map((todo: Todo) => { + return deleteTodo(todo.id, todo); + }); + + await Promise.all(needDelete); + } + }, [todosFromServer, deleteTodo]); + + return ( + + ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 0000000000..a07b259f57 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,297 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Filter } from './Filter'; +import { TodoList } from './TodoList'; +import * as Interaction from '../api/todos'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; + +type Props = { + setError: (str: string) => void; +}; + +function changeElement(todos: Todo[] | null, newTodo: Todo) { + if (todos) { + return todos.map(todo => (todo.id === newTodo.id ? { ...newTodo } : todo)); + } + + return todos; +} + +enum Change { + uncompleteAll = 'uncompleteAll', + completedAll = 'completedAll', +} + +function makeAll(todos: Todo[] | null, change: Change) { + if (todos) { + if (change === Change.completedAll) { + return todos.map(todo => + todo.completed === true ? { ...todo, completed: false } : todo, + ); + } else { + return todos.map(todo => { + if (todo.completed === false) { + return { ...todo, completed: true }; + } + + return todo; + }); + } + } + + return todos; +} + +export const TodoApp: React.FC = ({ setError }) => { + const [value, setValue] = useState(''); + const [todos, setTodos] = useState(null); + const [editing, setEditing] = useState(null); + const [loadingTodo, setLoadingTodo] = useState(false); + const [activeTodos, setActiveTodos] = useState([]); + const [todosFromServer, setTodosFormServer] = useState(null); + const [tempTodo, setTempTodo] = useState(null); + const [disabledInput, setDisabledInput] = useState(false); + const focusInput = useRef(null); + + //#region function + + const focusOnInput = useCallback(() => { + setTimeout(() => { + focusInput.current?.focus(); + }, 0); + }, []); + + const updateList = useCallback(() => { + Interaction.getTodos() + .then((serverTodos: Todo[] | null) => { + setTodos(serverTodos); + setTodosFormServer(serverTodos); + }) + .catch(() => { + setError('Unable to load todos'); + }) + .finally(() => { + setEditing(null); + setLoadingTodo(false); + // setActiveTodos([]); + setTimeout(() => { + setError(''); + }, 3000); + focusOnInput(); + }); + }, [setError, focusOnInput]); + + useEffect(() => { + updateList(); + }, [updateList]); + + const addTodo = useCallback( + (inputValue: string) => { + if (inputValue.trim()) { + setDisabledInput(true); + const newData: Omit = { + userId: Interaction.USER_ID, + title: inputValue.trim(), + completed: false, + }; + + setTempTodo({ ...newData, id: 0 }); + + setTimeout( + () => + Interaction.addTodo(newData) + .then(newTodos => { + setTodos(oldTodos => { + if (oldTodos) { + return [...oldTodos, newTodos]; + } + + return [newTodos]; + }); + setTodosFormServer(oldTodos => { + if (oldTodos) { + return [...oldTodos, newTodos]; + } + + return [newTodos]; + }); + setValue(''); + }) + .catch(() => { + setError('Unable to add a todo'); + }) + .finally(() => { + setTempTodo(null); + setDisabledInput(false); + focusOnInput(); + }), + 200, + ); + } else { + setValue(''); + setError('Title should not be empty'); + setTimeout(() => { + setError(''); + }, 3000); + } + }, + [setError, setTodosFormServer, setTodos, focusOnInput], + ); + + const deleteTodo = useCallback( + (id: number, activeTodo: Todo) => { + setLoadingTodo(true); + setActiveTodos(current => + current ? [...current, activeTodo] : [activeTodo], + ); + + return Interaction.deleteTodo(id) + .then(() => { + setTodos(prev => (prev ? prev.filter(todo => todo.id !== id) : [])); + setActiveTodos(current => + current ? current.filter(todo => todo.id !== id) : [], + ); + setTodosFormServer(prev => + prev ? prev.filter(todo => todo.id !== id) : [], + ); + }) + .catch(() => { + setError('Unable to delete a todo'); + }) + .finally(() => { + focusOnInput(); + }); + }, + [setError, setTodos, setTodosFormServer, focusOnInput], + ); + + const updateTodo = useCallback( + (newData: Todo) => { + if (newData.title.trim() === '') { + deleteTodo(newData.id, newData); + } else { + setLoadingTodo(true); + setActiveTodos(current => + current ? [...current, newData] : [newData], + ); + + return Interaction.updateTodo(newData) + .then(() => { + if (todosFromServer && todos) { + setTodosFormServer(currentTodos => + changeElement(currentTodos, newData), + ); + setTodos(currentTodos => changeElement(currentTodos, newData)); + setEditing(null); + setActiveTodos(current => + current ? current.filter(todo => todo.id !== newData.id) : [], + ); + } + }) + .catch(() => { + setError('Unable to update a todo'); + }) + .finally(() => {}); + } + }, + [setError, deleteTodo, todos, todosFromServer], + ); + + const allUpdateList = useCallback(() => { + if (todosFromServer) { + if (todosFromServer.every(todo => todo.completed === true)) { + Promise.all( + todosFromServer.map(todo => + updateTodo({ ...todo, completed: false }), + ), + ) + .then(() => { + setTodosFormServer(currentTodos => + makeAll(currentTodos, Change.completedAll), + ); + setTodos(currentTodos => + makeAll(currentTodos, Change.completedAll), + ); + }) + .finally(() => setActiveTodos([])); + } else { + Promise.all( + todosFromServer + .filter(todo => todo.completed === false) + .map(todo => updateTodo({ ...todo, completed: true })), + ) + .then(() => { + setTodosFormServer(currentTodos => + makeAll(currentTodos, Change.uncompleteAll), + ); + setTodos(currentTodos => + makeAll(currentTodos, Change.uncompleteAll), + ); + }) + .finally(() => setActiveTodos([])); + } + } + }, [updateTodo, todosFromServer]); + + //#endregion function + + return ( +
+
+ {!!todosFromServer?.length && ( +
+ + {todos && ( + updateTodo(prev)} + deleteTodo={(prevId, prevTodo) => deleteTodo(prevId, prevTodo)} + editing={editing} + setEditing={prev => setEditing(prev)} + loadingTodo={loadingTodo} + setActiveTodos={setActiveTodos} + activeTodos={activeTodos} + tempTodo={tempTodo} + /> + )} + + {todosFromServer && todosFromServer.length > 0 && ( + setTodos(prev)} + deleteTodo={(prevId, prevTodo) => deleteTodo(prevId, prevTodo)} + /> + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..c72fef1113 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; +import '../styles/todo.scss'; + +type Props = { + todos: Todo[]; + updateTodo: (todo: Todo) => void; + deleteTodo: (id: number, todo: Todo) => void; + editing: Todo | null; + setEditing: (todo: Todo | null) => void; + loadingTodo: boolean; + setActiveTodos: React.Dispatch>; + activeTodos: Todo[] | null; + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ + todos, + updateTodo, + deleteTodo, + editing, + setEditing, + loadingTodo, + setActiveTodos, + activeTodos, + tempTodo, +}) => { + const [editedValue, setEditedValue] = useState(''); + + const onSubmit = useCallback( + (currentTodo: Todo) => { + if (currentTodo.title !== editedValue) { + updateTodo({ ...currentTodo, title: editedValue.trim() }); + } else { + setEditing(null); + setActiveTodos([]); + setEditedValue(''); + } + }, + [editedValue, setActiveTodos, updateTodo, setEditing], + ); + + return ( +
+ {todos.map(todo => { + return ( +
{ + e.preventDefault(); + setEditing(todo); + setEditedValue(todo.title); + }} + > + + + {todo.id === editing?.id ? ( +
{ + e.preventDefault(); + onSubmit(todo); + }} + > + onSubmit(todo)} + onChange={e => setEditedValue(e.target.value)} + onKeyUp={e => { + if (e.key === 'Escape') { + setEditing(null); + setActiveTodos([]); + setEditedValue(''); + } + }} + autoFocus + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + +
activeTodo.id === todo.id, + )) || + todo.id === tempTodo?.id, + })} + > +
+
+
+
+ ); + })} + {tempTodo && ( +
+ + + + {tempTodo.title} + + +
+
+
+
+
+ )} +
+ ); +}; 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'), +};