diff --git a/README.md b/README.md index 47a1add059..5cf34863a3 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://tavokina.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..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/src/App.tsx b/src/App.tsx index 81e011f432..ce0e642bed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,261 @@ -/* 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 * as todoService from './api/todos'; +import { Todo } from './types/Todo'; +import classNames from 'classnames'; +import { TodoList } from './components/TodoList'; +import { TodoItem } from './components/TodoItem'; +import { NewTodo } from './types/NewTodo'; +import { ErrorMessages } from './types/ErrorMessages'; +import { Status } from './types/Status'; +import { Filter } from './components/Filter'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [title, setTitle] = useState(''); + const [todoStatus, setTodoStatus] = useState(Status.All); + const [errorMessage, setErrorMessage] = useState(null); + const [tempTodo, setTempTodo] = useState(null); + const [loadingIds, setLoadingIds] = useState([]); + + const normalizedTitle = title.trim(); + const isAllCompleted = todos.every(todo => todo.completed); + const hasCompleted = todos.some(todo => todo.completed); + const filteringByStatus = todos.filter(todo => { + if (todoStatus === Status.Active) { + return !todo.completed; + } + + if (todoStatus === Status.Completed) { + return todo.completed; + } + + return true; + }); + + const newTodo = (todoTitle: string): NewTodo => { + return { + userId: todoService.USER_ID, + title: todoTitle, + completed: false, + }; + }; + + const handleAppSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!normalizedTitle) { + setErrorMessage(ErrorMessages.Title); + + return; + } + + setTempTodo({ + ...newTodo(normalizedTitle), + id: 0, + }); + todoService + .addTodo(newTodo(normalizedTitle)) + .then(addedTodo => { + setTodos([...todos, addedTodo]); + setTitle(''); + setTempTodo(null); + }) + .catch(() => { + setErrorMessage(ErrorMessages.Add); + setTempTodo(null); + }); + }; + + /* get todos */ + useEffect(() => { + todoService + .getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessages.Load)); + }, []); + + /* errors */ + useEffect(() => { + if (!errorMessage) { + return; + } + + const timer = setTimeout(() => setErrorMessage(null), 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + /* input focus */ + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [tempTodo]); + + /* deleting */ + function deleteTodo(todoId: number) { + setLoadingIds(prev => [...prev, todoId]); + todoService + .deleteTodo(todoId) + .then(() => setTodos(prev => prev.filter(todo => todo.id !== todoId))) + .catch(() => { + setErrorMessage(ErrorMessages.Delete); + setTempTodo(null); + }) + .finally(() => { + setLoadingIds(loadingIds.filter(id => id !== todoId)); + inputRef?.current?.focus(); + }); + } + + const clearCompleted = () => { + todos.filter(todo => todo.completed).forEach(todo => deleteTodo(todo.id)); + }; + + /* toggle */ + const onToggle = (todo: Todo) => { + setLoadingIds(prev => [...prev, todo.id]); + const toggled = { ...todo, completed: !todo.completed }; + + todoService + .updateTodo(toggled) + .then(toggledTodo => { + setTodos(prev => + prev.map(t => (t.id === toggledTodo.id ? toggledTodo : t)), + ); + }) + .catch(() => setErrorMessage(ErrorMessages.Update)) + .finally(() => setLoadingIds(prev => prev.filter(id => id !== todo.id))); + }; + + const toggleAll = () => { + if (hasCompleted && !isAllCompleted) { + return todos.filter(todo => !todo.completed).forEach(t => onToggle(t)); + } + + return todos.forEach(todo => onToggle(todo)); + }; + + /* update */ + const onUpdate = (updatedTodo: Todo) => { + setLoadingIds(prev => [...prev, updatedTodo.id]); + + return todoService + .updateTodo(updatedTodo) + .then(() => + setTodos(prev => + prev.map(t => (t.id === updatedTodo.id ? updatedTodo : t)), + ), + ) + .catch(() => { + setErrorMessage(ErrorMessages.Update); + throw new Error(); + }) + .finally(() => + setLoadingIds(prev => prev.filter(id => id !== updatedTodo.id)), + ); + }; + + if (!todoService.USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ + + + {tempTodo && ( + {}} + isComplete={() => {}} + isChange={() => Promise.resolve()} + /> + )} + + {/* Hide the footer if there are no todos */} + {todos.length > 0 && ( +
+ + {todos.filter(todo => !todo.completed).length} items left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + +
+ )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..ee75c9ef78 --- /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 = 4201; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (todo: Omit) => { + return client.post('/todos', todo); +}; + +export const updateTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 0000000000..73c5d75cb5 --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import { Status } from '../types/Status'; +import { Dispatch, SetStateAction } from 'react'; + +type Props = { + todoStatus: Status; + onChangeStatus: Dispatch>; +}; + +export const Filter: React.FC = ({ todoStatus, onChangeStatus }) => { + return ( + + ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..2295dff133 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,138 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useEffect, useRef, useState } from 'react'; + +type Props = { + todo: Todo; + deleteItem: (id: number) => void; + isLoading: boolean; + isComplete: (obj: Todo) => void; + isChange: (obj: Todo) => Promise; +}; +export const TodoItem: React.FC = ({ + todo, + deleteItem, + isLoading, + isComplete, + isChange, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(todo.title); + + const editInputRef = useRef(null); + const isCancelledRef = useRef(false); + + const handleSubmit = () => { + const normalizedTitle = editingTitle.trim(); + + if (!normalizedTitle) { + deleteItem(todo.id); + + return; + } + + if (normalizedTitle !== todo.title) { + isChange({ ...todo, title: editingTitle.trim() }) + .then(() => setIsEditing(false)) + .catch(() => editInputRef.current?.focus()); + } + + if (normalizedTitle === todo.title) { + setIsEditing(false); + } + }; + + const submitOnBlur = () => { + if (!isCancelledRef.current) { + handleSubmit(); + } + + isCancelledRef.current = false; + }; + + const cancelSubmit = (eventKey: string) => { + if (eventKey === 'Escape') { + setIsEditing(false); + setEditingTitle(todo.title); + isCancelledRef.current = true; + } + }; + + useEffect(() => { + editInputRef.current?.focus(); + }, [isEditing]); + + + return ( + /* eslint-disable jsx-a11y/label-has-associated-control */ +
+ + + {!isEditing && ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} + + {isEditing && ( +
{ + e.preventDefault(); + handleSubmit(); + }} + > + setEditingTitle(e.target.value)} + onBlur={submitOnBlur} + onKeyUp={e => cancelSubmit(e.key)} + /> +
+ )} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..7f672fc72d --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,33 @@ +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + onDelete: (id: number) => void; + loadingIds: number[]; + onChecked: (obj: Todo) => void; + onChange: (obj: Todo) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + onDelete, + loadingIds, + onChecked, + onChange, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/types/ErrorMessages.ts b/src/types/ErrorMessages.ts new file mode 100644 index 0000000000..5c23ab5f81 --- /dev/null +++ b/src/types/ErrorMessages.ts @@ -0,0 +1,7 @@ +export enum ErrorMessages { + Load = 'Unable to load todos', + Title = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} diff --git a/src/types/NewTodo.ts b/src/types/NewTodo.ts new file mode 100644 index 0000000000..c5afe7a172 --- /dev/null +++ b/src/types/NewTodo.ts @@ -0,0 +1,5 @@ +export interface NewTodo { + userId: number; + title: string; + completed: boolean; +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..dc864cc93b --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + 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..6c7614e0e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ ], "compilerOptions": { "sourceMap": false, - "types": ["node", "cypress"] + "types": ["node", "cypress"], + "jsx": "react-jsx" } }