diff --git a/src/App.tsx b/src/App.tsx index a399287bd..c43e347f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,85 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect } from 'react'; +import { UserWarning } from './UserWarning'; +import { getTodos } from './api/todos'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { Filter } from './types/Filter'; +import { TodoItem } from './components/TodoItem'; +import { focusInput } from './utils/services'; +import { ErrNotification } from './components/ErrNotification'; +import { useTodoContext } from './components/TodoContext'; +import { USER_ID } from './utils/constants'; export const App: React.FC = () => { + const { + todos, + setTodos, + isLoading, + setIsLoading, + activeTodoId, + isSubmitting, + tempTodo, + inputRef, + filter, + showError, + } = useTodoContext(); + + useEffect(() => { + const fetchTodos = async () => { + setIsLoading(true); + try { + const fetchedTodos = await getTodos(USER_ID); + + setTodos(fetchedTodos); + } catch { + showError('Unable to load todos'); + } finally { + setIsLoading(false); + } + }; + + fetchTodos(); + }, []); + + useEffect(() => { + focusInput(inputRef); + }, [isSubmitting, activeTodoId, inputRef]); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case Filter.Active: + return !todo.completed; + case Filter.Completed: + return todo.completed; + default: + return true; + } + }); + + if (!USER_ID) { + return ; + } + return (

todos

-
- {/* this button should have `active` class only if all todos are completed */} -
- -
- {/* This is a completed todo */} -
- - - - Completed Todo - - - {/* Remove button appears only on hover */} - -
- - {/* This todo is an active todo */} -
- - - - Not Completed Todo - - - -
- - {/* This todo is being edited */} -
- - - {/* This form is shown instead of the title and remove button */} -
- -
-
- - {/* This todo is in loadind state */} -
- - - - Todo is being saved now - - - -
-
- - {/* Hide the footer if there are no todos */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - - - {/* this button should be disabled if there are no completed todos */} - -
+
+ {!isLoading && ( + <> +
+ {filteredTodos.map(todo => ( + + ))} + {tempTodo && } +
+ {todos.length > 0 &&
} + + )}
+
); }; diff --git a/src/UserWarning.tsx b/src/UserWarning.tsx new file mode 100644 index 000000000..fa25838e6 --- /dev/null +++ b/src/UserWarning.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export const UserWarning: React.FC = () => ( +
+

+ Please get your userId {' '} + + here + {' '} + and save it in the app

const USER_ID = ...
+ All requests to the API must be sent with this + userId. +

+
+); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..2cb0bf6fd --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,22 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +// Your userId is 1414 +// Please use it for all your requests to the Students API. For example: +// https://mate.academy/students-api/todos?userId=1414 + +export const getTodos = (userid: number) => { + return client.get(`/todos?userId=${userid}`); +}; + +export function deleteTodo(todoId: number) { + return client.delete(`/todos/${todoId}`); +} + +export function createTodo({ title, userId, completed }: Omit) { + return client.post('/todos', { title, userId, completed }); +} + +export function updateTodo({ id, title, completed, userId }: Todo) { + return client.patch(`/todos/${id}`, { title, completed, userId }); +} diff --git a/src/components/ErrNotification.tsx b/src/components/ErrNotification.tsx new file mode 100644 index 000000000..afa9402a4 --- /dev/null +++ b/src/components/ErrNotification.tsx @@ -0,0 +1,26 @@ +import classNames from 'classnames'; +import { useTodoContext } from './TodoContext'; + +type Props = {}; + +export const ErrNotification: React.FC = () => { + const { error, setError } = useTodoContext(); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..a133923e1 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,83 @@ +import classNames from 'classnames'; +import { Filter } from '../types/Filter'; +import { useTodoContext } from './TodoContext'; +import { deleteTodo } from '../api/todos'; +import { focusInput } from '../utils/services'; +import { Todo } from '../types/Todo'; + +type Props = {}; + +export const Footer: React.FC = () => { + const { + todos, + setTodos, + showError, + setActiveTodoId, + inputRef, + filter, + setFilter, + } = useTodoContext(); + + const hasCompleted = todos.some(todo => todo.completed); + + const handleFilterChange = (newFilter: Filter) => { + setFilter(newFilter); + }; + + const handleClearCompleted = async () => { + const completedTodoIds = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + Promise.allSettled( + completedTodoIds.map(async todoId => { + try { + await deleteTodo(todoId); + + setTodos((currentTodos: Todo[]) => + currentTodos.filter(todo => todo.id !== todoId), + ); + } catch { + showError('Unable to delete a todo'); + } finally { + setActiveTodoId(null); + focusInput(inputRef); + } + }), + ); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..cf9dcfb1d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,152 @@ +import classNames from 'classnames'; +import { useTodoContext } from './TodoContext'; +import { createTodo, updateTodo } from '../api/todos'; +import { Todo } from '../types/Todo'; +import { focusInput } from '../utils/services'; +import { USER_ID } from '../utils/constants'; + +type Props = {}; + +export const Header: React.FC = () => { + const { + todoTitle, + setTodoTitle, + todos, + setTodos, + showError, + setActiveTodoList, + setError, + inputRef, + isSubmitting, + setIsSubmitting, + setActiveTodoId, + setTempTodo, + } = useTodoContext(); + + const isAllCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + + const handleToggleAll = async () => { + let toggledList = todos.filter(todo => !todo.completed); + + if (isAllCompleted) { + toggledList = [...todos]; + } + + const shouldCompleteAll = !isAllCompleted; + const activeTodoIds = toggledList.map(todo => todo.id); + + setActiveTodoList(activeTodoIds); + + await Promise.allSettled( + toggledList.map(async todo => { + const updatedTodo = { + ...todo, + completed: shouldCompleteAll, + }; + const { id, title, completed, userId } = updatedTodo; + + try { + const updated = await updateTodo({ id, title, completed, userId }); + + setTodos(currentTodos => + currentTodos.map(t => (t.id === updated.id ? updated : t)), + ); + } catch { + showError(`Unable to update todo with ID ${todo.id}`); + } + }), + ); + + setActiveTodoList([]); + }; + + const resetForm = () => { + setTodoTitle(''); + setTempTodo(null); + setError(''); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = todoTitle.trim(); + + setError(''); + setTodoTitle(trimmedTitle); + + if (!trimmedTitle) { + showError('Title should not be empty'); + focusInput(inputRef); + + return; + } + + const newTempTodo: Todo = { + id: 0, + title: trimmedTitle, + userId: USER_ID, + completed: false, + }; + + setTempTodo(newTempTodo); + setIsSubmitting(true); + setActiveTodoId(newTempTodo.id); + + try { + const newTodo = await createTodo({ + title: trimmedTitle, + userId: USER_ID, + completed: false, + }); + + setTodos(prevTodos => [...prevTodos, newTodo]); + resetForm(); + } catch (err) { + showError('Unable to add a todo'); + throw err; + } finally { + setIsSubmitting(false); + setTempTodo(null); + setActiveTodoId(null); + focusInput(inputRef); + } + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + setTodoTitle(event.target.value); + setError(''); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx new file mode 100644 index 000000000..5c06946e3 --- /dev/null +++ b/src/components/TodoContext.tsx @@ -0,0 +1,90 @@ +import React, { useContext, useMemo, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { Filter } from '../types/Filter'; + +type TodoContextType = { + todos: Todo[]; + setTodos: React.Dispatch>; + isLoading: boolean; + setIsLoading: (state: boolean) => void; + error: string; + setError: (err: string) => void; + showError: (message: string) => void; + activeTodoId: number | null; + setActiveTodoId: (todoId: number | null) => void; + inputRef: React.RefObject; + activeTodoList: number[]; + setActiveTodoList: (activeTodoIds: number[]) => void; + tempTodo: Todo | null; + setTempTodo: (todo: Todo | null) => void; + isSubmitting: boolean; + setIsSubmitting: (issubmiting: boolean) => void; + todoTitle: string; + setTodoTitle: (newtitle: string) => void; + filter: Filter; + setFilter: (filter: Filter) => void; +}; + +export const TodoContext = React.createContext( + undefined, +); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [todoTitle, setTodoTitle] = useState(''); + const [filter, setFilter] = useState(Filter.All); + const [activeTodoId, setActiveTodoId] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const inputRef = useRef(null); + const [activeTodoList, setActiveTodoList] = useState([]); + + const showError = (message: string) => { + setError(message); + setTimeout(() => setError(''), 3000); + }; + + const value = useMemo( + () => ({ + todos, + setTodos, + isLoading, + setIsLoading, + error, + setError, + todoTitle, + setTodoTitle, + activeTodoId, + setActiveTodoId, + isSubmitting, + setIsSubmitting, + tempTodo, + setTempTodo, + inputRef, + activeTodoList, + setActiveTodoList, + showError, + filter, + setFilter, + }), + [todos, todoTitle, filter, activeTodoId, activeTodoList, error], + ); + + return {children}; +}; + +export const useTodoContext = () => { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('useTodoContext must be used within a TodoProvider'); + } + + return context; +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..dd22a7a99 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,207 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useEffect, useState } from 'react'; +import { focusInput } from '../utils/services'; +import { deleteTodo, updateTodo } from '../api/todos'; +import { USER_ID } from '../utils/constants'; +import { useTodoContext } from './TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { + inputRef, + setActiveTodoId, + setTodos, + showError, + activeTodoId, + activeTodoList, + setError, + } = useTodoContext(); + + const { id, completed, title } = todo; + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + useEffect(() => { + focusInput(inputRef); + }, [isEditing, inputRef]); + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleChange = (event: React.ChangeEvent) => { + setNewTitle(event.target.value); + }; + + const handleUpdateTodo = async (todoItem: Todo, newTodoTitle: string) => { + setActiveTodoId(todoItem.id); + + try { + const updatedTodo = await updateTodo({ + id: todoItem.id, + title: newTodoTitle, + completed: todoItem.completed, + userId: USER_ID, + }); + + setTodos(prevTodos => + prevTodos.map(tItem => + tItem.id === updatedTodo.id ? updatedTodo : tItem, + ), + ); + } catch (err) { + showError('Unable to update a todo'); + throw err; + } finally { + setActiveTodoId(null); + } + }; + + const handleDeleteTodo = async (todoId: number) => { + setActiveTodoId(todoId); + try { + await deleteTodo(todoId); + setTodos(currentTodos => currentTodos.filter(t => t.id !== todoId)); + } catch (err) { + showError('Unable to delete a todo'); + throw err; + } finally { + setActiveTodoId(null); + focusInput(inputRef); + } + }; + + const handleRenameSubmit = async () => { + let errorMsg = false; + + if (newTitle.trim() === '') { + try { + await handleDeleteTodo(todo.id); + } catch (err) { + if (err) { + errorMsg = true; + } + + focusInput(inputRef); + } + } else if (newTitle.trim() !== todo.title) { + try { + await handleUpdateTodo(todo, newTitle.trim()); + } catch (err) { + if (err) { + errorMsg = true; + } + + focusInput(inputRef); + } + } + + if (errorMsg) { + focusInput(inputRef); + + return; + } + + setIsEditing(false); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleRenameSubmit(); + } else if (e.key === 'Escape') { + setIsEditing(false); + setNewTitle(todo.title); + } + }; + + const handleBlur = () => { + handleRenameSubmit(); + }; + + const handleToggle = async (todoItem: Todo) => { + setError(''); + setActiveTodoId(todoItem.id); + const updatedtodo = { ...todoItem, completed: !todoItem.completed }; + + try { + const updated = await updateTodo(updatedtodo); + + setTodos(currentTodos => + currentTodos.map(t => (t.id === updated.id ? updated : t)), + ); + } catch { + showError('Unable to update a todo'); + } finally { + setActiveTodoId(null); + } + }; + + return ( +
+ + {isEditing ? ( +
e.preventDefault()}> + +
+ ) : ( + <> + + {title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..8caeb506f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,16 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import 'bulma/css/bulma.css'; +import '@fortawesome/fontawesome-free/css/all.css'; +import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './components/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /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/constants.ts b/src/utils/constants.ts new file mode 100644 index 000000000..f9fb74ede --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const USER_ID = 1414; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..708ac4c17 --- /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'), +}; diff --git a/src/utils/services.ts b/src/utils/services.ts new file mode 100644 index 000000000..3d3b84909 --- /dev/null +++ b/src/utils/services.ts @@ -0,0 +1,7 @@ +import { RefObject } from 'react'; + +export const focusInput = (inputRef: RefObject) => { + if (inputRef.current) { + inputRef.current.focus(); + } +};