From 2b57a1895ba2fedba385727387d5b8d5f8399cd6 Mon Sep 17 00:00:00 2001 From: Artem Vasylenko Date: Tue, 19 Nov 2024 15:47:49 +0200 Subject: [PATCH 1/7] add temp solution --- README.md | 4 +- src/App.tsx | 196 ++++++++++++++++-- src/api/todos.ts | 20 ++ .../ErrorNotification/ErrorNotification.tsx | 26 +++ src/components/ErrorNotification/index.ts | 1 + src/components/Footer/Footer.tsx | 74 +++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 80 +++++++ src/components/Header/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 124 +++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 45 ++++ src/components/TodoList/index.ts | 1 + src/components/index.ts | 5 + src/types/FilterCriteria.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++ 17 files changed, 616 insertions(+), 20 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/ErrorNotification/index.ts create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/components/index.ts create mode 100644 src/types/FilterCriteria.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index d3c3756ab9..81714b979f 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://IrbisKronos.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..f4314f5712 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,184 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +import React, { useEffect, useMemo, useState } from 'react'; -const USER_ID = 0; +import { Todo } from './types/Todo'; +import { FilterCriteria } from './types/FilterCriteria'; +import * as todoServise from './api/todos'; +import { Header, Footer, TodoList, ErrorNotification } from './components'; + +const filterTodos = (tasks: Todo[], filterCriteria: FilterCriteria) => { + return tasks.filter(task => { + const matchesStatus = + filterCriteria === FilterCriteria.All || + (filterCriteria === FilterCriteria.Active && !task.completed) || + (filterCriteria === FilterCriteria.Completed && task.completed); + + return matchesStatus; + }); +}; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [filter, setFilter] = useState(FilterCriteria.All); + const [titleTodo, setTitleTodo] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + + useEffect(() => { + setIsLoading(true); + + todoServise + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + useEffect(() => { + if (errorMessage) { + const timer = setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => clearTimeout(timer); + } + }, [errorMessage]); + + const getFilteredTodos = useMemo(() => { + return filterTodos(todos, filter); + }, [todos, filter]); + + const addTodo = ({ title, userId, completed }: Todo) => { + setIsLoading(true); + + const newTempTodo = { + id: 0, + userId: todoServise.USER_ID, + title, + completed: false, + }; + + setTempTodo(newTempTodo); + + todoServise + .createTodo({ title, userId, completed }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setTitleTodo(''); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + }) + .finally(() => { + setIsLoading(false); + setTempTodo(null); + }); + }; + + const updateTodo = (updatedTodo: Todo) => { + setLoadingTodoIds(ids => [...ids, updatedTodo.id]); + + todoServise + .updateTodo(updatedTodo) + .then(todo => { + setTodos(currentTodos => { + const newTodo = [...currentTodos]; + const index = newTodo.findIndex(post => post.id === updatedTodo.id); + + newTodo.splice(index, 1, todo); + + return newTodo; + }); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setLoadingTodoIds(ids => ids.filter(id => id !== updatedTodo.id)); + setTitleTodo(''); + }); + }; + + const deleteTodo = (todoId: number) => { + setLoadingTodoIds(ids => [...ids, todoId]); + + todoServise + .deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setLoadingTodoIds(ids => ids.filter(id => id !== todoId)); + }); + }; + + const handleFilter = (filterType: FilterCriteria) => { + setFilter(filterType); + }; + + const activeTodos = todos.filter(todo => !todo.completed).length || 0; + + const completedTodos = todos.filter(todo => todo.completed).length || 0; + + const toggleAllTodos = () => { + const areAllCompleted = todos.every(todo => todo.completed); + + const toggledTodos = todos.map(todo => ({ + ...todo, + completed: !areAllCompleted, + })); + + toggledTodos.forEach(todo => updateTodo(todo)); + }; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {!!todos.length && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..80af82eba2 --- /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 = 1415; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = ({ title, userId, completed }: Omit) => { + return client.post('/todos/', { title, userId, completed }); +}; + +export const updateTodo = ({ id, userId, completed }: Omit) => { + return client.patch(`/todos/${id}`, { userId, completed }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..2d2525d7cf --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,26 @@ +import classNames from 'classnames'; +import React from 'react'; + +type Props = { + errorMessage: string; +}; + +export const ErrorNotification: React.FC = ({ errorMessage }) => { + return ( +
+
+ ); +}; diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..3d3f3a8db4 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,74 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Todo } from '../../types/Todo'; +import { FilterCriteria } from '../../types/FilterCriteria'; + +type Props = { + handleFilter: (filterType: FilterCriteria) => void; + filter: FilterCriteria; + todos: Todo[] | null; + deleteTodo: (todoId: number) => void; + activeTodos: number; + completedTodos: number; +}; + +const capitalizeFirstLetter = (str: string) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +export const Footer: React.FC = ({ + handleFilter, + filter, + todos, + deleteTodo, + activeTodos, + completedTodos, +}) => { + const handleClearCompleted = () => { + todos?.forEach(todo => { + if (todo.completed) { + deleteTodo(todo.id); + } + }); + }; + + const correctItemTerm = activeTodos === 1 ? 'item' : 'items'; + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..c268d0fe6d --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { USER_ID } from '../../api/todos'; + +type Props = { + todos: Todo[]; + addTodo: (newTodo: Todo) => void; + setErrorMessage: React.Dispatch>; + isLoading: boolean; + titleTodo: string; + setTitleTodo: React.Dispatch>; + toggleAllTodos: () => void; + activeTodos: number; +}; + +export const Header: React.FC = ({ + todos, + addTodo, + setErrorMessage, + isLoading, + titleTodo, + setTitleTodo, + toggleAllTodos, + activeTodos, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current && !isLoading) { + inputRef.current.focus(); + } + }, [todos, isLoading]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!titleTodo.trim()) { + setErrorMessage('Title should not be empty'); + + return; + } + + addTodo({ + id: 0, + userId: USER_ID, + title: titleTodo.trim(), + completed: false, + }); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..3d9e393570 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,124 @@ +import React, { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + updateTodo: (updatedTodo: Todo) => void; + deleteTodo: (todoId: number) => void; + isLoading: boolean; +}; + +export const TodoItem: React.FC = ({ + todo, + updateTodo, + deleteTodo, + isLoading, +}) => { + const { title, completed, id } = todo; + + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(title); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + const handleDoubleClick = () => { + setIsEditing(true); + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + setEditedTitle(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = editedTitle.trim(); + + if (trimmedTitle) { + if (trimmedTitle !== title) { + updateTodo({ ...todo, title: trimmedTitle }); + } + } else { + deleteTodo(id); + } + + setIsEditing(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditedTitle(title); + setIsEditing(false); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ +
+ ) : ( + <> + + {title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..d267ec1520 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem'; + +type Props = { + todos: Todo[] | null; + updateTodo: (updatedTodo: Todo) => void; + deleteTodo: (todoId: number) => void; + isLoading: boolean; + loadingTodoIds: number[]; + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ + todos, + updateTodo, + deleteTodo, + isLoading, + loadingTodoIds, + tempTodo, +}) => { + return ( +
+ {todos && + todos.map((todo: Todo) => ( + + ))} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000000..5deb0131f0 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,5 @@ +export { Header } from './Header'; +export { Footer } from './Footer'; +export { TodoList } from './TodoList'; +export { TodoItem } from './TodoItem'; +export { ErrorNotification } from './ErrorNotification'; diff --git a/src/types/FilterCriteria.ts b/src/types/FilterCriteria.ts new file mode 100644 index 0000000000..fad6eaeaba --- /dev/null +++ b/src/types/FilterCriteria.ts @@ -0,0 +1,5 @@ +export enum FilterCriteria { + 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'), +}; From 16509ed338e9652c5ce59cbca3878d82042d0384 Mon Sep 17 00:00:00 2001 From: Artem Vasylenko Date: Wed, 20 Nov 2024 12:55:56 +0200 Subject: [PATCH 2/7] One more question in the chat) --- src/App.tsx | 27 +++++++++++++++++-------- src/api/todos.ts | 4 ++-- src/components/Header/Header.tsx | 18 +++++++++-------- src/components/TodoItem/TodoItem.tsx | 30 ++++++++++++++++++++-------- src/components/TodoList/TodoList.tsx | 10 ++++++++++ 5 files changed, 63 insertions(+), 26 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f4314f5712..bda1fa656f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ export const App: React.FC = () => { const [titleTodo, setTitleTodo] = useState(''); const [tempTodo, setTempTodo] = useState(null); const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [editingTodos, setEditingTodos] = useState>({}); useEffect(() => { setIsLoading(true); @@ -94,6 +95,7 @@ export const App: React.FC = () => { return newTodo; }); + setEditingTodos(prev => ({ ...prev, [updatedTodo.id]: false })); }) .catch(() => { setErrorMessage('Unable to update a todo'); @@ -131,14 +133,21 @@ export const App: React.FC = () => { const completedTodos = todos.filter(todo => todo.completed).length || 0; const toggleAllTodos = () => { - const areAllCompleted = todos.every(todo => todo.completed); - - const toggledTodos = todos.map(todo => ({ - ...todo, - completed: !areAllCompleted, - })); - - toggledTodos.forEach(todo => updateTodo(todo)); + const hasIncomplete = todos.some(todo => !todo.completed); + + const toggledTodos = hasIncomplete + ? todos + .filter(todo => !todo.completed) + .map(todo => ({ + ...todo, + completed: true, + })) + : todos.map(todo => ({ + ...todo, + completed: false, + })); + + toggledTodos.forEach(updateTodo); }; return ( @@ -164,6 +173,8 @@ export const App: React.FC = () => { isLoading={isLoading} loadingTodoIds={loadingTodoIds} tempTodo={tempTodo} + editingTodos={editingTodos} + setEditingTodos={setEditingTodos} /> {!!todos.length && ( diff --git a/src/api/todos.ts b/src/api/todos.ts index 80af82eba2..7fe58757c7 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -11,8 +11,8 @@ export const createTodo = ({ title, userId, completed }: Omit) => { return client.post('/todos/', { title, userId, completed }); }; -export const updateTodo = ({ id, userId, completed }: Omit) => { - return client.patch(`/todos/${id}`, { userId, completed }); +export const updateTodo = ({ title, id, userId, completed }: Todo) => { + return client.patch(`/todos/${id}`, { title, userId, completed }); }; export const deleteTodo = (id: number) => { diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index c268d0fe6d..24c2b17d5e 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -52,14 +52,16 @@ export const Header: React.FC = ({ return (
-