diff --git a/README.md b/README.md index 47a1add059..7c26374046 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ Implement the ability to edit a todo title on double click: - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_todo-app-with-api/) and add it to the PR description. +- Replace `fmoreira85` with your Github username in the [DEMO LINK](https://fmoreira85.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..11fe5fcf7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,477 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { + createTodo, + deleteTodo as deleteTodoRequest, + getTodos, + updateTodo as updateTodoRequest, +} from './api/todos'; +import { ERROR_MESSAGES, ERROR_HIDE_DELAY, FILTERS } from './constants'; +import type { FilterStatus, Todo, TodoUpdate } from './types/Todo'; import { UserWarning } from './UserWarning'; -const USER_ID = 0; +const getUserId = () => { + const savedUser = localStorage.getItem('user'); + + if (!savedUser) { + return 0; + } + + try { + const parsedUser = JSON.parse(savedUser); + + return Number(parsedUser.id) || 0; + } catch { + return 0; + } +}; + +const getVisibleTodos = (todos: Todo[], filter: FilterStatus) => { + switch (filter) { + case FILTERS.ACTIVE: + return todos.filter(todo => !todo.completed); + + case FILTERS.COMPLETED: + return todos.filter(todo => todo.completed); + + default: + return todos; + } +}; + +const getFilterFromHash = (): FilterStatus => { + const hash = window.location.hash.replace(/^#\/?/, ''); + + switch (hash) { + case FILTERS.ACTIVE: + return FILTERS.ACTIVE; + + case FILTERS.COMPLETED: + return FILTERS.COMPLETED; + + default: + return FILTERS.ALL; + } +}; export const App: React.FC = () => { - if (!USER_ID) { + const userId = getUserId(); + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(getFilterFromHash); + const [newTitle, setNewTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [editingTodoId, setEditingTodoId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const newTodoFieldRef = useRef(null); + const errorTimeoutId = useRef(null); + const tempTodoId = useRef(0); + + const visibleTodos = useMemo( + () => getVisibleTodos(todos, filter), + [filter, todos], + ); + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodos = todos.filter(todo => todo.completed); + const hasTodos = todos.length > 0; + const areAllTodosCompleted = hasTodos && activeTodosCount === 0; + + const clearError = useCallback(() => { + if (errorTimeoutId.current) { + window.clearTimeout(errorTimeoutId.current); + errorTimeoutId.current = null; + } + + setErrorMessage(''); + }, []); + + const showError = useCallback( + (message: string) => { + clearError(); + setErrorMessage(message); + errorTimeoutId.current = window.setTimeout(() => { + setErrorMessage(''); + errorTimeoutId.current = null; + }, ERROR_HIDE_DELAY); + }, + [clearError], + ); + + const focusNewTodoField = () => { + newTodoFieldRef.current?.focus(); + }; + + const addLoadingTodo = (todoId: number) => { + setLoadingTodoIds(currentIds => + currentIds.includes(todoId) ? currentIds : [...currentIds, todoId], + ); + }; + + const removeLoadingTodo = (todoId: number) => { + setLoadingTodoIds(currentIds => currentIds.filter(id => id !== todoId)); + }; + + const closeTodoEditor = () => { + setEditingTodoId(null); + setEditingTitle(''); + }; + + const replaceTodo = (updatedTodo: Todo) => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ), + ); + }; + + useEffect(() => { + if (!userId) { + return; + } + + const loadTodos = async () => { + try { + const loadedTodos = await getTodos(userId); + + setTodos(loadedTodos); + } catch { + showError(ERROR_MESSAGES.load); + } + }; + + void loadTodos(); + }, [showError, userId]); + + useEffect(() => { + newTodoFieldRef.current?.focus(); + }, []); + + useEffect(() => { + const handleHashChange = () => { + setFilter(getFilterFromHash()); + }; + + window.addEventListener('hashchange', handleHashChange); + + return () => { + window.removeEventListener('hashchange', handleHashChange); + }; + }, []); + + useEffect( + () => () => { + clearError(); + }, + [clearError], + ); + + const handleCreateTodo = async (event: React.FormEvent) => { + event.preventDefault(); + clearError(); + + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + showError(ERROR_MESSAGES.emptyTitle); + focusNewTodoField(); + + return; + } + + tempTodoId.current -= 1; + + const pendingTodo: Todo = { + id: tempTodoId.current, + userId, + title: trimmedTitle, + completed: false, + }; + + setIsCreating(true); + setTempTodo(pendingTodo); + + try { + const createdTodo = await createTodo({ + userId, + title: trimmedTitle, + completed: false, + }); + + setTodos(currentTodos => [...currentTodos, createdTodo]); + setNewTitle(''); + setTempTodo(null); + } catch { + setTempTodo(null); + showError(ERROR_MESSAGES.add); + } finally { + setIsCreating(false); + focusNewTodoField(); + } + }; + + const handleDeleteTodo = async ( + todoId: number, + options: { + preserveEditingOnFail?: boolean; + focusOnSuccess?: boolean; + } = {}, + ) => { + clearError(); + addLoadingTodo(todoId); + + try { + await deleteTodoRequest(todoId); + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== todoId)); + + if (editingTodoId === todoId) { + closeTodoEditor(); + } + + if (options.focusOnSuccess !== false) { + focusNewTodoField(); + } + + return true; + } catch { + showError(ERROR_MESSAGES.delete); + + if (!options.preserveEditingOnFail && editingTodoId === todoId) { + closeTodoEditor(); + } + + return false; + } finally { + removeLoadingTodo(todoId); + } + }; + + const handleUpdateTodo = async (todoId: number, changes: TodoUpdate) => { + clearError(); + addLoadingTodo(todoId); + + try { + const updatedTodo = await updateTodoRequest(todoId, changes); + + replaceTodo(updatedTodo); + + return updatedTodo; + } catch { + showError(ERROR_MESSAGES.update); + + return null; + } finally { + removeLoadingTodo(todoId); + } + }; + + const handleToggleTodo = async (todo: Todo) => { + await handleUpdateTodo(todo.id, { completed: !todo.completed }); + }; + + const handleClearCompleted = async () => { + clearError(); + + const completedTodoIds = completedTodos.map(todo => todo.id); + + if (!completedTodoIds.length) { + return; + } + + setLoadingTodoIds(currentIds => [ + ...currentIds, + ...completedTodoIds.filter(id => !currentIds.includes(id)), + ]); + + const results = await Promise.allSettled( + completedTodos.map(async todo => { + await deleteTodoRequest(todo.id); + + return todo.id; + }), + ); + + const deletedTodoIds = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map(result => result.value); + + const hasErrors = results.some(result => result.status === 'rejected'); + + if (deletedTodoIds.length) { + setTodos(currentTodos => + currentTodos.filter(todo => !deletedTodoIds.includes(todo.id)), + ); + + if (editingTodoId !== null && deletedTodoIds.includes(editingTodoId)) { + closeTodoEditor(); + } + } + + completedTodoIds.forEach(removeLoadingTodo); + + if (hasErrors) { + showError(ERROR_MESSAGES.delete); + } + + focusNewTodoField(); + }; + + const handleToggleAll = async () => { + clearError(); + + const nextCompletedState = !areAllTodosCompleted; + const todosToToggle = todos.filter( + todo => todo.completed !== nextCompletedState, + ); + + if (!todosToToggle.length) { + return; + } + + const todoIdsToToggle = todosToToggle.map(todo => todo.id); + + setLoadingTodoIds(currentIds => [ + ...currentIds, + ...todoIdsToToggle.filter(id => !currentIds.includes(id)), + ]); + + const results = await Promise.allSettled( + todosToToggle.map(async todo => + updateTodoRequest(todo.id, { + completed: nextCompletedState, + }), + ), + ); + + const updatedTodos = results.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ); + const hasErrors = results.some(result => result.status === 'rejected'); + + if (updatedTodos.length) { + setTodos(currentTodos => + currentTodos.map(todo => { + const updatedTodo = updatedTodos.find( + result => result.value.id === todo.id, + )?.value; + + return updatedTodo || todo; + }), + ); + } + + todoIdsToToggle.forEach(removeLoadingTodo); + + if (hasErrors) { + showError(ERROR_MESSAGES.update); + } + }; + + const handleStartEditing = (todo: Todo) => { + setEditingTodoId(todo.id); + setEditingTitle(todo.title); + }; + + const handleSubmitEditing = async (todo: Todo) => { + const trimmedTitle = editingTitle.trim(); + + if (trimmedTitle === todo.title) { + closeTodoEditor(); + + return true; + } + + if (!trimmedTitle) { + const isDeleted = await handleDeleteTodo(todo.id, { + preserveEditingOnFail: true, + }); + + if (isDeleted) { + closeTodoEditor(); + } + + return isDeleted; + } + + const updatedTodo = await handleUpdateTodo(todo.id, { + title: trimmedTitle, + }); + + if (!updatedTodo) { + return false; + } + + closeTodoEditor(); + + return true; + }; + + if (!userId) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(hasTodos || tempTodo) && ( +
+ +
+ )} + + {hasTodos && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..fecbfd6b97 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,42 @@ +import type { Todo, TodoUpdate } from '../types/Todo'; + +const BASE_URL = 'https://mate.academy/students-api'; + +async function request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${BASE_URL}${path}`, { + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + ...options, + }); + + if (!response.ok) { + throw new Error(); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +export const getTodos = (userId: number) => + request(`/todos?userId=${userId}`); + +export const createTodo = (todo: Omit) => + request('/todos', { + method: 'POST', + body: JSON.stringify(todo), + }); + +export const updateTodo = (todoId: number, changes: TodoUpdate) => + request(`/todos/${todoId}`, { + method: 'PATCH', + body: JSON.stringify(changes), + }); + +export const deleteTodo = (todoId: number) => + request(`/todos/${todoId}`, { + method: 'DELETE', + }); diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..3144dff61e --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface Props { + errorMessage: string; + onClose: () => void; +} + +export const ErrorNotification: React.FC = ({ + errorMessage, + onClose, +}) => ( +
+
+); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..968716ec88 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FILTERS } from '../constants'; +import type { FilterStatus } from '../types/Todo'; + +interface Props { + activeTodosCount: number; + completedTodosCount: number; + currentFilter: FilterStatus; + onFilterChange: (filter: FilterStatus) => void; + onClearCompleted: () => void; +} + +const FILTER_LINKS: FilterStatus[] = [ + FILTERS.ALL, + FILTERS.ACTIVE, + FILTERS.COMPLETED, +]; + +const FILTER_LABELS: Record = { + [FILTERS.ALL]: 'All', + [FILTERS.ACTIVE]: 'Active', + [FILTERS.COMPLETED]: 'Completed', +}; + +const FILTER_DATA_CY: Record = { + [FILTERS.ALL]: 'FilterLinkAll', + [FILTERS.ACTIVE]: 'FilterLinkActive', + [FILTERS.COMPLETED]: 'FilterLinkCompleted', +}; + +export const Footer: React.FC = ({ + activeTodosCount, + completedTodosCount, + currentFilter, + onFilterChange, + onClearCompleted, +}) => { + const itemsLeftLabel = `${activeTodosCount} item${activeTodosCount === 1 ? '' : 's'} left`; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..d68558335b --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface Props { + hasTodos: boolean; + isCreating: boolean; + newTitle: string; + areAllTodosCompleted: boolean; + newTodoFieldRef: React.RefObject; + onCreateTodo: (event: React.FormEvent) => void; + onToggleAll: () => void; + onNewTitleChange: (title: string) => void; +} + +export const Header: React.FC = ({ + hasTodos, + isCreating, + newTitle, + areAllTodosCompleted, + newTodoFieldRef, + onCreateTodo, + onToggleAll, + onNewTitleChange, +}) => ( +
+ {hasTodos && ( +
+); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..c865cc0955 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,155 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import type { Todo } from '../types/Todo'; + +interface Props { + todo: Todo; + isLoading: boolean; + isEditing: boolean; + editingTitle: string; + isTemp?: boolean; + onDeleteTodo: (todoId: number) => Promise; + onToggleTodo: (todo: Todo) => Promise; + onStartEditing: (todo: Todo) => void; + onCancelEditing: () => void; + onEditingTitleChange: (title: string) => void; + onSubmitEditing: (todo: Todo) => Promise; +} + +export const TodoItem: React.FC = ({ + todo, + isLoading, + isEditing, + editingTitle, + isTemp = false, + onDeleteTodo, + onToggleTodo, + onStartEditing, + onCancelEditing, + onEditingTitleChange, + onSubmitEditing, +}) => { + const titleFieldRef = useRef(null); + const isCommitting = useRef(false); + const shouldIgnoreBlur = useRef(false); + const statusFieldId = `todo-status-${todo.id}`; + + useEffect(() => { + if (!isEditing) { + isCommitting.current = false; + shouldIgnoreBlur.current = false; + + return; + } + + titleFieldRef.current?.focus(); + }, [isEditing]); + + const handleSubmit = async () => { + if (isCommitting.current || isTemp) { + return; + } + + isCommitting.current = true; + + const isCompleted = await onSubmitEditing(todo); + + isCommitting.current = false; + + if (!isCompleted) { + titleFieldRef.current?.focus(); + } + }; + + return ( +
+ + + {isEditing ? ( +
{ + event.preventDefault(); + void handleSubmit(); + }} + > + onEditingTitleChange(event.target.value)} + onBlur={() => { + if (shouldIgnoreBlur.current) { + shouldIgnoreBlur.current = false; + + return; + } + + void handleSubmit(); + }} + onKeyUp={event => { + if (event.key === 'Escape') { + shouldIgnoreBlur.current = true; + onCancelEditing(); + } + }} + /> +
+ ) : ( + <> + { + if (!isLoading && !isTemp) { + onStartEditing(todo); + } + }} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..d3e66e264d --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FILTERS } from '../constants'; +import type { FilterStatus, Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface Props { + todos: Todo[]; + tempTodo: Todo | null; + filter: FilterStatus; + loadingTodoIds: number[]; + editingTodoId: number | null; + editingTitle: string; + onDeleteTodo: (todoId: number) => Promise; + onToggleTodo: (todo: Todo) => Promise; + onStartEditing: (todo: Todo) => void; + onCancelEditing: () => void; + onEditingTitleChange: (title: string) => void; + onSubmitEditing: (todo: Todo) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + filter, + loadingTodoIds, + editingTodoId, + editingTitle, + onDeleteTodo, + onToggleTodo, + onStartEditing, + onCancelEditing, + onEditingTitleChange, + onSubmitEditing, +}) => { + const shouldShowTempTodo = tempTodo !== null && filter !== FILTERS.COMPLETED; + + return ( + <> + {todos.map(todo => ( + + ))} + + {shouldShowTempTodo && ( + + )} + + ); +}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000000..89fce60812 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,15 @@ +export const FILTERS = { + ALL: 'all', + ACTIVE: 'active', + COMPLETED: 'completed', +} as const; + +export const ERROR_MESSAGES = { + load: 'Unable to load todos', + add: 'Unable to add a todo', + delete: 'Unable to delete a todo', + update: 'Unable to update a todo', + emptyTitle: 'Title should not be empty', +} as const; + +export const ERROR_HIDE_DELAY = 3000; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..6a8b74a6da --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,14 @@ +import { FILTERS } from '../constants'; + +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; + createdAt?: string; + updatedAt?: string; +} + +export type TodoUpdate = Partial>; + +export type FilterStatus = (typeof FILTERS)[keyof typeof FILTERS];