diff --git a/README.md b/README.md index 47a1add059..ad2768008f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ and implement the ability to toggle and rename todos. ## Toggling a todo status Toggle the `completed` status on `TodoStatus` change: + - Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - covered the todo with a loader overlay while waiting for API response; - the status should be changed on success; @@ -38,6 +39,7 @@ Implement the ability to edit a todo title on double click: - or the deletion error message if we tried to delete the todo. ## If you want to enable tests + - open `cypress/integration/page.spec.js` - replace `describe.skip` with `describe` for the root `describe` @@ -47,4 +49,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 `` with your Github username in the [DEMO LINK](https://freelinex.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..e5990e9c40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,375 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + createTodo, + deleteTodo, + makeCompleted, + updateTodo, + getTodos, + USER_ID, +} from './api/todos'; -const USER_ID = 0; +import { ErrorMessages, FilterStatus, Todo } from './types/Types'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { ErrorNotifications } from './components/ErrorNotifications'; +import { + getCompletedTodos, + getFilteredTodos, + getHasCompletedTodos, + getIsAllTodosCompleted, + getTodosToToggle, +} from './utils/functions'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState('all'); + const [errorMessage, setErrorMessage] = useState( + ErrorMessages.empty, + ); + const [title, setTitle] = useState(''); + const [isSubmiting, setIsSubmiting] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [deletingTodoIds, setDeletingTodoIds] = useState([]); + const [checkedTodoId, setCheckedTodoId] = useState([]); + const [editingTodoId, setEditingTodoId] = useState(null); + const [editedTitle, setEditedTitle] = useState(''); + + const showError = (message: ErrorMessages) => { + setErrorMessage(message); + + setTimeout(() => { + setErrorMessage(ErrorMessages.empty); + }, 3000); + }; + + const inputRef = useRef(null); + + const checkCompleteAll = getIsAllTodosCompleted(todos); + + const checkComplete = getHasCompletedTodos(todos); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setErrorMessage(ErrorMessages.empty); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + showError(ErrorMessages.notBeEmpty); + inputRef.current?.focus(); + + return; + } + + setIsSubmiting(true); + setTempTodo({ + id: 0, + title: trimmedTitle, + userId: USER_ID, + completed: false, + }); + + createTodo({ + title: trimmedTitle, + userId: USER_ID, + completed: false, + }) + .then(newTodo => { + setTodos(current => [...current, newTodo]); + setTitle(''); + }) + .catch(() => { + showError(ErrorMessages.addError); + }) + .finally(() => { + setIsSubmiting(false); + setTempTodo(null); + }); + }; + + const handleDelete = (todoId: number) => { + setDeletingTodoIds(current => [...current, todoId]); + setErrorMessage(ErrorMessages.empty); + + deleteTodo(todoId) + .then(() => { + setTodos(current => current.filter(todo => todo.id !== todoId)); + }) + .catch(() => { + showError(ErrorMessages.deleteError); + }) + .finally(() => { + setDeletingTodoIds(current => current.filter(id => id !== todoId)); + inputRef.current?.focus(); + }); + }; + + const handleClearCompleted = () => { + const completedTodos = getCompletedTodos(todos); + + if (completedTodos.length === 0) { + return; + } + + const completedIds = completedTodos.map(todo => todo.id); + + setErrorMessage(ErrorMessages.empty); + setDeletingTodoIds(current => [...current, ...completedIds]); + + Promise.allSettled(completedTodos.map(todo => deleteTodo(todo.id))) + .then(results => { + const successfulIds = completedTodos + .filter((_, index) => results[index].status === 'fulfilled') + .map(todo => todo.id); + + const hasError = results.some(result => result.status === 'rejected'); + + if (successfulIds.length > 0) { + setTodos(current => + current.filter(todo => !successfulIds.includes(todo.id)), + ); + } + + if (hasError) { + showError(ErrorMessages.deleteError); + } + }) + .finally(() => { + setDeletingTodoIds(current => + current.filter(id => !completedIds.includes(id)), + ); + inputRef.current?.focus(); + }); + }; + + const handleMakeChecked = (updatedTodo: Todo) => { + setCheckedTodoId(current => [...current, updatedTodo.id]); + + const toggledTodo = { + ...updatedTodo, + completed: !updatedTodo.completed, + }; + + makeCompleted(toggledTodo) + .then(() => { + setTodos(currentTodo => { + return currentTodo.map(todo => + todo.id === toggledTodo.id ? toggledTodo : todo, + ); + }); + }) + .catch(() => { + showError(ErrorMessages.updateError); + }) + .finally(() => { + setCheckedTodoId(current => + current.filter(id => id !== updatedTodo.id), + ); + }); + }; + + const handleStartEditing = (todo: Todo) => { + setEditingTodoId(todo.id); + setEditedTitle(todo.title); + setErrorMessage(ErrorMessages.empty); + }; + + const handleCancelEditing = () => { + setEditingTodoId(null); + setEditedTitle(''); + }; + + const handleSubmitEditing = (todo: Todo) => { + if (editingTodoId !== todo.id) { + return; + } + + if (checkedTodoId.includes(todo.id) || deletingTodoIds.includes(todo.id)) { + return; + } + + const trimmedTitle = editedTitle.trim(); + + if (trimmedTitle === todo.title) { + handleCancelEditing(); + + return; + } + + if (!trimmedTitle) { + setDeletingTodoIds(current => [...current, todo.id]); + setErrorMessage(ErrorMessages.empty); + + deleteTodo(todo.id) + .then(() => { + setTodos(current => + current.filter(currentTodo => currentTodo.id !== todo.id), + ); + handleCancelEditing(); + }) + .catch(() => { + showError(ErrorMessages.deleteError); + }) + .finally(() => { + setDeletingTodoIds(current => current.filter(id => id !== todo.id)); + }); + + return; + } + + setCheckedTodoId(current => [...current, todo.id]); + setErrorMessage(ErrorMessages.empty); + + updateTodo({ ...todo, title: trimmedTitle }) + .then(updatedTodo => { + setTodos(current => + current.map(currentTodo => + currentTodo.id === todo.id ? updatedTodo : currentTodo, + ), + ); + handleCancelEditing(); + }) + .catch(() => { + showError(ErrorMessages.updateError); + }) + .finally(() => { + setCheckedTodoId(current => current.filter(id => id !== todo.id)); + }); + }; + + const switchCompleteAll = () => { + const todosToToggle = getTodosToToggle(todos, checkCompleteAll); + + const toggledTodoIds = todosToToggle.map(todo => todo.id); + + setCheckedTodoId(current => [...current, ...toggledTodoIds]); + + Promise.allSettled( + todosToToggle.map(todo => + makeCompleted({ + ...todo, + completed: !checkCompleteAll, + }), + ), + ) + .then(results => { + const successfulIds = todosToToggle + .filter((_, index) => results[index].status === 'fulfilled') + .map(todo => todo.id); + + const hasError = results.some(result => result.status === 'rejected'); + + if (successfulIds.length > 0) { + setTodos(current => + current.map(todo => + successfulIds.includes(todo.id) + ? { ...todo, completed: !checkCompleteAll } + : todo, + ), + ); + } + + if (hasError) { + showError(ErrorMessages.updateError); + } + }) + .finally(() => { + setCheckedTodoId(current => + current.filter(id => !toggledTodoIds.includes(id)), + ); + }); + }; + + const filteredTodos = getFilteredTodos(todos, filter); + + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + setErrorMessage(ErrorMessages.empty); + getTodos() + .then(data => setTodos(data)) + .catch(() => showError(ErrorMessages.loadError)); + }, []); + + useLayoutEffect(() => { + if (!isSubmiting) { + inputRef.current?.focus(); + } + }, [isSubmiting]); + if (!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 && ( +
+ + + + {todos.length > 0 && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..fdce288884 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,33 @@ +import { Todo } from '../types/Types'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 3207; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = ({ title, userId, completed }: Omit) => { + return client.post('/todos', { + title, + userId, + completed, + }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const makeCompleted = ({ id, completed }: Todo) => { + return client.patch(`/todos/${id}`, { + completed, + }); +}; + +export const updateTodo = ({ id, title }: Todo) => { + return client.patch(`/todos/${id}`, { + title, + }); +}; +// Add more methods here diff --git a/src/components/ErrorNotifications.tsx b/src/components/ErrorNotifications.tsx new file mode 100644 index 0000000000..ba28211b57 --- /dev/null +++ b/src/components/ErrorNotifications.tsx @@ -0,0 +1,23 @@ +import { ErrorMessages, ErrorNotificationsProps } from '../types/Types'; + +export const ErrorNotifications: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..541cbc697c --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,63 @@ +import { getActiveTodosCount } from '../utils/functions'; +import { FilterPatterns, FooterProps } from '../types/Types'; + +export const Footer: React.FC = ({ + todos, + filter, + setFilter, + checkComplete, + handleClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..3f93dcb0cd --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef } from 'react'; +import { Todo, TodoListProps } from '../types/Types'; + +export const TodoList: React.FC = ({ + filteredTodos, + deletingTodoIds, + checkedTodoId, + tempTodo, + editingTodoId, + editedTitle, + handleDelete, + handleMakeChecked, + handleStartEditing, + handleEditedTitleChange, + handleCancelEditing, + handleSubmitEditing, +}) => { + const editInputRef = useRef(null); + + const cancelEdit = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleCancelEditing(); + } + }; + + const submitEdit = (event: React.FormEvent, todo: Todo) => { + event.preventDefault(); + handleSubmitEditing(todo); + }; + + useEffect(() => { + if (editingTodoId !== null) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [editingTodoId]); + + return ( +
+ {filteredTodos.map(todo => ( +
+ + + {editingTodoId === todo.id ? ( +
submitEdit(event, todo)}> + handleEditedTitleChange(event.target.value)} + onBlur={() => handleSubmitEditing(todo)} + onKeyUp={cancelEdit} + /> +
+ ) : ( + <> + handleStartEditing(todo)} + > + {todo.title} + + + + )} + +
+
+
+
+
+ ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/types/Types.ts b/src/types/Types.ts new file mode 100644 index 0000000000..5fc514b09c --- /dev/null +++ b/src/types/Types.ts @@ -0,0 +1,53 @@ +import type { Dispatch, SetStateAction } from 'react'; + +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} + +export type FilterStatus = 'all' | 'active' | 'completed'; + +export enum ErrorMessages { + empty = '', + notBeEmpty = 'Title should not be empty', + addError = 'Unable to add a todo', + deleteError = 'Unable to delete a todo', + loadError = 'Unable to load todos', + updateError = `Unable to update a todo`, +} + +export enum FilterPatterns { + all = 'all', + active = 'active', + completed = 'completed', +} + +export type TodoListProps = { + filteredTodos: Todo[]; + deletingTodoIds: number[]; + checkedTodoId: number[]; + tempTodo: Todo | null; + editingTodoId: number | null; + editedTitle: string; + handleDelete: (TodoId: number) => void; + handleMakeChecked: (updatedTodo: Todo) => void; + handleStartEditing: (todo: Todo) => void; + handleEditedTitleChange: (value: string) => void; + handleCancelEditing: () => void; + handleSubmitEditing: (todo: Todo) => void; +}; + +export type FooterProps = { + todos: Todo[]; + filter: FilterStatus; + setFilter: (filter: FilterStatus) => void; + checkComplete: boolean; + handleClearCompleted: () => void; +}; + +export type ErrorNotificationsProps = { + errorMessage: ErrorMessages; + setErrorMessage: Dispatch>; +}; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..86f3ff6776 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,52 @@ +/* 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(); + } + + if (response.status === 204) { + return null as T; + } + + return response.text().then(text => { + return text ? (JSON.parse(text) as T) : (null as T); + }); + }); +} + +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'), +}; diff --git a/src/utils/functions.ts b/src/utils/functions.ts new file mode 100644 index 0000000000..11b22dbd50 --- /dev/null +++ b/src/utils/functions.ts @@ -0,0 +1,34 @@ +import { FilterStatus, Todo } from '../types/Types'; + +export const getIsAllTodosCompleted = (todos: Todo[]) => { + return todos.length > 0 && todos.every(todo => todo.completed); +}; + +export const getHasCompletedTodos = (todos: Todo[]) => { + return todos.some(todo => todo.completed); +}; + +export const getCompletedTodos = (todos: Todo[]) => { + return todos.filter(todo => todo.completed); +}; + +export const getActiveTodosCount = (todos: Todo[]) => { + return todos.filter(todo => !todo.completed).length; +}; + +export const getFilteredTodos = (todos: Todo[], filter: FilterStatus) => { + switch (filter) { + case 'active': + return todos.filter(todo => !todo.completed); + + case 'completed': + return todos.filter(todo => todo.completed); + + default: + return todos; + } +}; + +export const getTodosToToggle = (todos: Todo[], isAllCompleted: boolean) => { + return isAllCompleted ? todos : todos.filter(todo => !todo.completed); +};