diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..6b686f2d49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,170 @@ -/* 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, useMemo, useRef, useState } from 'react'; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { UserWarning } from './UserWarning'; +import { Errors } from './types/Errors'; +import { ErrorNotification } from './components/ErrorNotification'; +import { FilterType } from './types/FilterType'; +import { TodoList } from './components/TodoList/TodoList'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(Errors.Empty); + const [filteredField, setFilteredField] = useState( + FilterType.All, + ); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const inputAddRef = useRef(null); + + const todosActiveNumber = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const areAllTodosCompleted = useMemo(() => { + return todos.every(todo => todo.completed === true); + }, [todos]); + + useEffect(() => { + (async () => { + try { + const data = await todoService.getTodos(); + + setTodos(data); + } catch (err) { + setErrorMessage(Errors.UnableToLoad); + } + })(); + }, []); + + const onAddTodo = async (todoTitle: string) => { + setTempTodo({ + id: 0, + title: todoTitle, + completed: false, + userId: todoService.USER_ID, + }); + try { + const newTodo = await todoService.addTodo({ + title: todoTitle, + completed: false, + }); + + setTodos(prev => [...prev, newTodo]); + } catch (err) { + setErrorMessage(Errors.UnableToAdd); + inputAddRef?.current?.focus(); + throw err; + } finally { + setTempTodo(null); + } + }; + + const onRemoveTodo = async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + try { + await todoService.deleteTodo(todoId); + + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch (err) { + setErrorMessage(Errors.UnableToDelete); + inputAddRef?.current?.focus(); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + } + }; + + const onUpdateTodo = async (todoToUpdate: Todo) => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + try { + const updatedTodo = await todoService.updateTodo(todoToUpdate); + + setTodos(prev => + prev.map(todo => (todo.id === updatedTodo.id ? updatedTodo : todo)), + ); + } catch (err) { + setErrorMessage(Errors.UnableToUpdate); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const onToggleAll = async () => { + if (todosActiveNumber > 0) { + todos + .filter(todo => !todo.completed) + .forEach(item => onUpdateTodo({ ...item, completed: true })); + } else { + todos.forEach(todo => onUpdateTodo({ ...todo, completed: false })); + } + }; + + const onClearCompleted = async () => { + const completedTodo = todos.filter(todo => todo.completed); + + completedTodo.forEach(todo => onRemoveTodo(todo.id)); + }; + + const filteredTodos = todos.filter(todo => { + switch (filteredField) { + case FilterType.Active: + return !todo.completed; + case FilterType.Completed: + return todo.completed; + default: + return todos; + } + }); + + 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

+ +
+
+ + {(todos.length > 0 || tempTodo) && ( + <> + +
+ + )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..0100558efb --- /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 = 2136; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (newTodo: Omit) => { + return client.post('/todos', { ...newTodo, userId: USER_ID }); +}; + +export function updateTodo(todo: Todo) { + return client.patch(`/todos/${todo.id}`, todo); +} diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..af8d3eeef9 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import { Errors } from '../../types/Errors'; +import { Dispatch, SetStateAction, useEffect } from 'react'; + +type Props = { + error: Errors; + setErrorMessage: Dispatch>; +}; +export const ErrorNotification: React.FC = props => { + const { error, setErrorMessage } = props; + + useEffect(() => { + if (error === Errors.Empty) { + return; + } + + const timerId = setTimeout(() => { + setErrorMessage(Errors.Empty); + }, 3000); + + return () => { + clearTimeout(timerId); + }; + }, [error, 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/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..debe17876d --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; + +import { Dispatch, SetStateAction } from 'react'; +import { Todo } from '../../types/Todo'; +import { FilterType } from '../../types/FilterType'; + +type Props = { + todos: Todo[]; + activeTodo: number; + filteredField: FilterType; + setFilteredField: Dispatch>; + onClearCompleted: () => Promise; +}; + +export const Footer: React.FC = ({ + todos, + activeTodo, + filteredField, + setFilteredField, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..dfd0eaf4c7 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,81 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import cn from 'classnames'; +import { Errors } from '../../types/Errors'; + +type Props = { + setErrorMessage: Dispatch>; + onAddTodo: (value: string) => Promise; + isInputDisabled: boolean; + onToggleAll: () => Promise; + todosLength: number; + inputRef: React.RefObject | null; + areAllTodosCompleted: boolean; +}; + +export const Header: React.FC = props => { + const { + setErrorMessage, + onAddTodo, + isInputDisabled, + onToggleAll, + todosLength, + inputRef, + areAllTodosCompleted, + } = props; + const [inputValue, setInputValue] = useState(''); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (inputValue.trim() === '') { + setErrorMessage(Errors.EmptyTitle); + + return; + } + + try { + await onAddTodo(inputValue.trim()); + setInputValue(''); + } catch (err) {} + }; + + useEffect(() => { + inputRef?.current?.focus(); + }, [todosLength, inputRef]); + + useEffect(() => { + if (!isInputDisabled) { + inputRef?.current?.focus(); + } + }, [isInputDisabled, inputRef]); + + return ( +
+ {todosLength !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..e54423a587 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,123 @@ +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { Dispatch, SetStateAction, useRef, useState } from 'react'; +import { SubmitFormEvent } from '../../types/htmlFormElements'; + +type Props = { + isLoading?: boolean; + todo: Todo; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + isInEditMode?: boolean; + setEditedTodoId: Dispatch>; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onRemoveTodo, + onUpdateTodo, + isInEditMode, + setEditedTodoId, +}) => { + const [todoTitleValue, setTodoTitleValue] = useState(todo.title); + + const inputRef = useRef(null); + + const onCheckTodo = () => { + const todoToUpdate = { ...todo, completed: !todo.completed }; + + onUpdateTodo(todoToUpdate); + }; + + const onDoubleClick = () => { + setEditedTodoId(todo.id); + }; + + const onBlur = async (event: SubmitFormEvent) => { + event.preventDefault(); + + const normalizedTitle = todoTitleValue.trim(); + + if (normalizedTitle === todo.title) { + setEditedTodoId(null); + + return; + } + + try { + if (normalizedTitle === '') { + await onRemoveTodo(todo.id); + } else { + await onUpdateTodo({ ...todo, title: todoTitleValue.trim() }); + } + + setEditedTodoId(null); + } catch (err) { + inputRef?.current?.focus(); + } + }; + + const onKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditedTodoId(null); + setTodoTitleValue(todo.title); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {isInEditMode ? ( +
+ setTodoTitleValue(event?.target.value)} + onKeyUp={onKeyUp} + ref={inputRef} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..490cbad71f --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { Todo } from '../../types/Todo'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +type Props = { + isLoading?: boolean; + todos: Todo[]; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + tempTodo: Todo | null; + loadingTodoIds: number[]; +}; + +export const TodoList: React.FC = ({ + todos, + onRemoveTodo, + onUpdateTodo, + tempTodo, + loadingTodoIds, +}) => { + const [editedTodoId, setEditedTodoId] = useState(null); + + return ( + +
+ {todos.map(todo => ( + + + + ))} + {tempTodo && ( + + + + )} +
+
+ ); +}; 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/Errors.ts b/src/types/Errors.ts new file mode 100644 index 0000000000..b0f28f9f6d --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,8 @@ +export enum Errors { + Empty = '', + UnableToLoad = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToDelete = 'Unable to delete a todo', + UnableToUpdate = 'Unable to update a todo', +} diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 0000000000..1c3630f736 --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + Completed = 'Completed', + Active = 'Active', + All = 'All', +} 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/types/htmlFormElements.ts b/src/types/htmlFormElements.ts new file mode 100644 index 0000000000..63e88781e2 --- /dev/null +++ b/src/types/htmlFormElements.ts @@ -0,0 +1,3 @@ +export type SubmitFormEvent = + | React.FocusEvent + | React.FormEvent; diff --git a/src/types/rest.tsx b/src/types/rest.tsx new file mode 100644 index 0000000000..a94f5bb8c6 --- /dev/null +++ b/src/types/rest.tsx @@ -0,0 +1,80 @@ +// const updateTodo = (itemId, updatedTitle) => { +// const unmodifiedTodo = todos.find(todo => todo.id === itemId); + +// if (!unmodifiedTodo) { +// endChaning(itemId); + +// return; +// } + +// if (updatedTitle === unmodifiedTodo.title) { +// return; +// } + +// const updatedTodo = { +// ...unmodifiedTodo, +// title: updatedTitle !== null ? updatedTitle.trim() : unmodifiedTodo.title, +// completed: +// updatedTitle === null +// ? !unmodifiedTodo.completed +// : unmodifiedTodo.completed, +// }; + +// if (updatedTitle !== null && updatedTodo.title === '') { +// return deleteTodo(itemId); +// } + +// setTodos(prev => +// prev.map(todo => (todo.id === itemId ? updatedTodo : todo)), +// ); + +// return todoService +// .updateTodo(updateTodo) +// .then(res => +// setTodos(prev => prev.map(todo => (todo.id === itemId ? res : todo))), +// ) +// .catch(err => { +// setTodos(prev => +// prev.map(todo => (todo.id === itemId ? unmodifiedTodo : todo)), +// ); +// showErrorMes('Unable to update a todo'); +// throw err; +// }) +// .finally(() => endChaning(itemId)); +// }; + +// const clearComleted = () => { +// const completedIds = todos +// .filter(todo => todo.completed) +// .map(todo => todo.id); + +// completedIds.forEach(id => deleteTodo(id)); +// }; + +// function updateTodo(todoToUpdate: Todo) { +// postService.updateTodo(todoToUpdate).then(updatedTodo => { +// setSelectedTodo(null); + +// setTodos(currentTodos => { +// return currentTodos.map(todo => +// todo.id === updatedTodo.id ? updatedTodo : todo, +// ); +// }); +// }); +// } + +// function updatePost(postToUpdate: Post) { +// setErrorMessage(''); + +// return postService.updatePost(postToUpdate) +// .then(updatedPost => { +// setSelectedPost(null); + +// setPosts(currentPosts => { +// return currentPosts.map(post => +// post.id === updatedPost.id ? updatedPost : post, +// ); +// }); +// }) +// .catch(() => setErrorMessage('Failed to update post')); +// } diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..708ac4c17b --- /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'), +};