From e1d365ab0cfca371113d183093d51c3d447ca9d6 Mon Sep 17 00:00:00 2001 From: VasylynaBC Date: Sat, 16 Nov 2024 00:44:01 +0200 Subject: [PATCH] add task solution --- src/App.tsx | 166 ++++++++++++++++++--- src/api/todos.ts | 31 ++++ src/components/Footer.tsx | 80 ++++++++++ src/components/Header.tsx | 102 +++++++++++++ src/components/TodoItems.tsx | 252 ++++++++++++++++++++++++++++++++ src/components/TodoList.tsx | 34 +++++ src/components/errorsMessage.ts | 8 + src/components/status.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++++ 10 files changed, 711 insertions(+), 19 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoItems.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/components/errorsMessage.ts create mode 100644 src/components/status.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..db88f10cff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,154 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import React, { FormEvent, useMemo, useState, useEffect } from 'react'; +import cn from 'classnames'; +import { Todo } from './types/Todo'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { getTodos } from './api/todos'; +import { FooterPart } from './components/Footer'; +import { ErrorMessage } from './components/errorsMessage'; +import { Status } from './components/status'; +import { createTodos, USER_ID } from './api/todos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; + const [todos, setTodos] = useState([]); + + const [todoTemplate, setTodoTempalte] = useState(null); + const [error, setError] = useState(''); + const [status, setStatus] = useState(Status.All); + const [valueTitle, setValue] = useState(''); + const [disableInput, setDisableInput] = useState(false); + + const uploadingTodos = useMemo(() => { + setError(ErrorMessage.noError); + + getTodos() + .then(setTodos) + .catch(() => { + setError(ErrorMessage.loadError); + setTimeout(() => { + setError(ErrorMessage.noError); + }, 3000); + }) + .finally(); + }, []); + + function deleteError() { + setError(ErrorMessage.noError); } + const addPost = (e: FormEvent) => { + e.preventDefault(); + const getTrim = valueTitle.trim(); + + if (!getTrim) { + setValue(''); + setError(ErrorMessage.titleError); + setTimeout(() => setError(ErrorMessage.noError), 3000); + + return false; + } + + setDisableInput(true); + setTodoTempalte({ + id: 0, + completed: false, + title: getTrim, + userId: USER_ID, + }); + todos.filter((todo: Todo) => !todo.completed); + setTodos(currentTodos => [ + ...currentTodos, + { id: 0, completed: false, title: getTrim, userId: USER_ID }, + ]); + + return createTodos({ completed: false, title: getTrim, userId: USER_ID }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setValue(''); + }) + .catch(() => { + setError(ErrorMessage.addError); + setTimeout(() => { + setError(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== 0)); + setTodoTempalte(null); + setDisableInput(false); + }); + }; + + const filterTodosByStatus = () => { + switch (status) { + case Status.Active: + return todos.filter((todo: Todo) => !todo.completed); + case Status.Completed: + return todos.filter((todo: Todo) => todo.completed); + default: + return todos; + } + }; + + const filteredTodos = filterTodosByStatus(); + + useEffect(() => uploadingTodos); + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..cd516728bf --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,31 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1843; + +// interface UpdateTodo { +// id: Todo['id']; +// objectData: Todo; +// } + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodos = (obj: Omit) => { + return client.post('/todos', obj); +}; + +export const updateTodos = ({ + id, + userId, + title, + completed, +}: Todo): Promise => { + return client.patch(`/todos/${id}`, { userId, title, completed }); +}; + +export const deleteTodos = (id: number) => { + return client.delete(`/todos/${id}`); +}; +// Add more methods here diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..c44b6e9716 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { Status } from './status'; +import { deleteTodos } from '../api/todos'; +import { ErrorMessage } from './errorsMessage'; +import cn from 'classnames'; + +interface Props { + posts: Todo[]; + filterStatus: (status: Status) => void; + status: Status; + setErrorMessage: (errorMessage: ErrorMessage) => void; + setTodos: React.Dispatch>; + todoTemplate: Todo | null; +} + +export const FooterPart: React.FC = ({ + posts, + filterStatus, + status, + setTodos, + todoTemplate, + setErrorMessage, +}) => { + const count = posts.filter( + value => !value.completed && todoTemplate != value && value.id != 0, + ).length; + const deleteComplete = () => { + posts.map(value => { + if (value.completed) { + deleteTodos(value.id) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== value.id), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.deleteError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }); + } + }); + }; + + return ( +
+ + {count} items left + + + + {/* this button should be disabled if there are no completed todos */} + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..8579c01e6c --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,102 @@ +import cn from 'classnames'; +import React, { ChangeEvent, FormEvent, useEffect, useRef } from 'react'; +import { updateTodos, USER_ID } from '../api/todos'; +import { Todo } from '../types/Todo'; +import { ErrorMessage } from './errorsMessage'; + +type Props = { + setTodos: React.Dispatch>; + setErrorMessage: (errorMessage: ErrorMessage) => void; + posts: Todo[]; + disableInput?: boolean; + setIsInputLoading: number; + setTodoTemplate: React.Dispatch>; + valueTitle: string; + setValue: React.Dispatch>; + addPost: (event: FormEvent) => void; +}; + +export const Header: React.FC = ({ + setTodos, + setErrorMessage, + posts, + setIsInputLoading, + addPost, + valueTitle, + setValue, + disableInput, +}) => { + const fieldFocus = useRef(null); + + useEffect(() => { + if (fieldFocus.current) { + fieldFocus.current.focus(); + } + }, [setIsInputLoading, posts]); + const areAllCompleted = posts.every((todo: Todo) => todo.completed); + + const setCompleted = () => { + let flag = true; + + if (areAllCompleted) { + flag = false; + } + + posts.map(post => { + const id = post.id; + + if (post.completed != flag) { + updateTodos({ + id: id, + userId: USER_ID, + title: post.title, + completed: flag, + }) + .then(respone => { + setTodos(prevTodos => + prevTodos.map((item: Todo) => + item.id === respone.id ? respone : item, + ), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.updateError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }); + } + }); + }; + + const onTextChange = (e: ChangeEvent) => { + setValue(e.target.value); + }; + + return ( +
+ {posts.length !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoItems.tsx b/src/components/TodoItems.tsx new file mode 100644 index 0000000000..2bcd050df4 --- /dev/null +++ b/src/components/TodoItems.tsx @@ -0,0 +1,252 @@ +import React, { ChangeEvent, useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { deleteTodos, updateTodos, USER_ID } from '../api/todos'; +import { ErrorMessage } from './errorsMessage'; + +type ItemProps = { + todoTemplate: Todo | null; + post: Todo; + setErrorMessage: (errorMessage: ErrorMessage) => void; + setTodos: React.Dispatch>; + error: ErrorMessage | ''; +}; +export const TodoItems: React.FC = ({ + post, + todoTemplate, + setErrorMessage, + setTodos, + error, +}) => { + const { completed, id, title } = post; + const [focusItems, setFocusItems] = useState(false); + const [inputTitle, setInputTitle] = useState(title); + const [deleteCheck, setDeleteCheck] = useState(false); + + const saveInputOnBlur = () => { + const trimValueBlur = inputTitle.trim(); + + if (trimValueBlur == '') { + deleteTodos(post.id) + .then(() => { + setFocusItems(false); + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + }) + .catch(() => { + setFocusItems(true); + setErrorMessage(ErrorMessage.deleteError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + if (error == '') { + setFocusItems(false); + } + + setDeleteCheck(false); + }); + } else { + setInputTitle(trimValueBlur); + updateTodos({ + id: post.id, + userId: USER_ID, + title: trimValueBlur, + completed: completed, + }) + .then(respone => { + setFocusItems(false); + setTodos(prevTodos => + prevTodos.map((item: Todo) => + item.id === respone.id ? respone : item, + ), + ); + }) + + .catch(() => { + setErrorMessage(ErrorMessage.updateError); + setFocusItems(true); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + setDeleteCheck(false); + }); + } + }; + + const saveInput = (evt: React.KeyboardEvent) => { + if (evt.key === 'Enter') { + const trimValue = inputTitle.trim(); + + if (trimValue == '') { + setDeleteCheck(true); + + deleteTodos(id) + .then(() => { + setFocusItems(false); + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== id), + ); + }) + .catch(() => { + setFocusItems(true); + setErrorMessage(ErrorMessage.deleteError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + setDeleteCheck(false); + }); + } else { + setDeleteCheck(true); + setFocusItems(false); + setInputTitle(trimValue.trim()); + updateTodos({ + id: id, + userId: USER_ID, + title: trimValue, + completed: completed, + }) + .then(respone => { + setTodos(prevTodos => + prevTodos.map((item: Todo) => + item.id === respone.id ? respone : item, + ), + ); + setFocusItems(false); + }) + .catch(() => { + setFocusItems(true); + + setErrorMessage(ErrorMessage.updateError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + setDeleteCheck(false); + }); + } + } + + if (evt.key === 'Escape') { + setFocusItems(false); + setInputTitle(post.title.trim()); + } + }; + + const toggleCompleted = (todo: Todo) => { + const updatedTodo: Todo = { + id: todo.id, + userId: todo.userId, + title: todo.title, + completed: !todo.completed, + }; + + setDeleteCheck(true); + + updateTodos(updatedTodo) + .then(respone => { + setTodos(prevTodos => + prevTodos.map((item: Todo) => + item.id === respone.id ? respone : item, + ), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.updateError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + setDeleteCheck(false); + }); + }; + + const onDelete = () => { + setDeleteCheck(true); + deleteTodos(post.id) + .then(() => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + }) + .catch(() => { + setErrorMessage(ErrorMessage.deleteError); + setTimeout(() => { + setErrorMessage(ErrorMessage.noError); + }, 3000); + }) + .finally(() => { + setDeleteCheck(false); + }); + }; + + const doubleClickItem = () => { + setFocusItems(true); + }; + + const onTextChange = (e: ChangeEvent) => { + setInputTitle(e.target.value); + }; + + return ( +
+ + {focusItems ? ( + + ) : ( + <> + + {inputTitle} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..0ff0ad4b0c --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,34 @@ +import { Todo } from '../types/Todo'; +import { TodoItems } from './TodoItems'; +import { ErrorMessage } from './errorsMessage'; + +type Props = { + posts: Todo[]; + setTodos: React.Dispatch>; + setErrorMessage: (errorMessage: ErrorMessage) => void; + todoTemplate: Todo | null; + error: ErrorMessage | ''; +}; + +export const TodoList: React.FC = ({ + posts, + todoTemplate, + setTodos, + error, + setErrorMessage, +}) => { + return ( +
+ {posts.map(post => ( + + ))} +
+ ); +}; diff --git a/src/components/errorsMessage.ts b/src/components/errorsMessage.ts new file mode 100644 index 0000000000..b5f01c89e1 --- /dev/null +++ b/src/components/errorsMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + noError = '', + loadError = 'Unable to load todos', + titleError = '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/components/status.ts b/src/components/status.ts new file mode 100644 index 0000000000..dc864cc93b --- /dev/null +++ b/src/components/status.ts @@ -0,0 +1,5 @@ +export enum Status { + 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..28c946e7f6 --- /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(`${response.status} ${response.statusText}`); + } + + 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'), +};