diff --git a/README.md b/README.md index 47a1add059..1aa92e37d3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ and implement the ability to toggle and rename todos. ## Toggling a todo status Toggle the `completed` status on `TodoStatus` change: + - Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - covered the todo with a loader overlay while waiting for API response; - the status should be changed on success; @@ -38,6 +39,7 @@ Implement the ability to edit a todo title on double click: - or the deletion error message if we tried to delete the todo. ## If you want to enable tests + - open `cypress/integration/page.spec.js` - replace `describe.skip` with `describe` for the root `describe` @@ -47,4 +49,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://Sviatoslav593.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..dbd6d0676b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8701,6 +8701,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" }, diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..4a1a73b899 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,290 @@ -/* 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, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + addTodos, + deleteTodos, + getTodos, + updateTodos, + USER_ID, +} from './api/todos'; +import { TodoForm } from './components/TodoForm'; +import { Todo } from './types/Todo'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import cn from 'classnames'; +import { Selected } from './types/enums/Selected'; +import { ErrorMessage } from './types/enums/ErrorMessage'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [selected, setSelected] = useState(Selected.all); + + const [tempTodo, setTempTodo] = useState(null); + const [todoLoading, setTodoLoading] = useState(false); + const [todoIdLoading, setTodoIdLoading] = useState(null); + + const [todosIdsLoading, setTodosIdsLoading] = useState([]); + + const [visibleTodos, setVisibleTodos] = useState([]); + + useEffect(() => { + setVisibleTodos( + todos.filter(todo => { + if (selected === Selected.completed) { + return todo.completed; + } + + if (selected === Selected.active) { + return !todo.completed; + } + + return todos; + }), + ); + }, [selected, todos]); + + // const filteredTodos = todos.filter(todo => { + // if (filter === FilterField.completed) { + // return todo.completed; + // } + + // if (filter === FilterField.active) { + // return !todo.completed; + // } + + // return todos; + // }); + + const [errorMessage, setErrorMessage] = useState( + ErrorMessage.noErrors, + ); + + useEffect(() => { + setErrorMessage(ErrorMessage.noErrors); + + getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessage.loadError)); + }, []); + + useEffect(() => { + if (errorMessage) { + setTimeout(() => { + setErrorMessage(ErrorMessage.noErrors); + }, 3000); + } + }, [errorMessage]); + + const handleActiveTodosButton = () => { + setSelected(Selected.active); + }; + + const handleCompletedTodosButton = () => { + setSelected(Selected.completed); + }; + + const handleAllTodosButton = () => { + setSelected(Selected.all); + }; + + const addTodo = (title: string) => { + setTodoLoading(true); + setErrorMessage(ErrorMessage.noErrors); + setTempTodo({ + id: 0, + title: title, + userId: USER_ID, + completed: false, + }); + + return addTodos(title) + .then(newTodo => setTodos(currentTodos => [...currentTodos, newTodo])) + .catch(error => { + setErrorMessage(ErrorMessage.addError); + throw error; + }) + .finally(() => { + setTempTodo(null); + setTodoLoading(false); + }); + }; + + const deleteTodo = (todoId: number) => { + setTodoIdLoading(todoId); + + return deleteTodos(todoId) + .then(() => { + setTodos(currentTodos => { + return [...currentTodos.filter(todo => todo.id !== todoId)]; + }); + }) + .catch(error => { + setErrorMessage(ErrorMessage.deleteError); + setTodos(todos); + throw error; + }) + .finally(() => { + setTodoIdLoading(null); + }); + }; + + const updateTodo = (id: number, completed: boolean, title: string) => { + setErrorMessage(ErrorMessage.noErrors); + setTodoIdLoading(id); + + return updateTodos(id, completed, title) + .then(updatedTodo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(todo => { + return todo.id === id; + }); + + newTodos.splice(index, 1, updatedTodo); + + return newTodos; + }); + }) + .catch(error => { + setErrorMessage(ErrorMessage.updateError); + + throw error; + }) + .finally(() => setTodoIdLoading(null)); + }; + + const SetAllTodosCompleted = () => { + const uncompletedTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + + const todosToMap = + todos.length === completedTodos.length ? todos : uncompletedTodos; + + setTodosIdsLoading(uncompletedTodos.map(todo => todo.id)); + + return Promise.all( + todosToMap.map(uncompletedTodo => { + updateTodos( + uncompletedTodo.id, + !uncompletedTodo.completed, + uncompletedTodo.title, + ) + .then(updatedTodo => + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = currentTodos.findIndex( + todo => todo.id === updatedTodo.id, + ); + + newTodos.splice(index, 1, updatedTodo); + + return newTodos; + }), + ) + .catch(() => { + setErrorMessage(ErrorMessage.updateError); + }) + .finally(() => setTodosIdsLoading([])); + }), + ); + }; + + const handleAllActiveDelete = () => { + setTodoLoading(true); + + const completedTodos = todos.filter(todo => todo.completed); + + setTodosIdsLoading(completedTodos.map(todo => todo.id)); + + return Promise.all( + completedTodos.map(completedTodo => + deleteTodos(completedTodo.id) + .then(() => { + setTodos(current => + current.filter(todo => todo.id !== completedTodo.id), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.deleteError); + }), + ), + ).finally(() => setTodosIdsLoading([])); + }; + if (!USER_ID) { return ; } return ( -
-

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

+
+

todos

+ +
+
+ {/* Add a todo on form submit */} + +
+ {todos && errorMessage !== 'Unable to load todos' && ( + + )} + + {/* Hide the footer if there are no todos */} + {todos.length > 0 && ( +
+ )} +
-

Styles are already copied

-
+ {/* 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..e635a4f490 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,40 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4147; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here + +export const addTodos = ( + title: string, + completed = false, + userId = USER_ID, +) => { + return client.post(`/todos`, { + title, + completed, + userId, + }); +}; + +export const deleteTodos = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodos = ( + todoId: number, + completed: boolean, + title?: string, + userId = USER_ID, +) => { + return client.patch(`/todos/${todoId}`, { + todoId, + completed, + title, + userId, + }); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..c8721fb1c7 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from 'react'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; +import { Selected } from '../types/enums/Selected'; + +type Props = { + todos: Todo[]; + handleActiveTodosButton: () => void; + handleCompletedTodosButton: () => void; + handleAllTodosButton: () => void; + handleAllActiveDelete: () => void; + selected: Selected; +}; + +export const Footer: React.FC = ({ + todos, + handleActiveTodosButton, + handleCompletedTodosButton, + handleAllTodosButton, + handleAllActiveDelete, + selected, +}) => { + const [uncompletedTodosLength, setUncompletedTodosLength] = useState( + todos.filter(todo => !todo.completed).length, + ); + + const [completedTodosLength, setCompletedTodosLength] = useState( + todos.filter(todo => todo.completed).length, + ); + + useEffect(() => { + setUncompletedTodosLength(todos.filter(todo => !todo.completed).length); + setCompletedTodosLength(todos.filter(todo => todo.completed).length); + }, [todos]); + + return ( +
+ + {uncompletedTodosLength} 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/TodoForm.tsx b/src/components/TodoForm.tsx new file mode 100644 index 0000000000..3b46fc5c88 --- /dev/null +++ b/src/components/TodoForm.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ErrorMessage } from '../types/enums/ErrorMessage'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; + +type Props = { + onAdd: (arg0: string) => Promise; + setError: React.Dispatch>; + setAllCompleted: () => Promise; + todos: Todo[]; +}; + +export const TodoForm: React.FC = ({ + onAdd, + setError, + setAllCompleted, + todos, +}) => { + const isToggleAllActive = + todos.length > 0 && + todos.length === todos.filter(todo => todo.completed).length; + + const [value, setValue] = useState(''); + const [isDisabled, setIsDisabled] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }); + + const handleInput = (event: React.ChangeEvent) => { + setValue(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const normalizedTitle = value.trim(); + + setIsDisabled(true); + + if (normalizedTitle.length === 0) { + setIsDisabled(false); + + return setError(ErrorMessage.emptyTitle); + } + + return onAdd(normalizedTitle) + .then(() => { + setValue(''); + }) + .finally(() => { + setIsDisabled(false); + }); + }; + + return ( + <> + {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( + + +
+
+
+
+ + )} + + {/* Remove button appears only on hover */} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+ ); + })} + + {tempTodo !== null && ( +
+ + + + {tempTodo.title} + + + + + {/* 'is-active' class puts this modal on top of the todo */} +
+
+
+
+
+ )} + + ); +}; 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/enums/ErrorMessage.ts b/src/types/enums/ErrorMessage.ts new file mode 100644 index 0000000000..7a005d5acd --- /dev/null +++ b/src/types/enums/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + emptyTitle = 'Title should not be empty', + loadError = 'Unable to load todos', + addError = 'Unable to add a todo', + deleteError = 'Unable to delete a todo', + updateError = 'Unable to update a todo', + noErrors = '', +} diff --git a/src/types/enums/Selected.ts b/src/types/enums/Selected.ts new file mode 100644 index 0000000000..0ec93e6bd0 --- /dev/null +++ b/src/types/enums/Selected.ts @@ -0,0 +1,5 @@ +export enum Selected { + 'all', + 'completed', + 'active', +} 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'), +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26..78bb00370b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,8 @@ { "extends": "@mate-academy/students-ts-config", - "include": [ - "src" - ], + "include": ["src"], "compilerOptions": { + "jsx": "react", "sourceMap": false, "types": ["node", "cypress"] }