diff --git a/README.md b/README.md index 47a1add059..8b25a71801 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://Lazarin123.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..05f39aa2f1 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,9 +1183,9 @@ } }, "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, "dependencies": { "@octokit/rest": "^17.11.2", 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..e4522a146c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,267 @@ -/* 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 { USER_ID } from './api/todos'; +import { TodoContext } from './context/TodoContext'; +import { ERROR_TYPE, FILTER_TYPE } from './consts/constants'; export const App: React.FC = () => { + const { + todos, + todoTitle, + filteredTodos, + filterBy, + setFilterBy, + loadingIds, + error, + editingId, + setEditingId, + unfinishedTodos, + editTodoTitle, + setEditTodoTitle, + inputRef, + editTodoRef, + tempTodo, + handleEditingTodo, + handleTitleChange, + handleToggleAll, + handleEditFormSubmission, + handleDeleteTodo, + handleCloseError, + handleTodoToggle, + handleClearCompleted, + handleSubmitNewTodo, + } = React.useContext(TodoContext); + + const hasCompletedTodo = todos.some(todo => todo.completed); + + const allTodosCompleted = todos.every(todo => todo.completed); + if (!USER_ID) { return ; } return ( -
-

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

+
+

todos

+ +
+
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ +
+ {/* This is a completed todo */} + {filteredTodos.map(todo => ( +
{ + setEditingId(todo.id); + setEditTodoTitle(todo.title); + }} + className={`todo ${todo.completed ? 'completed' : ''}`} + > + + + {editingId === todo.id ? ( +
{ + event.preventDefault(); + handleEditFormSubmission(todo); + }} + > + handleEditFormSubmission(todo)} + className="todo__title-field" + onKeyUp={event => { + if (event.key === 'Escape') { + setEditingId(null); + setEditTodoTitle(todo.title); + } + }} + onChange={event => handleEditingTodo(event)} + value={editTodoTitle} + placeholder="Empty todo will be deleted" + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + + {/* overlay will cover the todo while it is being deleted or updated */} + {/* {fix is loading logic rn it sets the loader on all todos, even already loaded} */} +
+
+
+
+
+ ))} -

Styles are already copied

-
+ {tempTodo && ( +
+ + + + {tempTodo.title} + + + + {/* overlay will cover the todo while it is being deleted or updated */} + {/* {fix is loading logic rn it sets the loader on all todos, even already loaded} */} +
+
+
+
+
+ )} +
+ + {/* Hide the footer if there are no todos */} + {todos.length > 0 && ( + + )} + + + {/* DON'T use conditional rendering to hide the notification */} + {/* 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 0000000000..7971967673 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,47 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClients'; + +export const USER_ID = 3716; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here + +export const postTodo = (todo: Omit) => { + return client.post(`/todos`, todo); +}; + +export const patchTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const patchAllTodos = (todos: Todo[]) => { + const completedTodos = !todos.every(todo => todo.completed); + const alteredTodos = Promise.all([ + ...todos + .filter(todo => todo.completed !== completedTodos) + .map(todo => + client.patch(`/todos/${todo.id}`, { + ...todo, + completed: completedTodos, + }), + ), + ]); + + return alteredTodos; +}; + +export const deleteCompletedTodos = (todos: Todo[]) => { + const completedTodos = todos.filter(todo => todo.completed); + const deletedTodos = Promise.allSettled([ + ...completedTodos.map(todo => client.delete(`/todos/${todo.id}`)), + ]); + + return deletedTodos; +}; diff --git a/src/consts/constants.ts b/src/consts/constants.ts new file mode 100644 index 0000000000..67bdb30d57 --- /dev/null +++ b/src/consts/constants.ts @@ -0,0 +1,14 @@ +export const FILTER_TYPE = { + ALL: 'all', + ACTIVE: 'active', + COMPLETED: 'completed', +} as const; + +export const ERROR_TYPE = { + LOAD: 'loadError', + TITLE: 'titleError', + ADD: 'addError', + DELETE: 'deleteError', + UPDATE: 'updateError', + NONE: 'none', +} as const; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 0000000000..7c904666c9 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,344 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { + getTodos, + postTodo, + patchTodo, + deleteTodo, + patchAllTodos, + USER_ID, +} from '../api/todos'; +import { FILTER_TYPE, ERROR_TYPE } from '../consts/constants'; + +type TodoContextType = { + unfinishedTodos: Todo[]; + todos: Todo[]; + setTodos: React.Dispatch>; + todoTitle: string; + setTodoTitle: React.Dispatch>; + filteredTodos: Todo[]; + filterBy: FilterType; + setFilterBy: React.Dispatch>; + loadingIds: number[]; + setLoadingIds: React.Dispatch>; + error: Errors; + setError: React.Dispatch>; + editTodoTitle: string; + setEditTodoTitle: React.Dispatch>; + editingId: number | null; + setEditingId: React.Dispatch>; + tempTodo: Todo | null; + setTempTodo: React.Dispatch>; + inputRef: React.RefObject; + editTodoRef: React.RefObject; + handleSubmitNewTodo: (event: React.FormEvent) => void; + handleDeleteTodo: (id: number) => void; + handleToggleAll: (todos: Todo[]) => void; + handleEditingTodo: (event: React.ChangeEvent) => void; + handleEditFormSubmission: (todo: Todo) => void; + handleClearCompleted: (Todos: Todo[]) => void; + handleCloseError: () => void; + handleTodoToggle: (todo: Todo) => void; + handleTitleChange: (event: React.ChangeEvent) => void; +}; + +export type FilterType = 'all' | 'active' | 'completed'; + +type Errors = + | 'loadError' + | 'titleError' + | 'addError' + | 'deleteError' + | 'updateError' + | 'none'; + +export const TodoContext = React.createContext({ + unfinishedTodos: [], + todos: [], + setTodos: () => {}, + todoTitle: '', + setTodoTitle: () => {}, + filteredTodos: [], + filterBy: FILTER_TYPE.ALL, + loadingIds: [], + setLoadingIds: () => {}, + setFilterBy: () => {}, + error: ERROR_TYPE.NONE, + setError: () => {}, + editTodoTitle: '', + setEditTodoTitle: () => {}, + editingId: null, + setEditingId: () => {}, + tempTodo: null, + setTempTodo: () => {}, + inputRef: React.createRef(), + editTodoRef: React.createRef(), + handleSubmitNewTodo: () => {}, + handleTodoToggle: () => {}, + handleEditingTodo: () => {}, + handleEditFormSubmission: () => {}, + handleClearCompleted: () => {}, + handleToggleAll: () => {}, + handleCloseError: () => {}, + handleDeleteTodo: () => {}, + handleTitleChange: () => {}, +}); + +export const TodoProvider = ({ children }: { children: React.ReactNode }) => { + const [todos, setTodos] = React.useState([]); + const [todoTitle, setTodoTitle] = React.useState(''); + const [filterBy, setFilterBy] = React.useState(FILTER_TYPE.ALL); + const [loadingIds, setLoadingIds] = React.useState([]); + const [error, setError] = React.useState(ERROR_TYPE.NONE); + const [editTodoTitle, setEditTodoTitle] = React.useState(''); + const [editingId, setEditingId] = React.useState(null); + const [tempTodo, setTempTodo] = React.useState(null); + const [shouldFocusInput, setShouldFocusInput] = React.useState(true); + + const errorRef = React.useRef(null); + + const editTodoRef = React.useRef(null); + + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (shouldFocusInput && inputRef.current) { + inputRef.current.focus(); + setShouldFocusInput(false); + } + }, [shouldFocusInput]); + + React.useEffect(() => { + if (editingId && editTodoRef.current) { + editTodoRef.current.focus(); + } + }, [editingId]); + + const handleError = React.useCallback((errorType: Errors) => { + if (errorRef.current) { + window.clearTimeout(errorRef.current); + } + + setError(errorType); + + const newTimeout = window.setTimeout(() => { + setError(ERROR_TYPE.NONE); + }, 3000); + + errorRef.current = newTimeout; + }, []); + + React.useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => handleError(ERROR_TYPE.LOAD)); + }, [handleError]); + + const filteredTodos = React.useMemo(() => { + switch (filterBy) { + case FILTER_TYPE.ACTIVE: + return todos.filter(todo => !todo.completed); + case FILTER_TYPE.COMPLETED: + return todos.filter(todo => todo.completed); + default: + return todos; + } + }, [todos, filterBy]); + + const unfinishedTodos = todos.filter(todo => !todo.completed); + + const handleSubmitNewTodo = (event: React.FormEvent) => { + event.preventDefault(); + + if (!todoTitle.trim()) { + handleError(ERROR_TYPE.TITLE); + setShouldFocusInput(true); + + return; + } + + setLoadingIds(prev => [...prev, 0]); + + const newTempTodo = { + id: 0, + title: todoTitle.trim(), + completed: false, + userId: USER_ID, + }; + + setTempTodo(newTempTodo); + + const newTodo = { + title: todoTitle.trim(), + completed: false, + userId: USER_ID, + }; + + postTodo(newTodo) + .then(responseTodo => { + setTodos([...todos, responseTodo]); + setTempTodo(null); + setTodoTitle(''); + setShouldFocusInput(true); + }) + .catch(() => { + setTempTodo(null); + setTodoTitle(prevTitle => prevTitle.trim()); + handleError(ERROR_TYPE.ADD); + setShouldFocusInput(true); + }) + .finally(() => setLoadingIds(prev => prev.filter(id => id !== 0))); + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + setTodoTitle(event.target.value); + }; + + const handleEditingTodo = (event: React.ChangeEvent) => { + setEditTodoTitle(event.target.value); + }; + + const handleDeleteTodo = ( + id: number, + onSuccess = () => {}, + onError = () => {}, + ) => { + setLoadingIds(prev => [...prev, id]); + deleteTodo(id) + .then(() => { + setTodos(todos.filter(t => t.id !== id)); + setShouldFocusInput(true); + onSuccess(); + }) + .catch(() => { + handleError(ERROR_TYPE.DELETE); + onError(); + }) + .finally(() => setLoadingIds(prev => prev.filter(i => i !== id))); + }; + + const handleUpdatedTodos = ( + id: number, + updatedTodo: Todo, + onSuccess = () => {}, + ) => { + setLoadingIds(prev => [...prev, id]); + patchTodo(updatedTodo) + .then(() => { + setTodos(todos.map(t => (t.id === id ? { ...t, ...updatedTodo } : t))); + onSuccess(); + }) + .catch(() => handleError(ERROR_TYPE.UPDATE)) + .finally(() => setLoadingIds(prev => prev.filter(i => i !== id))); + }; + + const handleEditFormSubmission = (todo: Todo) => { + const trimedEditTodoTitle = editTodoTitle.trim(); + + if (trimedEditTodoTitle === todo.title) { + setEditingId(null); + + return; + } + + if (!trimedEditTodoTitle) { + handleDeleteTodo(todo.id, () => { + setEditingId(null); + }); + + return; + } + + handleUpdatedTodos(todo.id, { ...todo, title: trimedEditTodoTitle }, () => { + setEditingId(null); + }); + }; + + const handleTodoToggle = (todo: Todo) => { + setLoadingIds(prev => [...prev, todo.id]); + patchTodo({ ...todo, completed: !todo.completed }) + .then(() => { + setTodos( + todos.map(t => + t.id === todo.id ? { ...t, completed: !t.completed } : t, + ), + ); + }) + .catch(() => handleError(ERROR_TYPE.UPDATE)) + .finally(() => setLoadingIds(prev => prev.filter(id => id !== todo.id))); + }; + + const handleCloseError = () => { + setError(ERROR_TYPE.NONE); + }; + + const handleToggleAll = (passedTodos: Todo[]) => { + patchAllTodos(passedTodos); + + setTodos(prevTodos => { + const completed = !prevTodos.every(todo => todo.completed); + + return prevTodos.map(todo => ({ ...todo, completed })); + }); + }; + + const handleClearCompleted = (completedTodos: Todo[]) => { + const completedIds = completedTodos + .filter(todo => todo.completed) + .map(todo => todo.id); + + const promises = completedIds.map(id => deleteTodo(id)); + + Promise.allSettled(promises).then(results => { + const successfullyDeletedIds = completedIds.filter( + (id, i) => results[i].status === 'fulfilled', + ); + + if (successfullyDeletedIds.length > 0) { + setTodos(prevTodos => + prevTodos.filter(todo => !successfullyDeletedIds.includes(todo.id)), + ); + } + + if (successfullyDeletedIds.length < completedIds.length) { + handleError(ERROR_TYPE.DELETE); + } + + setShouldFocusInput(true); + }); + }; + + const value = { + unfinishedTodos, + todos, + setTodos, + todoTitle, + setTodoTitle, + filteredTodos, + filterBy, + setFilterBy, + loadingIds, + setLoadingIds, + error, + setError, + editTodoTitle, + setEditTodoTitle, + editingId, + setEditingId, + tempTodo, + setTempTodo, + inputRef, + editTodoRef, + handleEditingTodo, + handleClearCompleted, + handleEditFormSubmission, + handleSubmitNewTodo, + handleToggleAll, + handleCloseError, + handleTodoToggle, + handleDeleteTodo, + handleTitleChange, + }; + + return {children}; +}; diff --git a/src/index.tsx b/src/index.tsx index fee7a5959b..a0716773e8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,5 +5,10 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './context/TodoContext'; -createRoot(document.getElementById('root') as HTMLDivElement).render(); +createRoot(document.getElementById('root') as HTMLDivElement).render( + + + , +); 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/fetchClients.ts b/src/utils/fetchClients.ts new file mode 100644 index 0000000000..708ac4c17b --- /dev/null +++ b/src/utils/fetchClients.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'), +};