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..137ce4f45f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,149 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { deleteTodos, getTodos, patchTodos, USER_ID } from './api/todos'; +import { Todo } from './types/Todo'; +import { FilterMethods } from './types/FilterMethods'; +import { Header, HeaderRef } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { Footer } from './components/Footer/Footer'; +import { ErrorNotification } from './components/ErrorNotification/ErrorNotification'; +import { UpdateTodoData } from './types/UpdateTodoData'; +import { ErrorMessage } from './types/ErrorMessage'; export const App: React.FC = () => { + const [todosFromServer, setTodosFromServer] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [filteringMethod, setFilteringMethod] = useState('All'); + const [loadingIds, setLoadingIds] = useState([]); + const isAllTodosCompleted = todosFromServer.every( + todo => todo.completed === true, + ); + const headerRef = useRef(null); + + const loadTodos = () => { + return getTodos() + .then(setTodosFromServer) + .catch(() => { + setErrorMessage(ErrorMessage.LoadError); + setTimeout(() => setErrorMessage(null), 3000); + }); + }; + + useEffect(() => { + loadTodos(); + }, []); + + const visibleTodos = useMemo(() => { + switch (filteringMethod) { + case 'Active': + return todosFromServer.filter(todo => !todo.completed); + case 'Completed': + return todosFromServer.filter(todo => todo.completed); + case 'All': + default: + return todosFromServer; + } + }, [todosFromServer, filteringMethod]); + if (!USER_ID) { return ; } + const deleteTodo = (currentId: number) => { + setLoadingIds(prev => [...prev, currentId]); + + return deleteTodos(currentId) + .then(() => { + setTodosFromServer(prev => prev.filter(todo => todo.id !== currentId)); + headerRef.current?.focusInput(); + }) + .catch(() => { + setErrorMessage(ErrorMessage.DeleteError); + setTimeout(() => setErrorMessage(null), 3000); + }) + .finally(() => { + setLoadingIds(prev => prev.filter(id => id !== currentId)); + }); + }; + + const deleteCompleted = () => { + const completed = todosFromServer.filter(todo => todo.completed); + + Promise.all(completed.map(todo => deleteTodo(todo.id))).then(() => { + headerRef.current?.focusInput(); + }); + }; + + const updateTodos = async (id: number, data: UpdateTodoData) => { + setLoadingIds(prev => [...prev, id]); + + try { + await patchTodos(id, data); + + setTodosFromServer(prev => + prev.map(todo => (todo.id === id ? { ...todo, ...data } : todo)), + ); + } catch (error) { + setErrorMessage(ErrorMessage.UpdateError); + setTimeout(() => setErrorMessage(null), 3000); + + throw error; + } finally { + setLoadingIds(prev => prev.filter(filterId => filterId !== id)); + } + }; + + const toggleAll = () => { + const filtredTodos = todosFromServer.filter( + todo => todo.completed === false, + ); + + if (filtredTodos.length >= 1) { + filtredTodos.map(todo => + updateTodos(todo.id, { completed: !todo.completed }), + ); + } else { + todosFromServer.map(todo => + updateTodos(todo.id, { completed: !todo.completed }), + ); + } + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {/* Hide the footer if there are no todos */} +
+
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..b0a82207c9 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,23 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 3603; + +const TODOS_URL: string = `/todos`; + +export const getTodos = () => { + return client.get(`${TODOS_URL}?userId=${USER_ID}`); +}; + +// Add more methods here +export const postTodo = (data: Omit): Promise => { + return client.post(TODOS_URL, data); +}; + +export const deleteTodos = (id: number) => { + return client.delete(`${TODOS_URL}/${id}`); +}; + +export const patchTodos = (id: number, data: {}) => { + return client.patch(`${TODOS_URL}/${id}`, data); +}; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..a8c37ca07d --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { ErrorMessage } from '../../types/ErrorMessage'; + +interface Props { + errorMessage: ErrorMessage | null; +} + +export const ErrorNotification: React.FC = ({ errorMessage }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..5d823c73cd --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { FilterMethods } from '../../types/FilterMethods'; + +interface Props { + todos: Todo[]; + filteringMethod: FilterMethods; + setFilteringMethod: (f_method: FilterMethods) => void; + deleteCompleted: () => void; +} + +export const Footer: React.FC = ({ + todos, + filteringMethod, + setFilteringMethod, + deleteCompleted, +}) => { + const hasCompleted = todos.some(todo => todo.completed); + /* Hide the footer if there are no todos */ + + return ( + todos.length > 0 && ( + + ) + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..44ab299145 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,126 @@ +import React, { + useEffect, + useRef, + useState, + forwardRef, + useImperativeHandle, +} from 'react'; +import { USER_ID, postTodo } from '../../api/todos'; +import { Todo } from '../../types/Todo'; +import { ErrorMessage } from '../../types/ErrorMessage'; + +interface Props { + setTodosFromServer: React.Dispatch>; + todosFromServer: Todo[]; + setErrorMessage: (msg: ErrorMessage | null) => void; + setTempTodo: React.Dispatch>; + toggleAll: () => void; + allCompleted: boolean; +} + +export interface HeaderRef { + focusInput: () => void; +} + +export const Header = forwardRef( + ( + { + setTodosFromServer, + todosFromServer, + setErrorMessage, + setTempTodo, + toggleAll, + allCompleted, + }, + ref, + ) => { + const [tempTitle, setTempTitle] = useState(''); + const [formDisable, setFormDisable] = useState(false); + const inputRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + })); + + useEffect(() => { + if (!formDisable) { + inputRef.current?.focus(); + } + }, [formDisable]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = tempTitle.trim(); + const tempTodo: Todo = { + id: 0, + userId: USER_ID, + title: trimmedTitle, + completed: false, + }; + + if (!trimmedTitle) { + setErrorMessage(ErrorMessage.TitleError); + setTimeout(() => setErrorMessage(null), 3000); + + return; + } + + setFormDisable(true); + setTempTodo(tempTodo); + + try { + const newTodoFromServer = await postTodo({ + userId: USER_ID, + title: trimmedTitle, + completed: false, + }); + + setTodosFromServer(prev => [...prev, newTodoFromServer]); + + setTempTitle(''); + } catch (error) { + setErrorMessage(ErrorMessage.AddError); + setTimeout(() => setErrorMessage(null), 3000); + } finally { + setFormDisable(false); + setTempTodo(null); + inputRef.current?.focus(); + } + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + + {todosFromServer.length > 0 && ( +
+ ); + }, +); + +Header.displayName = 'Header'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..24c343bb50 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { UpdateTodoData } from '../../types/UpdateTodoData'; + +interface Props { + todo: Todo; + onDelete?: (id: number) => void; + isLoader?: boolean; + updateTodo?: (id: number, data: UpdateTodoData) => Promise; +} + +export const TodoItem: React.FC = ({ + todo, + onDelete = () => {}, + isLoader = false, + updateTodo = async () => {}, +}) => { + const todoId = `todo-status-${todo.id}`; + const [isTodoEditing, setIsTodoEditing] = useState(false); + const [tempTitle, setTempTitle] = useState(todo.title); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (tempTitle === todo.title) { + setIsTodoEditing(false); + } else { + if (tempTitle) { + updateTodo(todo.id, { title: tempTitle.trim() }).then(() => { + setIsTodoEditing(false); + }); + } else { + onDelete(todo.id); + } + } + }; + + return ( +
+ + + {!isTodoEditing ? ( + <> + setIsTodoEditing(true)} + > + {todo.title} + + + + + ) : ( +
+ setTempTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') { + setIsTodoEditing(false); + setTempTitle(todo.title); + } + }} + /> +
+ )} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..b1c634c0ca --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +interface Props { + todos: Todo[]; + tempTodo: Todo | null; + onDelete: (id: number) => void; + loadingTodoIds: number[]; + updateTodo: (id: number, data: {}) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + onDelete, + loadingTodoIds, + updateTodo, +}) => ( +
+ {todos.map(todo => { + const todoId = `todo-status-${todo.id}`; + + return ( + + ); + })} + + {tempTodo && } +
+); diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..30bc7e83b2 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + LoadError = 'Unable to load todos', + TitleError = 'Title should not be empty', + AddError = 'Unable to add a todo', + DeleteError = 'Unable to delete a todo', + UpdateError = 'Unable to update a todo', +} diff --git a/src/types/FilterMethods.ts b/src/types/FilterMethods.ts new file mode 100644 index 0000000000..d884779716 --- /dev/null +++ b/src/types/FilterMethods.ts @@ -0,0 +1 @@ +export type FilterMethods = 'All' | 'Active' | '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/types/UpdateTodoData.ts b/src/types/UpdateTodoData.ts new file mode 100644 index 0000000000..b38c6baab6 --- /dev/null +++ b/src/types/UpdateTodoData.ts @@ -0,0 +1,4 @@ +export type UpdateTodoData = Partial<{ + 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..778406f15e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "src" ], "compilerOptions": { + "jsx": "react", "sourceMap": false, "types": ["node", "cypress"] }