diff --git a/cypress/integration/page.spec.js b/cypress/integration/page.spec.js index fcac2e3d12..1d1c697657 100644 --- a/cypress/integration/page.spec.js +++ b/cypress/integration/page.spec.js @@ -519,7 +519,7 @@ describe('', () => { }); // this test may be flaky - it.skip('should replace loader with a created todo', () => { + it('should replace loader with a created todo', () => { page.flushJSTimers(); todos.assertCount(6); todos.assertNotLoading(5); @@ -1515,7 +1515,7 @@ describe('', () => { }); // It depend on your implementation - it.skip('should stay while waiting', () => { + it('should stay while waiting', () => { page.mockUpdate(257334); todos.title(0).trigger('dblclick'); @@ -1694,7 +1694,7 @@ describe('', () => { }); // this test may be unstable - it.skip('should hide loader on fail', () => { + it('should hide loader on fail', () => { // to prevent Cypress from failing the test on uncaught exception cy.once('uncaught:exception', () => false); diff --git a/package-lock.json b/package-lock.json index 19701e8788..511279ae76 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": "^1.9.12", "@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": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "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..005692edf7 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": "^1.9.12", "@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..80263c2f59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,45 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { getTodos, USER_ID } from './api/todos'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { DispatchContext, StatesContext } from './context/Store'; export const App: React.FC = () => { + const { todos } = useContext(StatesContext); + const dispatch = useContext(DispatchContext); + + useEffect(() => { + dispatch({ type: 'startUpdate' }); + getTodos() + .then(todosFromServer => { + dispatch({ type: 'loadTodos', payload: todosFromServer }); + }) + .catch(() => { + dispatch({ type: 'showError', payload: `Unable to load todos` }); + }) + .finally(() => { + dispatch({ type: 'stopUpdate' }); + }); + }, [dispatch]); + if (!USER_ID) { return ; } return ( -
-

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

+
+

todos

+ +
+
+ + {todos.length !== 0 &&
} +
-

Styles are already copied

-
+ + ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..5c2a8d5219 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,34 @@ +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2165; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const getFilteredTodos = (filter: Filter) => { + switch (filter) { + case 'all': + return client.get(`/todos?userId=${USER_ID}`); + case 'active': + return client.get(`/todos?userId=${USER_ID}&completed=false`); + case 'completed': + return client.get(`/todos?userId=${USER_ID}&completed=true`); + default: + return client.get(`/todos?userId=${USER_ID}`); + } +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, todo: Todo) => { + return client.patch(`/todos/${todoId}`, todo); +}; + +export const addTodo = ({ userId, title, completed }: Omit) => { + return client.post(`/todos`, { userId, title, completed }); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..dc51b188d5 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,28 @@ +import { useContext, useEffect } from 'react'; +import { DispatchContext, StatesContext } from '../context/Store'; +import classNames from 'classnames'; + +export const ErrorNotification: React.FC = () => { + const { errorMessage } = useContext(StatesContext); + const dispatch = useContext(DispatchContext); + + useEffect(() => { + if (errorMessage) { + setTimeout(() => dispatch({ type: 'showError', payload: null }), 3000); + } + }); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..3392a3ca9d --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,87 @@ +import { useContext } from 'react'; +import { DispatchContext, StatesContext } from '../context/Store'; +import { Filter } from '../types/Filter'; +import classNames from 'classnames'; +import { deleteTodo } from '../api/todos'; + +export const Footer: React.FC = () => { + const { todos, filter } = useContext(StatesContext); + const dispatch = useContext(DispatchContext); + const todosLeft = todos.filter(t => !t.completed); + + function handleOnFilterClick(selectedFilter: Filter) { + dispatch({ type: 'setFilter', payload: selectedFilter }); + } + + const handleOnClearClick = () => { + const completedTodos = todos.filter(t => t.completed); + + Promise.allSettled( + completedTodos.map(todo => deleteTodo(todo.id).then(() => todo.id)), + ).then(results => { + if (results.some(res => res.status === 'rejected')) { + dispatch({ type: 'showError', payload: 'Unable to delete a todo' }); + } + + return results + .filter( + (res): res is PromiseFulfilledResult => + res.status === 'fulfilled', + ) + .map(res => dispatch({ type: 'deleteTodo', payload: res.value })); + }); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..1036977c33 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,47 @@ +import { useContext } from 'react'; +import { NewTodoField } from './NewTodoField'; +import { DispatchContext, StatesContext } from '../context/Store'; +import { updateTodo } from '../api/todos'; +import classNames from 'classnames'; + +export const Header: React.FC = () => { + const dispatch = useContext(DispatchContext); + const { todos } = useContext(StatesContext); + const handleOnToggleAll = () => { + const toggledTodos = todos.every(todo => todo.completed) + ? todos + : todos.filter(t => !t.completed); + + toggledTodos.map(todo => { + dispatch({ type: 'startUpdate' }); + updateTodo(todo.id, { ...todo, completed: !todo.completed }) + .then(newTodo => { + dispatch({ type: 'selectTodo', payload: newTodo.id }); + dispatch({ type: 'updateTodo', payload: newTodo }); + }) + .catch(() => { + dispatch({ type: 'showError', payload: 'Unable to update a todo' }); + }) + .finally(() => { + dispatch({ type: 'stopUpdate' }); + }); + }); + }; + + return ( +
+ {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/components/NewTodoField.tsx b/src/components/NewTodoField.tsx new file mode 100644 index 0000000000..44790aeef5 --- /dev/null +++ b/src/components/NewTodoField.tsx @@ -0,0 +1,63 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { DispatchContext, StatesContext } from '../context/Store'; +import { addTodo } from '../api/todos'; + +export const NewTodoField: React.FC = () => { + const dispatch = useContext(DispatchContext); + const { isUpdating, todos, errorMessage } = useContext(StatesContext); + const [title, setTitle] = useState(''); + const handleOnChange = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const inputRef = useRef(null); + + const handleOnSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch({ type: 'startUpdate' }); + if (!title.trim()) { + dispatch({ + type: 'showError', + payload: 'Title should not be empty', + }); + dispatch({ type: 'stopUpdate' }); + } else { + dispatch({ + type: 'addTempTodo', + payload: { userId: 2165, id: 0, title: title.trim(), completed: false }, + }); + addTodo({ userId: 2165, title: title.trim(), completed: false }) + .then(newTodo => { + dispatch({ type: 'addTodo', payload: newTodo }); + setTitle(''); + }) + .catch(() => { + dispatch({ type: 'showError', payload: 'Unable to add a todo' }); + }) + .finally(() => { + inputRef.current?.focus(); + dispatch({ type: 'removeTempTodo' }); + dispatch({ type: 'stopUpdate' }); + }); + } + }; + + useEffect(() => { + inputRef.current?.focus(); + }, [todos, errorMessage]); + + return ( +
+ +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..87c99f445b --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,216 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; +import { TodoLoader } from './TodoLoader'; +import { deleteTodo, updateTodo } from '../api/todos'; +import { Todo } from '../types/Todo'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { DispatchContext, StatesContext } from '../context/Store'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { selectedTodo } = useContext(StatesContext); + const dispatch = useContext(DispatchContext); + const [updatedTitle, setUpdatedTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + + const editInputRef = useRef(null); + + useEffect(() => { + if (isEditing && editInputRef.current) { + editInputRef.current.focus(); + } else if (editInputRef.current) { + editInputRef.current.blur(); + } + }, [isEditing]); + + //#region handlers + const handleOnDoubleClick = () => { + setIsEditing(true); + dispatch({ type: 'selectTodo', payload: todo.id }); + }; + + const handleOnClick = () => { + dispatch({ type: 'startUpdate' }); + dispatch({ type: 'selectTodo', payload: todo.id }); + deleteTodo(todo.id) + .then(() => { + dispatch({ type: 'deleteTodo', payload: todo.id }); + }) + .catch(() => { + dispatch({ + type: 'showError', + payload: 'Unable to delete a todo', + }); + }) + .finally(() => dispatch({ type: 'stopUpdate' })); + }; + + const handleOnCheckChange = (e: React.ChangeEvent) => { + dispatch({ type: 'startUpdate' }); + dispatch({ type: 'selectTodo', payload: todo.id }); + + updateTodo(todo.id, { ...todo, completed: e.target.checked }) + .then(newTodo => { + dispatch({ + type: 'updateTodo', + payload: { ...newTodo }, + }); + }) + .catch(() => { + dispatch({ type: 'showError', payload: 'Unable to update a todo' }); + }) + .finally(() => { + dispatch({ type: 'stopUpdate' }); + }); + }; + + const handleOnSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch({ type: 'startUpdate' }); + dispatch({ type: 'selectTodo', payload: todo.id }); + if (updatedTitle.trim() === todo.title) { + setIsEditing(false); + } + + if (updatedTitle.trim().length === 0) { + deleteTodo(todo.id) + .then(() => { + setIsEditing(false); + dispatch({ type: 'deleteTodo', payload: todo.id }); + }) + .catch(() => + dispatch({ type: 'showError', payload: 'Unable to update a todo' }), + ) + .finally(() => dispatch({ type: 'stopUpdate' })); + } + + updateTodo(todo.id, { ...todo, title: updatedTitle.trim() }) + .then(newTodo => { + dispatch({ + type: 'updateTodo', + payload: { ...newTodo }, + }); + + setIsEditing(false); + }) + .catch(() => { + dispatch({ type: 'showError', payload: 'Unable to delete a todo' }); + setIsEditing(true); + }) + .finally(() => { + dispatch({ type: 'stopUpdate' }); + }); + }; + + const handleOnTextChange = (e: React.ChangeEvent) => { + setUpdatedTitle(e.target.value); + }; + + const handleOnInputBlur = (e: React.ChangeEvent) => { + setUpdatedTitle(e.target.value); + dispatch({ type: 'startUpdate' }); + dispatch({ type: 'selectTodo', payload: todo.id }); + + if (updatedTitle.trim() === todo.title) { + setIsEditing(false); + dispatch({ type: 'stopUpdate' }); + + return; + } + + if (updatedTitle.trim().length === 0) { + deleteTodo(todo.id) + .then(() => { + setIsEditing(false); + dispatch({ type: 'deleteTodo', payload: todo.id }); + }) + .catch(() => + dispatch({ type: 'showError', payload: 'Unable to delete a todo' }), + ) + .finally(() => dispatch({ type: 'stopUpdate' })); + + return; + } + + updateTodo(todo.id, { ...todo, title: updatedTitle.trim() }) + .then(newTodo => { + dispatch({ + type: 'updateTodo', + payload: { ...newTodo }, + }); + + setIsEditing(false); + }) + .catch(() => { + dispatch({ type: 'showError', payload: 'Unable to update a todo' }); + setIsEditing(true); + }) + .finally(() => { + dispatch({ type: 'stopUpdate' }); + }); + }; + + const handleEscKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setUpdatedTitle(todo.title); + setIsEditing(false); + } + }; + + //#endregion + return ( +
+ + + {isEditing && selectedTodo === todo.id ? ( +
+ +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..8fd2a5342e --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,28 @@ +import { useContext } from 'react'; +import { StatesContext } from '../context/Store'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter, tempTodo } = useContext(StatesContext); + const filteredTodos = todos.filter(t => { + switch (filter) { + case 'all': + return t; + case 'active': + return !t.completed; + case 'completed': + return t.completed; + default: + return t; + } + }); + + return ( +
+ {filteredTodos.map(todo => { + return ; + })} + {tempTodo && } +
+ ); +}; diff --git a/src/components/TodoLoader.tsx b/src/components/TodoLoader.tsx new file mode 100644 index 0000000000..97fc58a303 --- /dev/null +++ b/src/components/TodoLoader.tsx @@ -0,0 +1,26 @@ +import classNames from 'classnames'; +import { StatesContext } from '../context/Store'; +import { useContext } from 'react'; +import { Todo } from '../types/Todo'; + +type PropsTodoLoader = { + todo: Todo; +}; + +export const TodoLoader: React.FC = ({ todo }) => { + const { isUpdating, selectedTodo, tempTodo } = useContext(StatesContext); + + return ( +
+
+
+
+ ); +}; diff --git a/src/context/Store.tsx b/src/context/Store.tsx new file mode 100644 index 0000000000..4bfb597325 --- /dev/null +++ b/src/context/Store.tsx @@ -0,0 +1,102 @@ +import { createContext, useReducer } from 'react'; +import { States } from '../types/States'; +import { Todo } from '../types/Todo'; +import { Filter } from '../types/Filter'; + +type DispatchContextType = { + (action: Action): void; +}; + +type Action = + | { type: 'loadTodos'; payload: Todo[] } + | { type: 'showError'; payload: string | null } + | { type: 'startUpdate' } + | { type: 'stopUpdate' } + | { type: 'selectTodo'; payload: number } + | { type: 'updateTodo'; payload: Todo } + | { type: 'setFilter'; payload: Filter } + | { type: 'deleteTodo'; payload: number } + | { type: 'addTodo'; payload: Todo } + | { type: 'addTempTodo'; payload: Todo } + | { type: 'removeTempTodo' }; + +const initialStates: States = { + todos: [], + errorMessage: null, + isUpdating: false, + selectedTodo: null, + filter: Filter.all, + tempTodo: null, +}; + +function reducer(states: States, action: Action) { + let newStates: States = { ...states }; + + switch (action.type) { + case 'loadTodos': + newStates = { ...newStates, todos: action.payload }; + break; + case 'showError': + newStates = { ...newStates, errorMessage: action.payload }; + break; + case 'startUpdate': + newStates = { ...newStates, isUpdating: true }; + break; + case 'stopUpdate': + newStates = { ...newStates, isUpdating: false }; + break; + case 'selectTodo': + newStates = { ...newStates, selectedTodo: action.payload }; + break; + case 'updateTodo': + newStates = { + ...newStates, + todos: states.todos.map(t => + action.payload.id === t.id ? action.payload : t, + ), + }; + break; + case 'setFilter': + newStates = { ...newStates, filter: action.payload }; + break; + case 'deleteTodo': + newStates = { + ...newStates, + todos: states.todos.filter(t => t.id !== action.payload), + }; + break; + case 'addTodo': + newStates = { + ...newStates, + todos: [...states.todos, action.payload], + }; + break; + case 'addTempTodo': + newStates = { ...newStates, tempTodo: action.payload }; + break; + case 'removeTempTodo': + newStates = { ...newStates, tempTodo: null }; + break; + default: + return states; + } + + return newStates; +} + +export const StatesContext = createContext(initialStates); +export const DispatchContext = createContext(() => {}); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [states, dispatch] = useReducer(reducer, initialStates); + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index fee7a5959b..7beac46599 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,5 +5,10 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { GlobalStateProvider } from './context/Store'; -createRoot(document.getElementById('root') as HTMLDivElement).render(); +createRoot(document.getElementById('root') as HTMLDivElement).render( + + + , +); diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..b78f5a4c26 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + all = 'all', + completed = 'completed', + active = 'active', +} diff --git a/src/types/States.ts b/src/types/States.ts new file mode 100644 index 0000000000..6d8c76b144 --- /dev/null +++ b/src/types/States.ts @@ -0,0 +1,11 @@ +import { Filter } from './Filter'; +import { Todo } from './Todo'; + +export interface States { + todos: Todo[]; + errorMessage: string | null; + isUpdating: boolean; + selectedTodo: number | null; + filter: Filter; + tempTodo: Todo | null; +} 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'), +};