From 6d55969151d5fea7398384be869cb3ec605f69c8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 19 Dec 2024 03:16:37 +0200 Subject: [PATCH 1/8] solution --- src/App.tsx | 471 +++++++++++++++++- src/api/todos.ts | 10 + .../ErrorNotification/ErrorNotification.tsx | 33 ++ src/components/ErrorNotification/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 125 +++++ src/components/TodoItem/index.ts | 1 + src/types/Error.ts | 7 + src/types/Filter.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++ 10 files changed, 688 insertions(+), 17 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/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/types/Error.ts create mode 100644 src/types/Filter.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..fed070fa86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,463 @@ -/* 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 { UserWarning } from './UserWarning'; +import React, { + useEffect, + useRef, + useState, + Dispatch, + SetStateAction, +} from 'react'; +import { USER_ID } from './api/todos'; +import { Todo } from './types/Todo'; +import { client } from './utils/fetchClient'; +import cN from 'classnames'; +import { TodoItem } from './components/TodoItem'; +import { Filter } from './types/Filter'; +import { Error } from './types/Error'; +import { ErrorNotification } from './components/ErrorNotification'; -const USER_ID = 0; +function filterTodosByStatus(todos: Todo[], completedStatus: boolean | null) { + if (completedStatus === null) { + return todos; + } + + return todos.filter(todo => todo.completed === completedStatus); +} + +function removeTodoById(todos: Todo[], id: number) { + return todos.filter(todo => todo.id !== id); +} export const App: React.FC = () => { - if (!USER_ID) { - return ; + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + const [activeFilter, setActiveFilter] = useState(Filter.All); + const [tempTodo, setTempTodo] = useState(null); + const [todoToDeleteIds, setTodoToDeleteIds] = useState(null); + + const [inputText, setInputText] = useState(''); + const [isDeletingTodo, setIsDeletingTodo] = useState(false); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [filteredTodos, setFilteredTodos] = useState([]); + const [statusChangeId, setStatusChangeId] = useState(null); + const [editingTodoId, setEditingTodoId] = useState(null); + const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + + const addTodoField = useRef(null); + const errorTimeoutRef = useRef | null>(null); + + const isAllCompleted = !todos.some(todo => todo.completed === false); + const hasCompleted = todos.some(todo => todo.completed === true); + + const activeCount: number = todos.reduce((acc, todo) => { + if (todo.completed === false) { + return acc + 1; + } + + return acc; + }, 0); + const filterValues = Object.values(Filter); + + function ShowError(message: Error) { + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + + setErrorMessage(message); + errorTimeoutRef.current = setTimeout(() => setErrorMessage(null), 3000); + } + + function changeState( + id: number, + todosState: Dispatch>, + updatedTodo: Todo, + ) { + todosState(prev => { + return prev.map(todo => (todo.id === id ? updatedTodo : todo)); + }); + } + + function handleTitleChange(newTitle: string) { + const updateStatus = { title: newTitle }; + + setIsUpdatingTitle(true); + client + .patch(`/todos/${editingTodoId}`, updateStatus) + .then(fetchedTodo => { + changeState(editingTodoId as number, setFilteredTodos, fetchedTodo); + changeState(editingTodoId as number, setTodos, fetchedTodo); + }) + .catch(() => ShowError(Error.UpdateError)) + .finally(() => { + setIsUpdatingTitle(false); + setEditingTodoId(null); + }); + } + + function handleTodoStatusChange(id: number) { + setStatusChangeId(id); + const newStatus = !todos.find(todo => todo.id === id)?.completed; + const updateStatus = { completed: newStatus }; + + setIsUpdatingStatus(true); + client + .patch(`/todos/${id}`, updateStatus) + .then(fetchedTodo => { + changeState(id, setFilteredTodos, fetchedTodo); + changeState(id, setTodos, fetchedTodo); + }) + .catch(() => ShowError(Error.UpdateError)) + .finally(() => { + setIsUpdatingStatus(false); + }); + } + + useEffect(() => { + client + .get(`/todos?userId=${USER_ID}`) + .then(fetchedTodos => { + setTodos(fetchedTodos); + setFilteredTodos(fetchedTodos); + if (addTodoField.current !== null) { + addTodoField.current.focus(); + } + }) + .catch(() => ShowError(Error.LoadError)); + }, []); + + useEffect(() => { + if (addTodoField.current !== null && tempTodo === null) { + /*tempTodo === null для того, не виконувати це два рази (бо стейт tempTodo спочатку змінюється на об'єкт а потім змінюється на null)*/ + addTodoField.current.focus(); + } + }, [tempTodo]); + + function handleFilter(filterParam: Filter) { + let filter; + + if (filterParam === Filter.All) { + filter = null; + } else if (filterParam === Filter.Active) { + filter = false; + } else { + filter = true; + } + + setFilteredTodos(filterTodosByStatus(todos, filter)); + setActiveFilter(filterParam); + } + + function handleAddTodoOnEnter(event: React.FormEvent) { + event.preventDefault(); + const title = inputText.replace(/\s+/g, ' ').trim(); + + if (title === '') { + ShowError(Error.EmptyTitleError); + } else { + const newTodo = { + id: 0, + title: title, + userId: USER_ID, + completed: false, + }; + + setTempTodo(newTodo); + + client + .post(`/todos`, newTodo) + .then(fetchedTodo => { + setInputText(''); + setTodos(prevTodos => [...prevTodos, fetchedTodo]); + setFilteredTodos(prevTodos => [...prevTodos, fetchedTodo]); + }) + .catch(() => ShowError(Error.AddError)) + .finally(() => { + setTempTodo(null); + }); + } + } + + function onDelete(id: number): Promise { + setIsDeletingTodo(true); + + return client + .delete(`/todos/${id}`) + .then(() => { + setTodos(prevTodos => removeTodoById(prevTodos, id)); + setFilteredTodos(prevTodos => removeTodoById(prevTodos, id)); + }) + .catch(() => ShowError(Error.DeleteError)) + .finally(() => { + setIsDeletingTodo(false); + }); + } + + function handleClearCompleted() { + const completedTodoIds = todos.reduce( + (acc, todo) => (todo.completed ? [...acc, todo.id] : acc), + [] as number[], + ); + + setTodoToDeleteIds(completedTodoIds); + + const promises: Promise[] = []; + + completedTodoIds.forEach(id => { + promises.push(onDelete(id)); + }); + + Promise.all(promises).then(() => { + //винести + if (addTodoField.current !== null) { + addTodoField.current.focus(); + } + }); + } + + function changeStatusAll() { + const status = isAllCompleted ? false : true; + + setTodos(prev => + prev.map(todo => { + return { ...todo, completed: status }; + }), + ); + // const completedTodoIds = todos.reduce( + // (acc, todo) => (todo.completed ? [...acc, todo.id] : acc), + // [] as number[], + // ); + + // setTodoToDeleteIds(completedTodoIds); + + // const promises: Promise[] = []; + + // completedTodoIds.forEach(id => { + // promises.push(onDelete(id)); + // }); + + // Promise.all(promises).then(() => { + // //винести + // if (addTodoField.current !== null) { + // addTodoField.current.focus(); + // } + // }); } 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 +++*/} +
+
+ {filteredTodos.map(todo => ( + + ))} + {tempTodo !== null && ( + + )} +
+ + {false && ( +
+ {/* This is a completed todo +*/} +
+ + + + Completed Todo + + + {/* Remove button appears only on hover ок*/} + + + {/* overlay will cover the todo while it is being deleted or updated ок*/} +
+
+
+
+
+ + {/* This todo is an active todo +*/} +
+ + + + Not Completed Todo + + + +
+
+
+
+
+ + {/* This todo is being edited -*/} +
+ + + {/* This form is shown instead of the title and remove button -*/} +
+ +
+ +
+
+
+
+
+ + {/* This todo is in loadind state -*/} +
+ + + + Todo is being saved now + + + + + {/* 'is-active' class puts this modal on top of the todo --- */} +
+
+
+
+
+
+ )} + {/* Hide the footer if there are no todos +++*/} + {todos.length !== 0 && ( +
+ + {activeCount} items left + + + {/* Active link should have the 'selected' class +++*/} + + + {/* this button should be disabled if there are no completed todos +++*/} + +
+ )} +
+ + {/* 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..4079e3c2b2 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,10 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2148; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..245778ead1 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { Dispatch, SetStateAction } from 'react'; +import cN from 'classnames'; +import { Error } from '../../types/Error'; + +type Props = { + errorMessage: Error | null; + setErrorMessage: Dispatch>; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + 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/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..d68a7d9b85 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,125 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { + Dispatch, + SetStateAction, + RefObject, + useState, + useRef, + useEffect, +} from 'react'; +import { Todo } from '../../types/Todo'; +import cN from 'classnames'; + +type Props = { + todo: Todo; + isTemp?: boolean; + handleTodoStatusChange?: (id: number) => void; + onDelete?: (id: number) => Promise; + isDeletingTodo?: boolean; + todoToDeleteIds?: number[] | null; + setTodoToDeleteIds?: Dispatch>; + addTodoField?: RefObject; + isUpdatingStatus?: boolean; + statusChangeId?: number | null; + setEditingTodoId?: Dispatch>; + editingTodoId?: number | null; + handleTitleChange?: (newTitle: string) => void; + isUpdatingTitle?: boolean | null; +}; + +export const TodoItem: React.FC = ({ + todo, + isTemp = false, + handleTodoStatusChange, + onDelete, + isDeletingTodo, + todoToDeleteIds, + setTodoToDeleteIds, + addTodoField, + isUpdatingStatus, + statusChangeId, + setEditingTodoId, + editingTodoId, + handleTitleChange, + isUpdatingTitle, +}) => { + const { title, id, completed } = todo; + const [inputValue, setInputValue] = useState(title); + const editTodoField = useRef(null); + + useEffect(() => { + if (editTodoField.current !== null) { + editTodoField.current.focus(); + } + }, [editingTodoId]); + + return ( +
+ + {id === editingTodoId ? ( +
handleTitleChange?.(inputValue)}> + { + handleTitleChange?.(inputValue); + }} + ref={editTodoField} + data-cy="TodoTitleField" + type="text" + className="todo__title-field" + placeholder="Empty todo will be deleted" + value={inputValue} + onChange={event => setInputValue(event.target.value)} + /> +
+ ) : ( + <> + setEditingTodoId?.(id)} + data-cy="TodoTitle" + className="todo__title" + > + {title} + + + + )} + {/* isDeletingTodo спробувати прибрати*/} +
+
+
+
+
+ ); +}; 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/types/Error.ts b/src/types/Error.ts new file mode 100644 index 0000000000..d9b5bc2145 --- /dev/null +++ b/src/types/Error.ts @@ -0,0 +1,7 @@ +export enum Error { + LoadError = 'Unable to load todos', + EmptyTitleError = '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/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..401d5c5e8d --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'All', + Completed = 'Completed', + Active = 'Active', +} 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..e85161248c --- /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 c10a67cbe460e9b37f11a077d1f6f90b73e3859b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Dec 2024 01:00:12 +0200 Subject: [PATCH 2/8] Solution --- src/App.tsx | 257 +++++++++------------------ src/components/TodoItem/TodoItem.tsx | 76 ++++++-- 2 files changed, 143 insertions(+), 190 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fed070fa86..57b20eefe9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import React, { useState, Dispatch, SetStateAction, + FormEvent, } from 'react'; import { USER_ID } from './api/todos'; import { Todo } from './types/Todo'; @@ -16,7 +17,10 @@ import { Filter } from './types/Filter'; import { Error } from './types/Error'; import { ErrorNotification } from './components/ErrorNotification'; -function filterTodosByStatus(todos: Todo[], completedStatus: boolean | null) { +function filterTodosByStatus( + todos: Todo[], + completedStatus: boolean | null = null, +) { if (completedStatus === null) { return todos; } @@ -39,7 +43,7 @@ export const App: React.FC = () => { const [isDeletingTodo, setIsDeletingTodo] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [filteredTodos, setFilteredTodos] = useState([]); - const [statusChangeId, setStatusChangeId] = useState(null); + const [statusChangeId, setStatusChangeId] = useState([]); const [editingTodoId, setEditingTodoId] = useState(null); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); @@ -58,6 +62,10 @@ export const App: React.FC = () => { }, 0); const filterValues = Object.values(Filter); + function trimTitle(text: string) { + return text.replace(/\s+/g, ' ').trim(); + } + function ShowError(message: Error) { if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); @@ -67,40 +75,72 @@ export const App: React.FC = () => { errorTimeoutRef.current = setTimeout(() => setErrorMessage(null), 3000); } + function filterToBool(filter: Filter) { + let boolFilter = null; + + if (filter === Filter.Active) { + boolFilter = false; + } else if (filter === Filter.Completed) { + boolFilter = true; + } + + return boolFilter; + } + function changeState( id: number, todosState: Dispatch>, updatedTodo: Todo, ) { + const filter = filterToBool(activeFilter); + todosState(prev => { - return prev.map(todo => (todo.id === id ? updatedTodo : todo)); + let changed = prev.map(todo => (todo.id === id ? updatedTodo : todo)); + + if (todosState === setFilteredTodos) { + changed = filterTodosByStatus(changed, filter); + } + + return changed; }); } - function handleTitleChange(newTitle: string) { - const updateStatus = { title: newTitle }; + function handleTitleChange(newTitle: string, currentTitle: string) { + const trimedTitle = trimTitle(newTitle); + + if (trimedTitle === currentTitle) { + setEditingTodoId(null); + + return; + } + + const updateStatus = { title: trimedTitle }; setIsUpdatingTitle(true); - client + + return client .patch(`/todos/${editingTodoId}`, updateStatus) .then(fetchedTodo => { changeState(editingTodoId as number, setFilteredTodos, fetchedTodo); changeState(editingTodoId as number, setTodos, fetchedTodo); + setEditingTodoId(null); + }) + .catch(error => { + ShowError(Error.UpdateError); + throw error; }) - .catch(() => ShowError(Error.UpdateError)) .finally(() => { setIsUpdatingTitle(false); - setEditingTodoId(null); }); } - function handleTodoStatusChange(id: number) { - setStatusChangeId(id); - const newStatus = !todos.find(todo => todo.id === id)?.completed; + function handleTodoStatusChange(id: number, newStatus: boolean) { + setStatusChangeId(prev => [...prev, id]); const updateStatus = { completed: newStatus }; setIsUpdatingStatus(true); - client + + return client .patch(`/todos/${id}`, updateStatus) .then(fetchedTodo => { changeState(id, setFilteredTodos, fetchedTodo); @@ -108,6 +148,7 @@ export const App: React.FC = () => { }) .catch(() => ShowError(Error.UpdateError)) .finally(() => { + setStatusChangeId(prev => prev.filter(idParametr => idParametr !== id)); setIsUpdatingStatus(false); }); } @@ -132,24 +173,9 @@ export const App: React.FC = () => { } }, [tempTodo]); - function handleFilter(filterParam: Filter) { - let filter; - - if (filterParam === Filter.All) { - filter = null; - } else if (filterParam === Filter.Active) { - filter = false; - } else { - filter = true; - } - - setFilteredTodos(filterTodosByStatus(todos, filter)); - setActiveFilter(filterParam); - } - - function handleAddTodoOnEnter(event: React.FormEvent) { + function handleAddTodoOnEnter(event: FormEvent) { event.preventDefault(); - const title = inputText.replace(/\s+/g, ' ').trim(); + const title = trimTitle(inputText); if (title === '') { ShowError(Error.EmptyTitleError); @@ -214,33 +240,25 @@ export const App: React.FC = () => { }); } - function changeStatusAll() { - const status = isAllCompleted ? false : true; - - setTodos(prev => - prev.map(todo => { - return { ...todo, completed: status }; - }), - ); - // const completedTodoIds = todos.reduce( - // (acc, todo) => (todo.completed ? [...acc, todo.id] : acc), - // [] as number[], - // ); + function handleFilter(filterParam: Filter) { + const filter = filterToBool(filterParam); - // setTodoToDeleteIds(completedTodoIds); + setFilteredTodos(filterTodosByStatus(todos, filter)); + setActiveFilter(filterParam); + } - // const promises: Promise[] = []; + useEffect(() => { + handleFilter(activeFilter); //&&& + }); - // completedTodoIds.forEach(id => { - // promises.push(onDelete(id)); - // }); + function changeStatusAll() { + const status = isAllCompleted ? false : true; - // Promise.all(promises).then(() => { - // //винести - // if (addTodoField.current !== null) { - // addTodoField.current.focus(); - // } - // }); + todos.forEach(todo => { + if (todo.completed !== status) { + handleTodoStatusChange(todo.id, status); + } + }); } return ( @@ -250,14 +268,16 @@ export const App: React.FC = () => {
{/* this button should have `active` class only if all todos are completed +++*/} - - - {/* overlay will cover the todo while it is being deleted or updated ок*/} -
-
-
-
-
- - {/* This todo is an active todo +*/} -
- - - - Not Completed Todo - - - -
-
-
-
-
- - {/* This todo is being edited -*/} -
- - - {/* This form is shown instead of the title and remove button -*/} - - - - -
-
-
-
-
- - {/* This todo is in loadind state -*/} -
- - - - Todo is being saved now - - - - - {/* 'is-active' class puts this modal on top of the todo --- */} -
-
-
-
-
- - )} {/* Hide the footer if there are no todos +++*/} {todos.length !== 0 && (
diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index d68a7d9b85..1e7ddf296c 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -7,6 +7,8 @@ import React, { useState, useRef, useEffect, + FormEvent, + KeyboardEvent, } from 'react'; import { Todo } from '../../types/Todo'; import cN from 'classnames'; @@ -14,17 +16,20 @@ import cN from 'classnames'; type Props = { todo: Todo; isTemp?: boolean; - handleTodoStatusChange?: (id: number) => void; + handleTodoStatusChange?: (id: number, newStatus: boolean) => Promise; onDelete?: (id: number) => Promise; isDeletingTodo?: boolean; todoToDeleteIds?: number[] | null; setTodoToDeleteIds?: Dispatch>; addTodoField?: RefObject; isUpdatingStatus?: boolean; - statusChangeId?: number | null; + statusChangeId?: number[]; setEditingTodoId?: Dispatch>; editingTodoId?: number | null; - handleTitleChange?: (newTitle: string) => void; + handleTitleChange?: ( + newTitle: string, + currentTitle: string, + ) => Promise | undefined; isUpdatingTitle?: boolean | null; }; @@ -54,6 +59,53 @@ export const TodoItem: React.FC = ({ } }, [editingTodoId]); + function trimTitle(text: string) { + return text.replace(/\s+/g, ' ').trim(); + } + + function handlerOnKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setEditingTodoId?.(null); + } + } + + function handlerOnDelete() { + setTodoToDeleteIds?.([id]); + onDelete?.(id).then(() => { + if (addTodoField?.current !== null) { + addTodoField?.current.focus(); + } + }); + } + + function handlerOnSubmit( + newTitle: string, + currentTitle: string, + event?: FormEvent, + ) { + if (event) { + event.preventDefault(); + } + + if (newTitle.length === 0) { + handlerOnDelete(); + + return; + } + + const result = handleTitleChange?.(newTitle, currentTitle); + + result + ?.then(() => { + setInputValue(trimTitle(inputValue)); + }) + .catch(() => { + if (editTodoField.current !== null) { + editTodoField.current.focus(); + } + }); + } + return (
{id === editingTodoId ? ( -
handleTitleChange?.(inputValue)}> + handlerOnSubmit(inputValue, title, event)}> { - handleTitleChange?.(inputValue); + handlerOnSubmit(inputValue, title); }} + onKeyDown={handlerOnKeyDown} ref={editTodoField} data-cy="TodoTitleField" type="text" @@ -91,12 +146,7 @@ export const TodoItem: React.FC = ({ )} - {/* isDeletingTodo спробувати прибрати*/}
diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index e85161248c..5222631827 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -27,7 +27,7 @@ function request( } // DON'T change the delay it is required for tests + - return wait(100) + return wait(400) .then(() => fetch(BASE_URL + url, options)) .then(response => { if (!response.ok) { From 5a1fdd3f8af16038425fa2e6c1c04c60e3822cae Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Dec 2024 19:15:17 +0200 Subject: [PATCH 4/8] refactoring --- src/App.tsx | 27 ++++++++++++++------------- src/components/TodoItem/TodoItem.tsx | 10 ++++++---- src/utils/fetchClient.ts | 2 +- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 79953d06a1..797cdc6a7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -101,14 +101,14 @@ export const App: React.FC = () => { }); } - function handleTitleChange(newTitle: string, editingTodoId1: number | null) { + function handleTitleChange(newTitle: string, editingTodoId: number | null) { const updateStatus = { title: newTitle }; return client - .patch(`/todos/${editingTodoId1}`, updateStatus) + .patch(`/todos/${editingTodoId}`, updateStatus) .then(fetchedTodo => { - changeState(editingTodoId1 as number, setFilteredTodos, fetchedTodo); - changeState(editingTodoId1 as number, setTodos, fetchedTodo); + changeState(editingTodoId as number, setFilteredTodos, fetchedTodo); + changeState(editingTodoId as number, setTodos, fetchedTodo); }) .catch(error => { ShowError(Error.UpdateError); @@ -132,23 +132,27 @@ export const App: React.FC = () => { }); } + function setFocusOnAddInput() { + if (addTodoField.current !== null) { + addTodoField.current.focus(); + } + } + useEffect(() => { client .get(`/todos?userId=${USER_ID}`) .then(fetchedTodos => { setTodos(fetchedTodos); setFilteredTodos(fetchedTodos); - if (addTodoField.current !== null) { - addTodoField.current.focus(); - } + setFocusOnAddInput(); }) .catch(() => ShowError(Error.LoadError)); }, []); useEffect(() => { - if (addTodoField.current !== null && tempTodo === null) { + if (tempTodo === null) { /*tempTodo === null для того, не виконувати це два рази (бо стейт tempTodo спочатку змінюється на об'єкт а потім змінюється на null)*/ - addTodoField.current.focus(); + setFocusOnAddInput(); } }, [tempTodo]); @@ -207,10 +211,7 @@ export const App: React.FC = () => { }); Promise.all(promises).then(() => { - //винести - if (addTodoField.current !== null) { - addTodoField.current.focus(); - } + setFocusOnAddInput(); }); } diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 4b79c562fb..d9b988e55f 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -45,10 +45,14 @@ export const TodoItem: React.FC = ({ const [editingTodoId, setEditingTodoId] = useState(null); const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); - useEffect(() => { + function setFocusOnEditInput() { if (editTodoField.current !== null) { editTodoField.current.focus(); } + } + + useEffect(() => { + setFocusOnEditInput(); }, [editingTodoId]); function trimTitle(text: string) { @@ -102,9 +106,7 @@ export const TodoItem: React.FC = ({ setEditingTodoId(null); }) .catch(() => { - if (editTodoField.current !== null) { - editTodoField.current.focus(); - } + setFocusOnEditInput(); }) .finally(() => { setIsUpdatingTitle(false); diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index 5222631827..e85161248c 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -27,7 +27,7 @@ function request( } // DON'T change the delay it is required for tests + - return wait(400) + return wait(100) .then(() => fetch(BASE_URL + url, options)) .then(response => { if (!response.ok) { From 5af2f6b52bf0cc05d4ad0195eb0ac4790f1cefb6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Dec 2024 21:19:40 +0200 Subject: [PATCH 5/8] refactoring --- src/App.tsx | 68 +++++++++---------- .../ErrorNotification/ErrorNotification.tsx | 8 +-- src/components/TodoItem/TodoItem.tsx | 4 +- src/types/Error.ts | 1 + src/utils/filterTodosByStatus.ts | 12 ++++ src/utils/removeTodoById.ts | 5 ++ 6 files changed, 58 insertions(+), 40 deletions(-) create mode 100644 src/utils/filterTodosByStatus.ts create mode 100644 src/utils/removeTodoById.ts diff --git a/src/App.tsx b/src/App.tsx index 797cdc6a7b..4777cd5d33 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import React, { Dispatch, SetStateAction, FormEvent, + useMemo, } from 'react'; import { USER_ID } from './api/todos'; import { Todo } from './types/Todo'; @@ -16,59 +17,57 @@ import { TodoItem } from './components/TodoItem'; import { Filter } from './types/Filter'; import { Error } from './types/Error'; import { ErrorNotification } from './components/ErrorNotification'; +import { filterTodosByStatus } from './utils/filterTodosByStatus'; +import { removeTodoById } from './utils/removeTodoById'; -function filterTodosByStatus( - todos: Todo[], - completedStatus: boolean | null = null, -) { - if (completedStatus === null) { - return todos; - } - - return todos.filter(todo => todo.completed === completedStatus); -} - -function removeTodoById(todos: Todo[], id: number) { - return todos.filter(todo => todo.id !== id); -} +const filterValues = Object.values(Filter); export const App: React.FC = () => { const [todos, setTodos] = useState([]); - const [errorMessage, setErrorMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(Error.Default); const [activeFilter, setActiveFilter] = useState(Filter.All); const [tempTodo, setTempTodo] = useState(null); - const [todoToDeleteIds, setTodoToDeleteIds] = useState(null); + const [todoToDeleteIds, setTodoToDeleteIds] = useState([]); - const [inputText, setInputText] = useState(''); + const [inputText, setInputText] = useState(''); const [filteredTodos, setFilteredTodos] = useState([]); const [statusChangeId, setStatusChangeId] = useState([]); const addTodoField = useRef(null); const errorTimeoutRef = useRef | null>(null); - const isAllCompleted = !todos.some(todo => todo.completed === false); - const hasCompleted = todos.some(todo => todo.completed === true); + const isAllCompleted = useMemo(() => { + return !todos.some(todo => todo.completed === false); + }, [todos]); - const activeCount: number = todos.reduce((acc, todo) => { - if (todo.completed === false) { - return acc + 1; - } + const hasCompleted = useMemo(() => { + return todos.some(todo => todo.completed === true); + }, [todos]); - return acc; - }, 0); - const filterValues = Object.values(Filter); + const activeCount: number = useMemo(() => { + return todos.reduce((acc, todo) => { + if (todo.completed === false) { + return acc + 1; + } + + return acc; + }, 0); + }, [todos]); function trimTitle(text: string) { return text.replace(/\s+/g, ' ').trim(); } - function ShowError(message: Error) { + function showError(message: Error) { if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); } setErrorMessage(message); - errorTimeoutRef.current = setTimeout(() => setErrorMessage(null), 3000); + errorTimeoutRef.current = setTimeout( + () => setErrorMessage(Error.Default), + 3000, + ); } function filterToBool(filter: Filter) { @@ -111,7 +110,7 @@ export const App: React.FC = () => { changeState(editingTodoId as number, setTodos, fetchedTodo); }) .catch(error => { - ShowError(Error.UpdateError); + showError(Error.UpdateError); throw error; }); } @@ -126,7 +125,7 @@ export const App: React.FC = () => { changeState(id, setFilteredTodos, fetchedTodo); changeState(id, setTodos, fetchedTodo); }) - .catch(() => ShowError(Error.UpdateError)) + .catch(() => showError(Error.UpdateError)) .finally(() => { setStatusChangeId(prev => prev.filter(idParametr => idParametr !== id)); }); @@ -146,7 +145,7 @@ export const App: React.FC = () => { setFilteredTodos(fetchedTodos); setFocusOnAddInput(); }) - .catch(() => ShowError(Error.LoadError)); + .catch(() => showError(Error.LoadError)); }, []); useEffect(() => { @@ -161,7 +160,7 @@ export const App: React.FC = () => { const title = trimTitle(inputText); if (title === '') { - ShowError(Error.EmptyTitleError); + showError(Error.EmptyTitleError); } else { const newTodo = { id: 0, @@ -179,7 +178,7 @@ export const App: React.FC = () => { setTodos(prevTodos => [...prevTodos, fetchedTodo]); setFilteredTodos(prevTodos => [...prevTodos, fetchedTodo]); }) - .catch(() => ShowError(Error.AddError)) + .catch(() => showError(Error.AddError)) .finally(() => { setTempTodo(null); }); @@ -193,7 +192,7 @@ export const App: React.FC = () => { setTodos(prevTodos => removeTodoById(prevTodos, id)); setFilteredTodos(prevTodos => removeTodoById(prevTodos, id)); }) - .catch(() => ShowError(Error.DeleteError)); + .catch(() => showError(Error.DeleteError)); } function handleClearCompleted() { @@ -215,6 +214,7 @@ export const App: React.FC = () => { }); } + //96 221 function handleFilter(filterParam: Filter) { const filter = filterToBool(filterParam); diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx index 245778ead1..2c24291fa1 100644 --- a/src/components/ErrorNotification/ErrorNotification.tsx +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -5,8 +5,8 @@ import cN from 'classnames'; import { Error } from '../../types/Error'; type Props = { - errorMessage: Error | null; - setErrorMessage: Dispatch>; + errorMessage: Error; + setErrorMessage: Dispatch>; }; export const ErrorNotification: React.FC = ({ @@ -17,11 +17,11 @@ export const ErrorNotification: React.FC = ({
+
- {filteredTodos.map(todo => ( + {filterTodosByStatus(todos, filterToBool(activeFilter)).map(todo => ( { handleTitleChange={handleTitleChange} /> ))} - {tempTodo !== null && ( + + {!!tempTodo && ( )}
@@ -303,14 +281,13 @@ export const App: React.FC = () => { selected: activeFilter === filter, })} data-cy={`FilterLink${filter}`} - onClick={() => handleFilter(filter)} + onClick={() => setActiveFilter(filter)} > {filter} ); })} - {/* this button should be disabled if there are no completed todos +++*/}
- {/* DON'T use conditional rendering to hide the notification +++*/} - {/* Add the 'hidden' class to hide the message smoothly +++*/} { return client.get(`/todos?userId=${USER_ID}`); }; -// Add more methods here +export const patchTodos = ( + Id: number | null, + updateProperty: { title: string } | { completed: boolean }, +) => { + return client.patch(`/todos/${Id}`, updateProperty); +}; + +export const postTodos = (newTodo: Todo) => { + return client.post(`/todos`, newTodo); +}; + +export const deleteTodos = (id: number) => { + return client.delete(`/todos/${id}`); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 978fe64466..9b416d6667 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -18,13 +18,12 @@ type Props = { isTemp?: boolean; handleTodoStatusChange?: (id: number, newStatus: boolean) => Promise; onDelete?: (id: number) => Promise; - todoToDeleteIds?: number[]; - setTodoToDeleteIds?: Dispatch>; + loadingTodoIds?: number[]; + setLoadingTodoIds?: Dispatch>; addTodoField?: RefObject; - statusChangeId?: number[]; handleTitleChange?: ( - newTitle: string, editingTodoId: number | null, + newTitle: string, ) => Promise | undefined; }; @@ -33,10 +32,9 @@ export const TodoItem: React.FC = ({ isTemp = false, handleTodoStatusChange, onDelete, - todoToDeleteIds, - setTodoToDeleteIds, + loadingTodoIds, + setLoadingTodoIds, addTodoField, - statusChangeId, handleTitleChange, }) => { const { title, id, completed } = todo; @@ -66,7 +64,7 @@ export const TodoItem: React.FC = ({ } function handlerOnDelete() { - setTodoToDeleteIds?.([id]); + setLoadingTodoIds?.([id]); onDelete?.(id).then(() => { if (addTodoField?.current !== null) { addTodoField?.current.focus(); @@ -98,7 +96,7 @@ export const TodoItem: React.FC = ({ } setIsUpdatingTitle(true); - const result = handleTitleChange?.(trimedTitle, editingTodoId); + const result = handleTitleChange?.(editingTodoId, trimedTitle); result ?.then(() => { @@ -168,8 +166,7 @@ export const TodoItem: React.FC = ({ className={cN('modal overlay', { 'is-active': isTemp || - todoToDeleteIds?.includes(id) || - statusChangeId?.includes(id) || + loadingTodoIds?.includes(id) || (isUpdatingTitle && id === editingTodoId), })} > From c5cce3c7f84cc9c65af2ade0d4a3d710bcc201ab Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 23 Dec 2024 21:04:13 +0200 Subject: [PATCH 8/8] Refactor: split code into three new components --- src/App.tsx | 246 +++++---------------------- src/components/Footer/Footer.tsx | 88 ++++++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 104 +++++++++++ src/components/Header/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 5 +- src/components/TodoList/TodoList.tsx | 91 ++++++++++ src/components/TodoList/index.ts | 1 + src/utils/trimTitle.ts | 3 + 9 files changed, 330 insertions(+), 210 deletions(-) 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/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/utils/trimTitle.ts diff --git a/src/App.tsx b/src/App.tsx index 3e24437be2..8579fcbc5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,23 +1,15 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React, { useEffect, useRef, useState, FormEvent, useMemo } from 'react'; -import { - deleteTodos, - getTodos, - patchTodos, - postTodos, - USER_ID, -} from './api/todos'; +import React, { useEffect, useRef, useState } from 'react'; +import { deleteTodos, getTodos, patchTodos } from './api/todos'; import { Todo } from './types/Todo'; -import cN from 'classnames'; -import { TodoItem } from './components/TodoItem'; import { Filter } from './types/Filter'; import { Error } from './types/Error'; import { ErrorNotification } from './components/ErrorNotification'; -import { filterTodosByStatus } from './utils/filterTodosByStatus'; import { removeTodoById } from './utils/removeTodoById'; - -const filterValues = Object.values(Filter); +import { TodoList } from './components/TodoList'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; export const App: React.FC = () => { const [todos, setTodos] = useState([]); @@ -26,33 +18,9 @@ export const App: React.FC = () => { const [tempTodo, setTempTodo] = useState(null); const [loadingTodoIds, setLoadingTodoIds] = useState([]); - const [inputText, setInputText] = useState(''); - const addTodoField = useRef(null); const errorTimeoutRef = useRef | null>(null); - const isAllCompleted = useMemo(() => { - return !todos.some(todo => todo.completed === false); - }, [todos]); - - const hasCompleted = useMemo(() => { - return todos.some(todo => todo.completed === true); - }, [todos]); - - const activeCount: number = useMemo(() => { - return todos.reduce((acc, todo) => { - if (todo.completed === false) { - return acc + 1; - } - - return acc; - }, 0); - }, [todos]); - - function trimTitle(text: string) { - return text.replace(/\s+/g, ' ').trim(); - } - function showError(message: Error) { if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); @@ -65,50 +33,17 @@ export const App: React.FC = () => { ); } - function filterToBool(filter: Filter) { - let boolFilter; - - switch (filter) { - case Filter.Active: - boolFilter = false; - break; - case Filter.Completed: - boolFilter = true; - break; - default: - boolFilter = null; - break; - } - - return boolFilter; - } - - function changeTodos(fetchedTodo: Todo) { - setTodos(prevTodos => - prevTodos.map(todo => (todo.id === fetchedTodo.id ? fetchedTodo : todo)), - ); - } - - function handleTitleChange(editingTodoId: number | null, newTitle: string) { - const updateTitle = { title: newTitle }; - - return patchTodos(editingTodoId, updateTitle) - .then(fetchedTodo => { - changeTodos(fetchedTodo); - }) - .catch(error => { - showError(Error.UpdateError); - throw error; - }); - } - function handleTodoStatusChange(id: number, newStatus: boolean) { setLoadingTodoIds(prev => [...prev, id]); const updateStatus = { completed: newStatus }; return patchTodos(id, updateStatus) .then(fetchedTodo => { - changeTodos(fetchedTodo); + setTodos(prevTodos => + prevTodos.map(todo => + todo.id === fetchedTodo.id ? fetchedTodo : todo, + ), + ); }) .catch(() => showError(Error.UpdateError)) .finally(() => { @@ -137,34 +72,6 @@ export const App: React.FC = () => { } }, [tempTodo]); - function handleAddTodoOnEnter(event: FormEvent) { - event.preventDefault(); - const title = trimTitle(inputText); - - if (!title.length) { - showError(Error.EmptyTitleError); - } else { - const newTodo = { - id: 0, - title: title, - userId: USER_ID, - completed: false, - }; - - setTempTodo(newTodo); - - postTodos(newTodo) - .then(fetchedTodo => { - setInputText(''); - setTodos(prevTodos => [...prevTodos, fetchedTodo]); - }) - .catch(() => showError(Error.AddError)) - .finally(() => { - setTempTodo(null); - }); - } - } - function onDelete(id: number): Promise { return deleteTodos(id) .then(() => { @@ -173,116 +80,43 @@ export const App: React.FC = () => { .catch(() => showError(Error.DeleteError)); } - function handleClearCompleted() { - const completedTodoIds = todos.reduce( - (acc, todo) => (todo.completed ? [...acc, todo.id] : acc), - [] as number[], - ); - - setLoadingTodoIds(completedTodoIds); - - const promises: Promise[] = []; - - completedTodoIds.forEach(id => { - promises.push(onDelete(id)); - }); - - Promise.all(promises).then(() => { - setFocusOnAddInput(); - }); - } - - function changeStatusAll() { - const status = isAllCompleted ? false : true; - - todos.forEach(todo => { - if (todo.completed !== status) { - handleTodoStatusChange(todo.id, status); - } - }); - } - return (

todos

-
- {todos.length !== 0 && ( -
- -
- {filterTodosByStatus(todos, filterToBool(activeFilter)).map(todo => ( - - ))} - - {!!tempTodo && ( - - )} -
+
+ + {todos.length !== 0 && ( - +
)}
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..1323afdb35 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,88 @@ +import { Dispatch, SetStateAction, useMemo } from 'react'; +import { Todo } from '../../types/Todo'; +import { Filter } from '../../types/Filter'; +import cN from 'classnames'; + +type Props = { + todos: Todo[]; + activeFilter: Filter; + setActiveFilter: Dispatch>; + setLoadingTodoIds: Dispatch>; + onDelete: (id: number) => Promise; + setFocusOnAddInput: () => void; +}; + +const filterValues = Object.values(Filter); + +export const Footer: React.FC = ({ + todos, + activeFilter, + setActiveFilter, + setLoadingTodoIds, + onDelete, + setFocusOnAddInput, +}) => { + const activeCount: number = useMemo(() => { + return todos.reduce((acc, todo) => { + if (todo.completed === false) { + return acc + 1; + } + + return acc; + }, 0); + }, [todos]); + + const hasCompleted = useMemo(() => { + return todos.some(todo => todo.completed === true); + }, [todos]); + + function handleClearCompleted() { + const completedTodoIds = todos.reduce( + (acc, todo) => (todo.completed ? [...acc, todo.id] : acc), + [] as number[], + ); + + setLoadingTodoIds(completedTodoIds); + + const promises = completedTodoIds.map(id => onDelete(id)); + + Promise.all(promises).then(() => { + setFocusOnAddInput(); + }); + } + + 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..685bf7ad64 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,104 @@ +import { + Dispatch, + FormEvent, + RefObject, + SetStateAction, + useMemo, + useState, +} from 'react'; +import { Todo } from '../../types/Todo'; +import { trimTitle } from '../../utils/trimTitle'; +import { Error } from '../../types/Error'; +import cN from 'classnames'; + +import { postTodos, USER_ID } from '../../api/todos'; + +type Props = { + todos: Todo[]; + handleTodoStatusChange: (id: number, newStatus: boolean) => Promise; + showError: (message: Error) => void; + setTodos: Dispatch>; + setTempTodo: Dispatch>; + addTodoField: RefObject; + tempTodo: Todo | null; +}; + +export const Header: React.FC = ({ + todos, + handleTodoStatusChange, + showError, + setTodos, + setTempTodo, + addTodoField, + tempTodo, +}) => { + const [inputText, setInputText] = useState(''); + + const isAllCompleted = useMemo(() => { + return !todos.some(todo => todo.completed === false); + }, [todos]); + + function changeStatusAll() { + const status = !isAllCompleted; + + todos.forEach(todo => { + if (todo.completed !== status) { + handleTodoStatusChange(todo.id, status); + } + }); + } + + function handleAddTodoOnEnter(event: FormEvent) { + event.preventDefault(); + const title = trimTitle(inputText); + + if (!title.length) { + showError(Error.EmptyTitleError); + } else { + const newTodo = { + id: 0, + title: title, + userId: USER_ID, + completed: false, + }; + + setTempTodo(newTodo); + + postTodos(newTodo) + .then(fetchedTodo => { + setInputText(''); + setTodos(prevTodos => [...prevTodos, fetchedTodo]); + }) + .catch(() => showError(Error.AddError)) + .finally(() => { + setTempTodo(null); + }); + } + } + + return ( +
+ {todos.length !== 0 && ( +
+ ); +}; 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 index 9b416d6667..561f9b4192 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -12,6 +12,7 @@ import React, { } from 'react'; import { Todo } from '../../types/Todo'; import cN from 'classnames'; +import { trimTitle } from '../../utils/trimTitle'; type Props = { todo: Todo; @@ -53,10 +54,6 @@ export const TodoItem: React.FC = ({ setFocusOnEditInput(); }, [editingTodoId]); - function trimTitle(text: string) { - return text.replace(/\s+/g, ' ').trim(); - } - function handlerOnKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { setEditingTodoId(null); diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..2452038e31 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,91 @@ +import { Filter } from '../../types/Filter'; +import { Todo } from '../../types/Todo'; +import { filterTodosByStatus } from '../../utils/filterTodosByStatus'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { Error } from '../../types/Error'; + +import React, { Dispatch, SetStateAction, RefObject } from 'react'; + +import { patchTodos } from '../../api/todos'; + +function filterToBool(filter: Filter) { + let boolFilter; + + switch (filter) { + case Filter.Active: + boolFilter = false; + break; + case Filter.Completed: + boolFilter = true; + break; + default: + boolFilter = null; + break; + } + + return boolFilter; +} + +type Props = { + todos: Todo[]; + activeFilter: Filter; + handleTodoStatusChange: (id: number, newStatus: boolean) => Promise; + onDelete: (id: number) => Promise; + loadingTodoIds: number[]; + setLoadingTodoIds: Dispatch>; + addTodoField: RefObject; + tempTodo: Todo | null; + setTodos: Dispatch>; + showError: (message: Error) => void; +}; + +export const TodoList: React.FC = ({ + todos, + activeFilter, + handleTodoStatusChange, + onDelete, + loadingTodoIds, + setLoadingTodoIds, + addTodoField, + tempTodo, + setTodos, + showError, +}) => { + function handleTitleChange(editingTodoId: number | null, newTitle: string) { + const updateTitle = { title: newTitle }; + + return patchTodos(editingTodoId, updateTitle) + .then(fetchedTodo => { + setTodos(prevTodos => + prevTodos.map(todo => + todo.id === fetchedTodo.id ? fetchedTodo : todo, + ), + ); + }) + .catch(error => { + showError(Error.UpdateError); + throw error; + }); + } + + return ( +
+ {filterTodosByStatus(todos, filterToBool(activeFilter)).map(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/utils/trimTitle.ts b/src/utils/trimTitle.ts new file mode 100644 index 0000000000..f2da66d2bc --- /dev/null +++ b/src/utils/trimTitle.ts @@ -0,0 +1,3 @@ +export function trimTitle(text: string) { + return text.replace(/\s+/g, ' ').trim(); +}