From 356d94302e23f0ac605743bec2f9308b9564d620 Mon Sep 17 00:00:00 2001 From: Jakub Bajkowski Date: Mon, 23 Dec 2024 14:01:57 +0100 Subject: [PATCH 1/2] add task solution --- src/App.tsx | 198 ++++++++++++++++++++++++++++++++---- src/api/todos.ts | 20 ++++ src/components/Error.tsx | 42 ++++++++ src/components/Footer.tsx | 53 ++++++++++ src/components/Header.tsx | 72 +++++++++++++ src/components/TodoItem.tsx | 126 +++++++++++++++++++++++ src/styles/index.scss | 47 +++++++++ src/types/ErrorTypes.tsx | 7 ++ src/types/FilterTypes.tsx | 5 + src/types/Todo.ts | 6 ++ src/utils/fetchClient.ts | 42 ++++++++ 11 files changed, 601 insertions(+), 17 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/TodoItem.tsx create mode 100644 src/types/ErrorTypes.tsx create mode 100644 src/types/FilterTypes.tsx 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..e418b5d3d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,190 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { Error } from './components/Error'; +import { TodoItem } from './components/TodoItem'; +import { FilterType } from './types/FilterTypes'; +import { ErrorTypes } from './types/ErrorTypes'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + const [filterType, setFilterType] = useState(FilterType.All); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [editingTodo, setEditingTodo] = useState(null); + + const visibleTodos = useMemo( + () => + todos.filter(todo => { + if (filterType === FilterType.All) { + return true; + } + + return filterType === FilterType.Completed + ? todo.completed + : !todo.completed; + }), + [todos, filterType], + ); + + const inputField = useRef(null); + + const isAllCompleted = todos.every(todo => todo.completed); + const activeTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + + const onAddTodo = async (newTodoTitle: string) => { + setIsSubmitting(true); + setTempTodo({ + id: 0, + title: newTodoTitle, + completed: false, + userId: todoService.USER_ID, + }); + try { + const newTodo = await todoService.addTodo({ + title: newTodoTitle, + completed: false, + userId: todoService.USER_ID, + }); + + setTodos(currentTodos => [...currentTodos, newTodo]); + } catch (error) { + setErrorMessage(ErrorTypes.Adding); + inputField?.current?.focus(); + throw error; + } finally { + setIsSubmitting(false); + setTempTodo(null); + } + }; + + const onDeleteTodo = async (todoToDelete: number) => { + setLoadingTodoIds(prev => [...prev, todoToDelete]); + try { + await todoService.deleteTodo(todoToDelete); + setTodos(prev => prev.filter(todo => todo.id !== todoToDelete)); + } catch (error) { + setErrorMessage(ErrorTypes.Deleting); + inputField?.current?.focus(); + throw error; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToDelete)); + } + }; + + const onUpdateTodo = async (todoToUpdate: Todo) => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + try { + const updated = await todoService.updateTodo(todoToUpdate); + + setTodos(prev => + prev.map(todo => { + return todo.id === updated.id ? updated : todo; + }), + ); + } catch (error) { + setErrorMessage(ErrorTypes.Updating); + throw error; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const onToggleAll = async () => { + if (activeTodos.length > 0) { + activeTodos.forEach(todo => { + onUpdateTodo({ ...todo, completed: true }); + }); + } else { + todos.forEach(todo => { + onUpdateTodo({ ...todo, completed: false }); + }); + } + }; + + const onClearCompleted = async () => { + const completed = todos.filter(todo => todo.completed); + + completed.forEach(todo => { + onDeleteTodo(todo.id); + }); + }; + + useEffect(() => { + todoService + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage(ErrorTypes.Loading); + }); + }, []); + + if (!todoService.USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+
+ + {visibleTodos.map((todo: Todo) => ( + + + + ))} + {tempTodo && ( + + + + )} + +
+ + {todos.length > 0 && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..4f300da168 --- /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 = 2191; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const addTodo = (newTodo: Omit) => { + return client.post(`/todos`, { ...newTodo }); +}; + +export const updateTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/components/Error.tsx b/src/components/Error.tsx new file mode 100644 index 0000000000..b3812c482e --- /dev/null +++ b/src/components/Error.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; +import { ErrorTypes } from '../types/ErrorTypes'; +import { Dispatch, SetStateAction, useEffect } from 'react'; + +type Props = { + errorMessage: ErrorTypes | null; + setErrorMessage: Dispatch>; +}; + +export const Error: React.FC = ({ errorMessage, setErrorMessage }) => { + useEffect(() => { + if (errorMessage === null) { + return; + } + + const timerId = setTimeout(() => { + setErrorMessage(null); + }, 3000); + + return () => { + clearTimeout(timerId); + }; + }, [errorMessage, setErrorMessage]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..2f0289c815 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,53 @@ +import classNames from 'classnames'; +import { FilterType } from '../types/FilterTypes'; +import { Todo } from '../types/Todo'; + +type Props = { + activeTodos: Todo[]; + completedTodos: Todo[]; + filterType: FilterType; + setFilterType: (selectedOption: FilterType) => void; + onClearCompleted: () => Promise; +}; + +export const Footer: React.FC = ({ + activeTodos, + filterType, + completedTodos, + setFilterType, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..7274de861d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,72 @@ +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import { ErrorTypes } from '../types/ErrorTypes'; + +type Props = { + isAllCompleted: boolean; + onAddTodo: (newTodoTitle: string) => Promise; + isSubmitting: boolean; + setErrorMessage: (error: ErrorTypes) => void; + todosLength: number; + inputField: React.RefObject; + onToggleAll: () => void; +}; +export const Header: React.FC = ({ + isAllCompleted, + onAddTodo, + isSubmitting, + setErrorMessage, + todosLength, + inputField, + onToggleAll, +}) => { + const [todoTitle, setTodoTitle] = useState(''); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (todoTitle.trim() === '') { + setErrorMessage(ErrorTypes.Titling); + + return; + } + + try { + await onAddTodo(todoTitle.trim()); + setTodoTitle(''); + } catch (error) {} + }; + + useEffect(() => { + if (inputField.current || !isSubmitting) { + inputField?.current?.focus(); + } + }, [inputField, todosLength, isSubmitting]); + + return ( +
+ {todosLength > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..b609693379 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,126 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useRef, useState } from 'react'; +type Props = { + todo: Todo; + isLoading?: boolean; + isEditing?: boolean; + onDeleteTodo: (id: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + setEditingTodo: (id: number | null) => void; +}; +export const TodoItem: React.FC = ({ + todo, + isLoading, + isEditing, + onDeleteTodo, + onUpdateTodo, + setEditingTodo, +}) => { + const [editingTitle, setEditingTitle] = useState(todo.title); + + const inputRef = useRef(null); + + const onChangeStatus = () => { + const toUpdate = { ...todo, completed: !todo.completed }; + + onUpdateTodo(toUpdate); + }; + + const changeTitle = async (event: React.FormEvent) => { + event.preventDefault(); + + if (todo.title === editingTitle.trim()) { + setEditingTodo(null); + + return; + } + + if (editingTitle.trim() === '') { + try { + await onDeleteTodo(todo.id); + setEditingTodo(null); + } catch (error) { + inputRef?.current?.focus(); + } + + return; + } + + try { + await onUpdateTodo({ ...todo, title: editingTitle.trim() }); + setEditingTodo(null); + } catch (error) { + inputRef?.current?.focus(); + } + }; + + const cancelEditing = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditingTodo(null); + setEditingTitle(todo.title); + } + }; + + return ( +
+ + {isEditing ? ( +
+ setEditingTitle(event.target.value)} + onKeyUp={cancelEditing} + /> +
+ ) : ( + <> + setEditingTodo(todo.id)} + > + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..f2f19f4f7f 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -23,3 +23,50 @@ body { @import "./todoapp"; @import "./todo"; @import "./filter"; + +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/types/ErrorTypes.tsx b/src/types/ErrorTypes.tsx new file mode 100644 index 0000000000..1ae2eb1146 --- /dev/null +++ b/src/types/ErrorTypes.tsx @@ -0,0 +1,7 @@ +export enum ErrorTypes { + Loading = 'Unable to load todos', + Titling = 'Title should not be empty', + Adding = 'Unable to add a todo', + Deleting = 'Unable to delete a todo', + Updating = 'Unable to update a todo', +} diff --git a/src/types/FilterTypes.tsx b/src/types/FilterTypes.tsx new file mode 100644 index 0000000000..579c7f50ce --- /dev/null +++ b/src/types/FilterTypes.tsx @@ -0,0 +1,5 @@ +export enum FilterType { + 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..5be775084e --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + 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 143b2a4384d340dd8d5d27ba134701fc04cad549 Mon Sep 17 00:00:00 2001 From: Jakub Bajkowski Date: Mon, 23 Dec 2024 17:06:53 +0100 Subject: [PATCH 2/2] add task solution --- src/components/Error.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/Error.tsx b/src/components/Error.tsx index b3812c482e..c27f7061c9 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import { ErrorTypes } from '../types/ErrorTypes'; -import { Dispatch, SetStateAction, useEffect } from 'react'; +import { Dispatch, SetStateAction, useEffect, useRef } from 'react'; type Props = { errorMessage: ErrorTypes | null; @@ -8,17 +8,25 @@ type Props = { }; export const Error: React.FC = ({ errorMessage, setErrorMessage }) => { + const timerRef = useRef(null); + useEffect(() => { if (errorMessage === null) { return; } - const timerId = setTimeout(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { setErrorMessage(null); }, 3000); return () => { - clearTimeout(timerId); + if (timerRef.current) { + clearTimeout(timerRef.current); + } }; }, [errorMessage, setErrorMessage]); @@ -34,7 +42,13 @@ export const Error: React.FC = ({ errorMessage, setErrorMessage }) => { data-cy="HideErrorButton" type="button" className="delete" - onClick={() => setErrorMessage(null)} + onClick={() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + setErrorMessage(null); + }} /> {errorMessage}