diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..87ab2f0014 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,225 @@ /* 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 React, { useState, useEffect } from 'react'; import { UserWarning } from './UserWarning'; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; +import { FilterStatus } from './types/FilterStatus'; +import { ErrorMessage } from './types/ErrorMessage'; -const USER_ID = 0; +import { ErrorNotification } from './components/ErrorNotification'; + +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + const [filter, setFilter] = useState(FilterStatus.All); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [loadingIds, setLoadingIds] = useState([]); + + const todoFieldRef = React.useRef(null); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + + const showError = (message: ErrorMessage) => { + setErrorMessage(message); + setTimeout(() => setErrorMessage(null), 3000); + }; + + useEffect(() => { + const loadTodos = async () => { + setErrorMessage(null); + + try { + const data = await todoService.getTodos(); + + setTodos(data); + } catch { + showError(ErrorMessage.Load); + } + }; + + loadTodos(); + todoFieldRef.current?.focus(); + }, []); + + useEffect(() => { + if (!isLoading) { + todoFieldRef.current?.focus(); + } + }, [isLoading, todos.length]); + + const visibleTodos = todos.filter(todo => { + if (filter === FilterStatus.Active) { + return !todo.completed; + } + + if (filter === FilterStatus.Completed) { + return todo.completed; + } + + return true; + }); + + if (!todoService.USER_ID) { return ; } + const handleFormSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setErrorMessage(null); + + const trimmedTodoTitle = newTodoTitle.trim(); + + if (!trimmedTodoTitle) { + showError(ErrorMessage.Title); + + return; + } + + setIsLoading(true); + + const newTempTodo = { + id: 0, + userId: todoService.USER_ID, + title: trimmedTodoTitle, + completed: false, + }; + + setTempTodo(newTempTodo); + + try { + const newTodo = await todoService.createTodo(trimmedTodoTitle); + + setTodos(prev => [...prev, newTodo]); + setNewTodoTitle(''); + } catch { + showError(ErrorMessage.Add); + } finally { + setIsLoading(false); + setTempTodo(null); + } + }; + + const onDeleteTodo = async (todoId: number) => { + setErrorMessage(null); + + setLoadingIds(prev => [...prev, todoId]); + + try { + await todoService.deleteTodo(todoId); + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch { + showError(ErrorMessage.Delete); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todoId)); + } + }; + + const onToggleTodo = async (todo: Todo) => { + setErrorMessage(null); + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updatedTodo = await todoService.updateTodo(todo.id, { + completed: !todo.completed, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updatedTodo : t))); + } catch { + showError(ErrorMessage.Update); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const onUpdateTodo = async (todoToUpdate: Todo, newTitle: string) => { + setErrorMessage(null); + setLoadingIds(prev => [...prev, todoToUpdate.id]); + + try { + const updatedTodo = await todoService.updateTodo(todoToUpdate.id, { + title: newTitle, + }); + + setTodos(prev => + prev.map(t => (t.id === todoToUpdate.id ? updatedTodo : t)), + ); + } catch (error) { + showError(ErrorMessage.Update); + throw error; + } finally { + setLoadingIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const clearCompleted = () => { + const completedTodos = todos.filter(t => t.completed); + + completedTodos.forEach(todo => { + onDeleteTodo(todo.id); + }); + }; + + const onToggleAll = () => { + const areAllCompleted = todos.every(todo => todo.completed); + const todosToUpdate = todos.filter( + todo => todo.completed === areAllCompleted, + ); + + todosToUpdate.forEach(todo => onToggleTodo(todo)); + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
0 && todos.every(todo => todo.completed) + } + isLoading={isLoading} + onToggleAll={onToggleAll} + hasTodos={!!todos.length} + /> + + {(!!todos.length || tempTodo) && ( + <> + +
todo.completed)} + onClearCompleted={clearCompleted} + /> + + )} +
+ + setErrorMessage(null)} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..e982fa4541 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4002; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (title: string) => { + return client.post('/todos', { + title, + userId: USER_ID, + completed: false, + }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, data: Partial) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..79a7fcc0ae --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import cn from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; + +type Props = { + message: ErrorMessage | null; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ message, onClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..f5222a0f24 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import cn from 'classnames'; +import { FilterStatus } from '../types/FilterStatus'; + +type Props = { + activeTodosCount: number; + filter: FilterStatus; + setFilter: (status: FilterStatus) => void; + hasCompleted: boolean; + onClearCompleted: () => void; +}; + +const filterLinks = [ + { status: FilterStatus.All, label: 'All', href: '#/', cy: 'FilterLinkAll' }, + + { + status: FilterStatus.Active, + label: 'Active', + href: '#/active', + cy: 'FilterLinkActive', + }, + { + status: FilterStatus.Completed, + label: 'Completed', + href: '#/completed', + cy: 'FilterLinkCompleted', + }, +]; + +export const Footer: React.FC = ({ + activeTodosCount, + filter, + setFilter, + hasCompleted, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..d13e889bc2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,55 @@ +import React, { forwardRef } from 'react'; +import cn from 'classnames'; + +type Props = { + title: string; + onTitleChange: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + isToggleAllActive: boolean; + isLoading: boolean; + onToggleAll: () => void; + hasTodos: boolean; +}; + +export const Header = forwardRef( + ( + { + title, + onTitleChange, + onSubmit, + isToggleAllActive, + isLoading, + onToggleAll, + hasTodos, + }, + ref, + ) => ( +
+ {hasTodos && ( +
+ ), +); + +Header.displayName = 'Header'; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..c15c66c734 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,143 @@ +/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState, useEffect, useRef } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + isLoading: boolean; + onDeleteTodo: (todoId: number) => void; + onToggleTodo: (todo: Todo) => void; + onUpdateTodo: (todo: Todo, newTitle: string) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onDeleteTodo, + onToggleTodo, + onUpdateTodo, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const editFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editFieldRef.current?.focus(); + } + }, [isEditing]); + + useEffect(() => { + setTitle(todo.title); + }, [todo.title]); + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleCancel = () => { + setIsEditing(false); + setTitle(todo.title); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleCancel(); + } + + if (event.key === 'Enter') { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handleSubmit(event); + } + }; + + const handleSubmit = async (event?: React.BaseSyntheticEvent) => { + event?.preventDefault(); + + if (isLoading) { + return; + } + + const trimmedTitle = title.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDeleteTodo(todo.id); + + return; + } + + try { + await onUpdateTodo(todo, trimmedTitle); + setIsEditing(false); + } catch (error) {} + }; + + return ( +
+ + + {isEditing ? ( +
+ setTitle(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSubmit} + autoFocus + disabled={isLoading} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..71453907f5 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,45 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + loadingIds: number[]; + tempTodo: Todo | null; + onDeleteTodo: (todoId: number) => void; + onToggleTodo: (todo: Todo) => void; + onUpdateTodo: (todo: Todo, newTitle: string) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + loadingIds, + tempTodo, + onDeleteTodo, + onToggleTodo, + onUpdateTodo, +}) => ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && ( + + )} +
+); diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..91de57ea7f --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + Load = 'Unable to load todos', + Title = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 0000000000..13d1a2739c --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + 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..5620945028 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,45 @@ +/* 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', + }; + } + + 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'), +};