From 9aa7445a4d0886975ed9c9e59c874f478659efd5 Mon Sep 17 00:00:00 2001 From: Sergio47ua Date: Tue, 24 Oct 2023 11:40:39 +0200 Subject: [PATCH] add task solution --- src/App.tsx | 351 ++++++++++++++++++++++++++++++++++-- src/api/todos.ts | 18 ++ src/components/Error.tsx | 34 ++++ src/components/Footer.tsx | 78 ++++++++ src/components/Header.tsx | 62 +++++++ src/components/TodoList.tsx | 151 ++++++++++++++++ src/types/ErrorMessage.ts | 7 + src/types/Filters.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 10 files changed, 745 insertions(+), 13 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Error.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/ErrorMessage.ts create mode 100644 src/types/Filters.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 5749bdf784..b199f1cbd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,349 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + useCallback, useEffect, useMemo, useState, +} from 'react'; +import { Footer } from './components/Footer'; +import { TodoList } from './components/TodoList'; +import { Header } from './components/Header'; +import { Error } from './components/Error'; +import { Todo } from './types/Todo'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, +} from './api/todos'; +import { Filters } from './types/Filters'; import { UserWarning } from './UserWarning'; +import { ErrorMessage } from './types/ErrorMessage'; -const USER_ID = 0; +const USER_ID = 11689; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState(Filters.All); + const [errorMessage, setErrorMessage] = useState(''); + const [pageIsLoaded, setPageIsLoaded] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [isUpdatingId, setIsUpdatingId] = useState([]); + const [isEditing, setIsEditing] = useState(null); + const [editQuery, setEditQuery] = useState(''); + const [editQueryPrev, setEditQueryPrev] = useState(''); + + const handleErrorSet = (errMessage: string) => { + setErrorMessage(errMessage); + setTimeout(() => { + setErrorMessage(''); + }, 3000); + }; + + const closeError = () => { + setErrorMessage(''); + }; + + const loadTodos = useCallback(async () => { + try { + const loadedTodos = await getTodos(USER_ID); + + setTodos(loadedTodos); + setPageIsLoaded(true); + } catch { + handleErrorSet(ErrorMessage.loadTodo); + } + }, []); + + useEffect(() => { + loadTodos(); + }, [loadTodos]); + + const visibleTodos = (filterBy: Filters) => { + if (todos) { + switch (filterBy) { + case Filters.All: + return todos; + case Filters.Active: + return todos.filter(todo => ( + !todo.completed + )); + case Filters.Completed: + return todos.filter(todo => ( + todo.completed + )); + default: return todos; + } + } + + return todos; + }; + + const handleFormSubmit = async (e?: Event) => { + e?.preventDefault(); + if (!query.trim().length) { + handleErrorSet(ErrorMessage.titleEmpty); + + return; + } + + try { + const newTodo = { + userId: USER_ID, + title: query.trim(), + completed: false, + }; + + setTempTodo({ ...newTodo, id: 0 }); + setPageIsLoaded(false); + + const newTodos = await addTodo('/todos', newTodo); + + setTodos(state => { + return [...state, newTodos as Todo]; + }); + + setTempTodo(null); + setPageIsLoaded(true); + setQuery(''); + } catch { + handleErrorSet(ErrorMessage.addTodo); + setTempTodo(null); + setPageIsLoaded(true); + } + }; + + const handleComplete = async (id: number, completed: boolean) => { + try { + setIsUpdatingId(state => ( + [...state, id] + )); + await updateTodo(id, { completed: !completed }); + setTodos(state => { + state.map(todo => { + if (todo.id === id) { + // eslint-disable-next-line no-param-reassign + todo.completed = !completed; + } + + return todo; + }); + + return [...state]; + }); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== id)] + )); + } catch { + handleErrorSet(ErrorMessage.updateTodo); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== id)] + )); + } + }; + + const toggleAll = () => { + const isStatus = todos.every(todo => todo.completed); + + setTodos(stateMain => { + stateMain.map(async (todo) => { + try { + if (isStatus && todo.completed) { + setIsUpdatingId(state => ( + [...state, todo.id] + )); + await updateTodo(todo.id, { completed: false }); + // eslint-disable-next-line no-param-reassign + todo.completed = false; + setIsUpdatingId([]); + + return [...stateMain]; + } + + if (!todo.completed) { + setIsUpdatingId(state => ( + [...state, todo.id] + )); + await updateTodo(todo.id, { completed: true }); + // eslint-disable-next-line no-param-reassign + todo.completed = true; + setIsUpdatingId([]); + + return [...stateMain]; + } + + return [...stateMain]; + } catch { + handleErrorSet(ErrorMessage.updateTodo); + setIsUpdatingId([]); + } + + return [...stateMain]; + }); + + return [...stateMain]; + }); + + setTodos(state => [...state]); + }; + + const handleDelete = async (id?: number) => { + if (id) { + try { + setIsUpdatingId(state => ( + [...state, id] + )); + await deleteTodo(id); + setTodos((state) => { + return [...state.filter(todo => todo.id !== id)]; + }); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== id)] + )); + } catch { + handleErrorSet(ErrorMessage.deleteTodo); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== id)] + )); + } + } else { + setTodos(stateMain => { + stateMain.forEach(async (todo) => { + try { + if (todo.completed === true) { + setIsUpdatingId(state => ( + [...state, todo.id] + )); + await deleteTodo(todo.id); + setTodos((state) => { + return [...state.filter(todoF => todoF.id !== todo.id)]; + }); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== todo.id)] + )); + } + } catch { + handleErrorSet(ErrorMessage.deleteTodo); + setIsUpdatingId([]); + } + }); + + return stateMain; + }); + } + }; + + const ecsKeyCancel = (e: string) => { + if (e === 'Escape') { + setEditQuery(editQueryPrev); + setIsEditing(null); + } + }; + + const handleEdit = (id: number, title: string) => { + document.addEventListener('keyup', event => { + ecsKeyCancel(event.key); + }); + + setIsEditing(id); + setEditQuery(title); + setEditQueryPrev(title); + }; + + const handleEditSubmit = async (queryEdit: string, id: number) => { + try { + if (!queryEdit.trim()) { + handleDelete(id); + + return; + } + + if (editQueryPrev === queryEdit.trim()) { + setIsEditing(null); + + return; + } + + setIsUpdatingId(state => ( + [...state, id] + )); + await updateTodo(id, { title: queryEdit.trim() }); + setTodos(stateMain => { + stateMain.map(todo => { + if (todo.id === id) { + // eslint-disable-next-line no-param-reassign + todo.title = queryEdit.trim(); + } + + return todo; + }); + + return stateMain; + }); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== id)] + )); + setIsEditing(null); + } catch { + handleErrorSet(ErrorMessage.updateTodo); + setIsUpdatingId((state) => ( + [...state.filter(stateId => stateId !== id)] + )); + } + }; + + const completedTodos = useMemo(() => { + return todos.filter(todo => todo.completed).length; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [todos, isUpdatingId]); + + const uncompletedTodos = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [todos, isUpdatingId]); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {todos && ( + + )} + + {/* Hide the footer if there are no todos */} + {!!todos.length && ( +
+ )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..55ae55c1da --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (url: string, data: any) => { + return client.post(url, data); +}; + +export const updateTodo = (todoId: number, data: any) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/Error.tsx b/src/components/Error.tsx new file mode 100644 index 0000000000..7bdcd222f0 --- /dev/null +++ b/src/components/Error.tsx @@ -0,0 +1,34 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; +import React from 'react'; + +type Props = { + errorMessage: string, + closeErrorMessage: () => void; +}; + +export const Error: React.FC = React.memo(({ + errorMessage, + closeErrorMessage, +}) => { + return ( +
+
+ ); +}); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..682713f7db --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Filters } from '../types/Filters'; + +type Props = { + filter: (filter: Filters) => void, + filterValue: string, + completedTodos: number, + uncompletedTodos: number, + handleMultipleDelete: () => void; +}; + +export const Footer: React.FC = React.memo(({ + filter, + filterValue, + completedTodos, + uncompletedTodos, + handleMultipleDelete, +}) => { + return ( + + ); +}); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..80132557b7 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; +import React, { useRef, useEffect } from 'react'; + +type Props = { + todosLength: number, + handleFormSubmit: () => void; + query: string | number; + setQuery: (event: string) => void; + uncompletedTodos: number, + toggleAll: () => void, + pageIsLoaded: boolean, +}; + +export const Header: React.FC = React.memo(({ + todosLength, + handleFormSubmit, + query, + setQuery, + uncompletedTodos, + toggleAll, + pageIsLoaded, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef, pageIsLoaded, uncompletedTodos, todosLength]); + + return ( +
+ {!!todosLength && ( +
+ ); +}); diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..787502bc47 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,151 @@ +/* eslint-disable jsx-a11y/no-autofocus */ +import classNames from 'classnames'; +import React from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[], + handleComplete: (id: number, completed: boolean) => void, + handleDelete: (id: number) => void, + isUpdatingId: number[], + isEditing: number | null, + handleEdit: (id: number, title: string) => void, + query: string, + setQuery: (title: string) => void + handleEditSubmit: (query: string, id: number) => void, + tempTodo: Todo | null, +}; + +export const TodoList: React.FC = React.memo(({ + todos, + handleComplete, + handleDelete, + isUpdatingId, + isEditing, + handleEdit, + query, + setQuery, + handleEditSubmit, + tempTodo, +}) => { + return ( +
+ {todos.map(todo => { + const { + id, + title, + completed, + } = todo; + + return ( +
+ + + {isEditing === id ? ( +
{ + event.preventDefault(); + handleEditSubmit(query, id); + }} + > + { + setQuery(event.target.value); + }} + onBlur={() => { + handleEditSubmit(query, id); + }} + /> +
+ ) : ( + <> + { + handleEdit(id, title); + }} + > + {title} + + + + + )} +
ids === id) }, + )} + > +
+
+
+
+ ); + })} + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}); diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..41fd5ae771 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + loadTodo = 'Unable to load todos', + titleEmpty = 'Title should not be empty', + addTodo = 'Unable to add a todo', + deleteTodo = 'Unable to delete a todo', + updateTodo = 'Unable to update a todo', +} diff --git a/src/types/Filters.ts b/src/types/Filters.ts new file mode 100644 index 0000000000..6786d4f5b3 --- /dev/null +++ b/src/types/Filters.ts @@ -0,0 +1,5 @@ +export enum Filters { + 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..ca588ab63a --- /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', + }; + } + + // we wait for testing purpose to see loaders + 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'), +};