diff --git a/README.md b/README.md index 47a1add059..c4c38b19a1 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 `` with your Github username in the [DEMO LINK](https://wiolip.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 19701e8788..0dbc74a8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index b6062525ab..6d0f20adcc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..80deef56bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,282 @@ -/* 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, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + getTodos, + createTodo, + deleteTodo, + USER_ID, + updateTodo, +} from './api/todos'; +import { NewTodoForm } from './components/NewTodoForm'; +import { Todo } from './types/Todo'; +import { TodoList } from './components/TodoList'; +import { Filter, FILTERS } from './types/Filters'; +import { ErrorNotification } from './components/ErrorNotification'; +import { FilterComponent } from './components/FilterComponent'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [loadingIds, setLoadingIds] = useState([]); + const [filter, setFilter] = useState('all'); + const [error, setError] = useState(null); + + const todoFieldRef = useRef(null); + const errorTimeoutRef = useRef(null); + + // Focus the input field + const focusField = useCallback(() => { + todoFieldRef.current?.focus(); + }, []); + + // Refocus input whenever temporary todo changes + useEffect(() => { + focusField(); + }, [tempTodo, focusField]); + + // Display error message and hide it automatically after 3 seconds + const showError = (message: string) => { + setError(message); + + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + + errorTimeoutRef.current = window.setTimeout(() => { + setError(null); + }, 3000); + }; + + // Load todos from API when the component mounts + useEffect(() => { + if (!USER_ID) { + return; + } + + getTodos() + .then(setTodos) + .catch(() => showError('Unable to load todos')); + + // Cleanup timeout on unmount + return () => { + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + }; + }, []); + + // Memoized calculations for derived todo lists + const { activeTodos, completedTodos, filteredTodos } = useMemo(() => { + const active = todos.filter(t => !t.completed); + const completed = todos.filter(t => t.completed); + let filtered = todos; + + if (filter === FILTERS.active) { + filtered = active; + } + + if (filter === FILTERS.completed) { + filtered = completed; + } + + return { + activeTodos: active, + completedTodos: completed, + filteredTodos: filtered, + }; + }, [todos, filter]); + + // Derived UI states + const isAllCompleted = todos.length > 0 && todos.every(t => t.completed); + const hasTodos = todos.length > 0 || tempTodo; + + // Add new todo + const handleAdd = async (title: string): Promise => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + showError('Title should not be empty'); + + return false; + } + + // Optimistic UI: show temporary todo while request is in progress + setTempTodo({ + id: 0, + userId: USER_ID, + title: trimmedTitle, + completed: false, + }); + + try { + const newTodo = await createTodo({ title: trimmedTitle }); + + setTodos(current => [...current, newTodo]); + + return true; + } catch { + showError('Unable to add a todo'); + + return false; + } finally { + setTempTodo(null); + } + }; + + // Delete a todo + const handleDelete = async (id: number) => { + setLoadingIds(prev => [...prev, id]); + + try { + await deleteTodo(id); + setTodos(prev => prev.filter(t => t.id !== id)); + focusField(); + } catch { + showError('Unable to delete a todo'); + } finally { + setLoadingIds(prev => prev.filter(lid => lid !== id)); + } + }; + + // toggle todo + const handleToggle = async (todo: Todo) => { + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updated = await updateTodo(todo.id, { + completed: !todo.completed, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + } catch { + showError('Unable to update a todo'); + throw new Error('Update failed'); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const handleToggleAll = async () => { + const newStatus = !isAllCompleted; + const todosToUpdate = todos.filter(todo => todo.completed !== newStatus); + + try { + await Promise.all(todosToUpdate.map(todo => handleToggle(todo))); + } catch {} + }; + + const handleRename = async ( + todo: Todo, + newTitle: string, + ): Promise => { + const trimmed = newTitle.trim(); + + if (!trimmed) { + await handleDelete(todo.id); + + return true; + } + + if (trimmed === todo.title) { + return true; + } + + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updated = await updateTodo(todo.id, { + title: trimmed, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + + return true; + } catch { + showError('Unable to update a todo'); + + return false; + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + // Remove all completed todos + const handleClearCompleted = async () => { + await Promise.allSettled(completedTodos.map(todo => handleDelete(todo.id))); + focusField(); + }; + + // If USER_ID is missing, show warning instead of the app if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {hasTodos && ( +
+ + {hasTodos && ( + + )} + + {/* Footer */} + {hasTodos && ( +
+ + {/*eslint-disable-next-line max-len*/} + {activeTodos.length} {activeTodos.length === 1 ? 'item' : 'items'}{' '} + left + + + +
+ )} +
+ setError(null)} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..8a20139297 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,27 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 3981; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +// Add new todo +export const createTodo = ({ title }: { title: string }) => { + return client.post('/todos', { + title, + userId: USER_ID, + completed: false, + }); +}; + +// Delete todo by id +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (id: number, data: Partial) => { + return client.patch(`/todos/${id}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..ad9c5c8585 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,30 @@ +import classNames from 'classnames'; +import React from 'react'; + +interface ErrorNotificationProps { + message: string | null; + onClose?: () => void; +} + +export const ErrorNotification: React.FC = ({ + message, + onClose, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/FilterComponent.tsx b/src/components/FilterComponent.tsx new file mode 100644 index 0000000000..71dd899a04 --- /dev/null +++ b/src/components/FilterComponent.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Filter as FilterType, FILTERS } from '../types/Filters'; +import classNames from 'classnames'; + +interface FilterProps { + current: FilterType; + onChange: (filter: FilterType) => void; +} + +const capitalize = (value: string) => value[0].toUpperCase() + value.slice(1); + +export const FilterComponent: React.FC = ({ + current, + onChange, +}) => { + return ( + + ); +}; diff --git a/src/components/NewTodoForm.tsx b/src/components/NewTodoForm.tsx new file mode 100644 index 0000000000..8469caef0b --- /dev/null +++ b/src/components/NewTodoForm.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; + +type Props = { + onAdd: (title: string) => Promise; + loading?: boolean; + todoFieldRef: React.RefObject; +}; + +export const NewTodoForm: React.FC = ({ + onAdd, + loading, + todoFieldRef, +}) => { + const [title, setTitle] = useState(''); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (loading) { + return; + } + + const isSuccess = await onAdd(title); + + if (isSuccess) { + setTitle(''); // Czyścimy tylko przy sukcesie + } + }; + + return ( +
+ setTitle(event.target.value)} + placeholder="What needs to be done?" + disabled={loading} + /> +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..9e08e50ea0 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; + +interface Props { + todo: Todo; + isLoading?: boolean; + onDelete: (id: number) => void; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, title: string) => Promise; +} + +export const TodoItem: React.FC = ({ + todo, + isLoading = false, + onDelete, + onToggle, + onRename, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const todoClass = classNames('todo', { + completed: todo.completed, + editing: isEditing, + 'is-loading': isLoading, + }); + + const handleFocus = (node: HTMLInputElement | null) => { + if (node) { + node.focus(); + } + }; + + const handleSubmit = async (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + onDelete(todo.id); + + return; + } + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + const success = await onRename(todo, trimmedTitle); + + if (success) { + setIsEditing(false); + } + }; + + const handleCancel = () => { + setTitle(todo.title); + setIsEditing(false); + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {/* Title or editing input */} + {isEditing ? ( +
{ + e.preventDefault(); + handleSubmit(); + }} + > + setTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={e => { + if (e.key === 'Escape') { + handleCancel(); + } + }} + /> +
+ ) : ( + setIsEditing(true)} + > + {todo.title} + + )} + + {/* Remove button (pokazuje się tylko jeśli nie w edycji/loading) */} + {!isEditing && !isLoading && ( + + )} + + {/* overlay will cover the todo while it is being deleted or updated */} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..acdcc66a21 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface TodoListProps { + todos: Todo[]; + tempTodo: Todo | null; + loadingIds: number[]; + onDelete: (id: number) => void; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, title: string) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingIds, + onDelete, + onToggle, + onRename, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && ( + {}} + onToggle={() => {}} + onRename={async () => false} + /> + )} +
+ ); +}; diff --git a/src/types/Filters.ts b/src/types/Filters.ts new file mode 100644 index 0000000000..bfbedd26c0 --- /dev/null +++ b/src/types/Filters.ts @@ -0,0 +1,7 @@ +export const FILTERS = { + all: 'all', + active: 'active', + completed: 'completed', +} as const; + +export type Filter = (typeof FILTERS)[keyof typeof FILTERS]; 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..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'), +};