diff --git a/README.md b/README.md index 47a1add059..97801803fa 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://aholubko.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..900ed3ed66 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,268 @@ -/* 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'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +import { FilterBy } from './types/FilterBy'; +import { TodoFooter } from './components/TodoFooter'; +import { ErrorNotification } from './components/ErrorNotification'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoItem } from './components/TodoItem'; export const App: React.FC = () => { + //#region State + const [todos, setTodos] = useState([]); + const [filterBy, setFilterBy] = useState(FilterBy.All); + const [errorMessage, setErrorMessage] = useState(''); + + const [title, setTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [processingIds, setProcessingIds] = useState([]); + //#endregion + + //#region Refs + const inputRef = useRef(null); + //#endregion + + //#region Derived values + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodosCount = todos.filter(todo => todo.completed).length; + const allCompleted = todos.length > 0 && activeTodosCount === 0; + + let visibleTodos = todos; + + if (filterBy === FilterBy.Active) { + visibleTodos = visibleTodos.filter(todo => !todo.completed); + } + + if (filterBy === FilterBy.Completed) { + visibleTodos = visibleTodos.filter(todo => todo.completed); + } + //#endregion + + //#region Handlers + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (title.trim() === '') { + setErrorMessage('Title should not be empty'); + + return; + } + + const newTempTodo = { + id: 0, + title: title.trim(), + completed: false, + userId: USER_ID, + }; + + setErrorMessage(''); + setIsAdding(true); + + setTempTodo(newTempTodo); + + addTodo(title.trim()) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setTitle(''); + setTempTodo(null); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + setTempTodo(null); + }) + .finally(() => { + setIsAdding(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }); + }; + + const handleDelete = (todoId: number) => { + setErrorMessage(''); + + setProcessingIds(currentIds => [...currentIds, todoId]); + + return deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setProcessingIds(currentIds => currentIds.filter(id => id !== todoId)); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }); + }; + + const handleClearCompleted = () => { + const completedTodos = todos.filter(todo => todo.completed); + + return Promise.all(completedTodos.map(todo => handleDelete(todo.id))); + }; + + const handleToggle = (todo: Todo) => { + const newCompletedStatus = !todo.completed; + + setErrorMessage(''); + + setProcessingIds(currentIds => [...currentIds, todo.id]); + + return updateTodo(todo.id, { completed: newCompletedStatus }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(currentTodo => + currentTodo.id === todo.id ? updatedTodo : currentTodo, + ), + ); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setProcessingIds(currentIds => currentIds.filter(id => id !== todo.id)); + }); + }; + + const handleToggleAll = () => { + const newCompletedStatus = !allCompleted; + + const todosToUpdate = todos.filter( + todo => todo.completed !== newCompletedStatus, + ); + + return Promise.all(todosToUpdate.map(todo => handleToggle(todo))); + }; + + const handleUpdate = (todoId: number, newTitle: string) => { + setErrorMessage(''); + + setProcessingIds(currentIds => [...currentIds, todoId]); + + return updateTodo(todoId, { title: newTitle }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(currentTodo => + currentTodo.id === todoId ? updatedTodo : currentTodo, + ), + ); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + + throw new Error(); + }) + .finally(() => { + setProcessingIds(currentIds => currentIds.filter(id => id !== todoId)); + }); + }; + + //#endregion + + //#region Effects + useEffect(() => { + setErrorMessage(''); + + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')); + }, []); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!errorMessage) { + return; + } + + const timerId = window.setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => { + window.clearTimeout(timerId); + }; + }, [errorMessage]); + //#endregion + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ 0} + /> + + {(todos.length > 0 || tempTodo) && ( +
+ {visibleTodos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ )} + + {todos.length > 0 && ( + + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..afb17b79e6 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,25 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4192; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const addTodo = (title: string) => { + return client.post('/todos', { + userId: USER_ID, + title: title.trim(), + completed: false, + }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, data: Partial) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..0c802b6820 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + setErrorMessage: (value: string) => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 0000000000..bd5599f630 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FilterBy } from '../types/FilterBy'; + +type Props = { + activeTodosCount: number; + completedTodosCount: number; + filterBy: FilterBy; + setFilterBy: (filterBy: FilterBy) => void; + handleClearCompleted: () => void; +}; + +export const TodoFooter: React.FC = ({ + activeTodosCount, + completedTodosCount, + filterBy, + setFilterBy, + handleClearCompleted, +}) => { + const filters = [ + { + title: 'All', + href: '#/', + dataCy: 'FilterLinkAll', + value: FilterBy.All, + }, + + { + title: 'Active', + href: '#/active', + dataCy: 'FilterLinkActive', + value: FilterBy.Active, + }, + + { + title: 'Completed', + href: '#/completed', + dataCy: 'FilterLinkCompleted', + value: FilterBy.Completed, + }, + ]; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 0000000000..e708375ce9 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + allCompleted: boolean; + handleSubmit: (event: React.FormEvent) => void; + title: string; + setTitle: (value: string) => void; + isAdding: boolean; + inputRef: React.RefObject; + handleToggleAll: () => void; + hasTodos: boolean; +}; + +export const TodoHeader: React.FC = ({ + allCompleted, + handleSubmit, + title, + setTitle, + isAdding, + inputRef, + handleToggleAll, + hasTodos, +}) => { + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..9d5f877d4e --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,126 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + onDelete: (todoId: number) => void; + isProcessing: boolean; + onToggle: (todo: Todo) => void; + onUpdate: (todoId: number, newTitle: string) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + onDelete, + isProcessing, + onToggle, + onUpdate, +}) => { + const [isEditing, setIsEdititng] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const editInputRef = useRef(null); + + const handleRename = () => { + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + setIsEdititng(false); + + return; + } + + if (trimmedTitle === '') { + onDelete(todo.id); + + return; + } + + onUpdate(todo.id, trimmedTitle).then(() => { + setIsEdititng(false); + }); + }; + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [isEditing]); + + return ( +
+ + + {!isEditing ? ( + <> + setIsEdititng(true)} + > + {todo.title} + + + + + ) : ( +
{ + event.preventDefault(); + handleRename(); + }} + > + setNewTitle(e.target.value)} + onBlur={handleRename} + onKeyDown={event => { + if (event.key === 'Escape') { + setNewTitle(todo.title); + setIsEdititng(false); + } + }} + ref={editInputRef} + /> +
+ )} + +
+
+
+
+
+ ); +}; diff --git a/src/types/FilterBy.ts b/src/types/FilterBy.ts new file mode 100644 index 0000000000..088e9526ce --- /dev/null +++ b/src/types/FilterBy.ts @@ -0,0 +1,5 @@ +export enum FilterBy { + 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'), +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26..5d2a3022d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,17 @@ { - "extends": "@mate-academy/students-ts-config", - "include": [ - "src" - ], "compilerOptions": { - "sourceMap": false, - "types": ["node", "cypress"] - } -} + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} \ No newline at end of file