From 53755e40f32e0c9e8ce05750dd6c92b713e6947b Mon Sep 17 00:00:00 2001 From: misha-vynnyk Date: Wed, 29 Jan 2025 19:43:35 +0100 Subject: [PATCH 1/2] feat(todo): implement modal opening, filtering, and state management --- README.md | 2 +- src/App.tsx | 119 +++++++++++++++- src/components/TodoFilter/TodoFilter.tsx | 83 +++++++---- src/components/TodoList/TodoList.tsx | 170 ++++++++++------------- src/components/TodoModal/TodoModal.tsx | 51 +++++-- src/types/Todo.ts | 3 + 6 files changed, 290 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 5ec1e6f104..636a4d60a4 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,4 @@ loaded and show them using `TodoList` (check the code in the `api.ts`); - 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). - Open one more terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_dynamic-list-of-todos/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://misha-vynnyk.github.io/react_dynamic-list-of-todos/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index d46111e825..cf17103be6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,108 @@ /* eslint-disable max-len */ -import React from 'react'; +// #region import +import React, { useEffect, useMemo, useState } from 'react'; import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; +import { getTodos, getUser } from './api'; import { TodoList } from './components/TodoList'; import { TodoFilter } from './components/TodoFilter'; import { TodoModal } from './components/TodoModal'; import { Loader } from './components/Loader'; +import { Todo } from './types/Todo'; +// #endregion import + export const App: React.FC = () => { + // #region State + const [todos, setTodos] = useState([]); + const [currentTodo, setCurrentTodo] = useState(null); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [openTodoId, setOpenTodoId] = useState(null); + + const [todosLoading, setTodosLoading] = useState(false); + const [modalLoading, setModalLoading] = useState(false); + + const [filterValue, setFilterValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + + // eslint-disable-next-line no-console + console.log('Render'); + + useEffect(() => { + setTodosLoading(true); + + getTodos() + .then(data => { + setTodos(data); + }) + .finally(() => setTodosLoading(false)); + }, []); + + useEffect(() => { + if (!currentTodo || currentTodo.user) { + return; + } + + getUser(currentTodo.userId) + .then(user => { + setCurrentTodo(prevTodo => { + if (prevTodo?.id === currentTodo.id) { + return { ...prevTodo, user }; + } + + return prevTodo; + }); + }) + .finally(() => setModalLoading(false)); + + // // eslint-disable-next-line no-console + // console.log('Modal state:', isModalOpen, 'ModalLoader:', modalLoading); + }, [currentTodo]); + + // #endregion State + + // #region Handler + + const handleOpenModal = (todo: Todo): void => { + setOpenTodoId(todo.id); + setModalLoading(true); + setIsModalOpen(true); + + setCurrentTodo(todo); + }; + + const filteredAndSearchedTodos = useMemo(() => { + return todos + .filter(todo => { + if (filterValue === 'completed') { + return todo.completed === true; + } else if (filterValue === 'active') { + return todo.completed === false; + } + + return true; + }) + .filter(todo => { + return todo.title.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }, [todos, filterValue, searchTerm]); + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + const handleCloseModal = (): void => { + setIsModalOpen(false); + setCurrentTodo(null); + }; + + const handleClearSearch = () => { + setSearchTerm(''); + }; + + // #endregion Handler return ( <>
@@ -17,18 +111,33 @@ export const App: React.FC = () => {

Todos:

- +
- - + {todosLoading && } +
- + ); }; diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx index 193f1cd2b2..597010497c 100644 --- a/src/components/TodoFilter/TodoFilter.tsx +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -1,30 +1,57 @@ -export const TodoFilter = () => ( -
-

- - - -

+interface Props { + setFilterValue: React.Dispatch>; + searchTerm: string; + handleSearchChange: (event: React.ChangeEvent) => void; + handleClearSearch: () => void; +} -

- - - - +export const TodoFilter: React.FC = ({ + setFilterValue, + searchTerm, + handleSearchChange, + handleClearSearch, +}) => { + return ( + +

+ + + +

- - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - + + + ); + })} + + + ); + }, ); + +TodoList.displayName = 'TodoList'; diff --git a/src/components/TodoModal/TodoModal.tsx b/src/components/TodoModal/TodoModal.tsx index 0fbe6dae99..846b382c2c 100644 --- a/src/components/TodoModal/TodoModal.tsx +++ b/src/components/TodoModal/TodoModal.tsx @@ -1,12 +1,34 @@ import React from 'react'; import { Loader } from '../Loader'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { User } from '../../types/User'; + +interface Props { + currentTodo: (Todo & { user?: User }) | null; + isModalOpen: boolean; + modalLoading: boolean; + handleCloseModal: () => void; +} + +export const TodoModal: React.FC = ({ + currentTodo, + isModalOpen, + modalLoading, + handleCloseModal, +}) => { + if (!currentTodo) { + return null; + } -export const TodoModal: React.FC = () => { return ( -
+
- {true ? ( + {modalLoading ? ( ) : (
@@ -15,25 +37,36 @@ export const TodoModal: React.FC = () => { className="modal-card-title has-text-weight-medium" data-cy="modal-header" > - Todo #2 + Todo #{currentTodo.id}
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} -
diff --git a/src/types/Todo.ts b/src/types/Todo.ts index 780afae02d..1b32f6bbd7 100644 --- a/src/types/Todo.ts +++ b/src/types/Todo.ts @@ -1,6 +1,9 @@ +import { User } from './User'; + export interface Todo { id: number; title: string; completed: boolean; userId: number; + user?: User; } From 0c33c82ead3bb11c1abc780ffd5ef1c37e36417e Mon Sep 17 00:00:00 2001 From: misha-vynnyk Date: Thu, 30 Jan 2025 19:31:00 +0100 Subject: [PATCH 2/2] fix: resolve issues from code review, update filter logic, remove commented code, destructure todo, and improve UI --- src/App.tsx | 44 ++++++------ src/components/TodoFilter/TodoFilter.tsx | 17 +++-- src/components/TodoList/TodoList.tsx | 91 +++++++++++++----------- src/components/TodoModal/TodoModal.tsx | 1 - 4 files changed, 80 insertions(+), 73 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cf17103be6..9672ebb8ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,8 +13,19 @@ import { Loader } from './components/Loader'; import { Todo } from './types/Todo'; // #endregion import +export enum Filter { + all = 'all', + active = 'active', + completed = 'completed', +} + +export const filterLabels: { [key in Filter]: string } = { + [Filter.all]: 'All', + [Filter.active]: 'Active', + [Filter.completed]: 'Completed', +}; + export const App: React.FC = () => { - // #region State const [todos, setTodos] = useState([]); const [currentTodo, setCurrentTodo] = useState(null); @@ -24,19 +35,14 @@ export const App: React.FC = () => { const [todosLoading, setTodosLoading] = useState(false); const [modalLoading, setModalLoading] = useState(false); - const [filterValue, setFilterValue] = useState(''); + const [filterValue, setFilterValue] = useState(Filter.all); const [searchTerm, setSearchTerm] = useState(''); - // eslint-disable-next-line no-console - console.log('Render'); - useEffect(() => { setTodosLoading(true); getTodos() - .then(data => { - setTodos(data); - }) + .then(setTodos) .finally(() => setTodosLoading(false)); }, []); @@ -56,15 +62,8 @@ export const App: React.FC = () => { }); }) .finally(() => setModalLoading(false)); - - // // eslint-disable-next-line no-console - // console.log('Modal state:', isModalOpen, 'ModalLoader:', modalLoading); }, [currentTodo]); - // #endregion State - - // #region Handler - const handleOpenModal = (todo: Todo): void => { setOpenTodoId(todo.id); setModalLoading(true); @@ -76,13 +75,15 @@ export const App: React.FC = () => { const filteredAndSearchedTodos = useMemo(() => { return todos .filter(todo => { - if (filterValue === 'completed') { - return todo.completed === true; - } else if (filterValue === 'active') { - return todo.completed === false; + switch (filterValue) { + case Filter.completed: + return todo.completed; + case Filter.active: + return !todo.completed; + case Filter.all: + default: + return true; } - - return true; }) .filter(todo => { return todo.title.toLowerCase().includes(searchTerm.toLowerCase()); @@ -102,7 +103,6 @@ export const App: React.FC = () => { setSearchTerm(''); }; - // #endregion Handler return ( <>
diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx index 597010497c..d5ffcd557d 100644 --- a/src/components/TodoFilter/TodoFilter.tsx +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -1,5 +1,7 @@ +import { Filter, filterLabels } from '../../App'; + interface Props { - setFilterValue: React.Dispatch>; + setFilterValue: React.Dispatch>; searchTerm: string; handleSearchChange: (event: React.ChangeEvent) => void; handleClearSearch: () => void; @@ -17,11 +19,13 @@ export const TodoFilter: React.FC = ({

@@ -40,10 +44,9 @@ export const TodoFilter: React.FC = ({ - {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} - {searchTerm && ( - - - ); - })} + ) : ( + + )} + +

+ {title} +

+ + + + + + ))} ); diff --git a/src/components/TodoModal/TodoModal.tsx b/src/components/TodoModal/TodoModal.tsx index 846b382c2c..fc78acda69 100644 --- a/src/components/TodoModal/TodoModal.tsx +++ b/src/components/TodoModal/TodoModal.tsx @@ -40,7 +40,6 @@ export const TodoModal: React.FC = ({ Todo #{currentTodo.id}
- {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}