From 12b903d4c574a94f7c7b0b774e0e00089343e9db Mon Sep 17 00:00:00 2001 From: Yana Date: Thu, 18 Jun 2026 01:40:11 +0300 Subject: [PATCH 1/3] Solution --- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 201 +++++++++------------------ src/UserWarning.tsx | 15 ++ src/components/ErrorNotification.tsx | 23 +++ src/components/Footer.tsx | 52 +++++++ src/components/TodoContext.tsx | 78 +++++++++++ src/components/TodoItem.tsx | 97 +++++++++++++ src/components/TodoList.tsx | 14 ++ src/index.tsx | 20 ++- src/styles/filter.scss | 22 +++ src/styles/todo.scss | 149 ++++++++++++++++++++ src/types/Errors.ts | 7 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 41 ++++++ 15 files changed, 590 insertions(+), 146 deletions(-) create mode 100644 src/UserWarning.tsx create mode 100644 src/components/ErrorNotification.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/TodoContext.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/styles/filter.scss create mode 100644 src/styles/todo.scss create mode 100644 src/types/Errors.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/package-lock.json b/package-lock.json index 1f19b4743..a7f06fc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1170,10 +1170,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", - "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", + "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 91d7489b9..446974833 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@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 a399287bd..ead313cd4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,87 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useContext, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { TodoContext } from './components/TodoContext'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { Todo } from './types/Todo'; export const App: React.FC = () => { + const { todos, addTodo, toggleAll } = useContext(TodoContext); + const [filter, setFilter] = useState<'All' | 'Active' | 'Completed'>('All'); + const [title, setTitle] = useState(''); + + const newTodoField = useRef(null); + + useEffect(() => { + newTodoField.current?.focus(); + }, [todos.length]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + addTodo(trimmedTitle); + setTitle(''); + }; + + const visibleTodos = todos.filter((todo: Todo) => { + if (filter === 'Active') { + return !todo.completed; + } + + if (filter === 'Completed') { + return todo.completed; + } + + return true; + }); + + const isAllCompleted = + todos.length > 0 && todos.every((todo: Todo) => todo.completed); + 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 */} - -
+ {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/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 000000000..7ed40de02 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,23 @@ +type Prop = { + errorMessage: string; + setErrorMessage: (errorMessage: string) => void; +}; + +export const ErrorNotification = ({ errorMessage, setErrorMessage }: Prop) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..c616490b4 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,52 @@ +import { useContext } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import classNames from 'classnames'; +import { TodoContext } from './TodoContext'; + +type FilterType = 'All' | 'Active' | 'Completed'; + +export const Footer: React.FC<{ + filter: FilterType; + setFilter: Dispatch>; +}> = ({ filter, setFilter }) => { + const { todos, clearCompleted } = useContext(TodoContext); + + const activeCount = todos.filter(todo => !todo.completed).length; + const hasCompleted = todos.some(todo => todo.completed); + + const filters: FilterType[] = ['All', 'Active', 'Completed']; + + return ( + + ); +}; diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx new file mode 100644 index 000000000..2193d36af --- /dev/null +++ b/src/components/TodoContext.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useState, useEffect } from 'react'; +import { Todo } from '../types/Todo'; + +type TodoContextType = { + todos: Todo[]; + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + updateTodo: (updatedTodo: Todo) => void; + toggleAll: () => void; + clearCompleted: () => void; +}; + +export const TodoContext = createContext( + {} as TodoContextType, +); + +export const TodoProvider = ({ children }: { children?: React.ReactNode }) => { + const [todos, setTodos] = useState(() => { + const saved = localStorage.getItem('todos'); + + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + const newTodo: Todo = { + id: +new Date(), + userId: 1, + title, + completed: false, + }; + + setTodos((prev: Todo[]) => [...prev, newTodo]); + }; + + const deleteTodo = (id: number) => { + setTodos((prev: Todo[]) => prev.filter((todo: Todo) => todo.id !== id)); + }; + + const updateTodo = (updatedTodo: Todo) => { + setTodos((prev: Todo[]) => + prev.map((todo: Todo) => + todo.id === updatedTodo.id ? updatedTodo : todo, + ), + ); + }; + + const toggleAll = () => { + const isAllCompleted = + todos.length > 0 && todos.every((todo: Todo) => todo.completed); + + setTodos((prev: Todo[]) => + prev.map((todo: Todo) => ({ ...todo, completed: !isAllCompleted })), + ); + }; + + const clearCompleted = () => { + setTodos((prev: Todo[]) => prev.filter((todo: Todo) => !todo.completed)); + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..f8221d438 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,97 @@ +import React, { useState, useRef, useEffect, useContext } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { TodoContext } from './TodoContext'; + +export const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => { + const { deleteTodo, updateTodo } = useContext(TodoContext); + + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + const editField = useRef(null); + + useEffect(() => { + if (isEditing) { + editField.current?.focus(); + } + }, [isEditing]); + + const saveTitle = () => { + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + deleteTodo(todo.id); + } else if (trimmedTitle !== todo.title) { + updateTodo({ ...todo, title: trimmedTitle }); + } + + setIsEditing(false); + setNewTitle(trimmedTitle || todo.title); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + setNewTitle(todo.title); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveTitle(); + }; + + return ( +
+
+ updateTodo({ ...todo, completed: !todo.completed })} + /> +
+ + {isEditing ? ( +
+ ) => + setNewTitle(e.target.value) + } + onBlur={saveTitle} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..63ae0d06f --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,14 @@ +import { TodoItem } from './TodoItem'; +import { Todo } from '../types/Todo'; + +export const TodoList: React.FC<{ + visibleTodos: Todo[]; +}> = ({ visibleTodos }) => { + return ( +
+ {visibleTodos.map((todo: Todo) => ( + + ))} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..2289baa5b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,17 @@ -import { createRoot } from 'react-dom/client'; - -import './styles/index.scss'; - +import React from 'react'; +import ReactDOM from 'react-dom/client'; import { App } from './App'; +import { TodoProvider } from './components/TodoContext'; +import './styles/index.scss'; -const container = document.getElementById('root') as HTMLDivElement; +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement, +); -createRoot(container).render(); +root.render( + + + + + , +); diff --git a/src/styles/filter.scss b/src/styles/filter.scss new file mode 100644 index 000000000..75b5804e5 --- /dev/null +++ b/src/styles/filter.scss @@ -0,0 +1,22 @@ +.filter { + display: flex; + + &__link { + margin: 3px; + padding: 3px 7px; + + color: inherit; + text-decoration: none; + + border: 1px solid transparent; + border-radius: 3px; + + &:hover { + border-color: rgba(175, 47, 47, 0.1); + } + + &.selected { + border-color: rgba(175, 47, 47, 0.2); + } + } +} diff --git a/src/styles/todo.scss b/src/styles/todo.scss new file mode 100644 index 000000000..c7f93ff6b --- /dev/null +++ b/src/styles/todo.scss @@ -0,0 +1,149 @@ +.todo { + position: relative; + + display: grid; + grid-template-columns: 45px 1fr; + justify-items: stretch; + + font-size: 24px; + line-height: 1.4em; + border-bottom: 1px solid #ededed; + + &:last-child { + border-bottom: 0; + } + + &__status-label { + cursor: pointer; + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center left; + } + + &.completed &__status-label { + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E"); + } + + &__status { + opacity: 0; + } + + &__title { + padding: 12px 15px; + + word-break: break-all; + transition: color 0.4s; + } + + &.completed &__title { + color: #d9d9d9; + text-decoration: line-through; + } + + &__remove { + position: absolute; + right: 12px; + top: 0; + bottom: 0; + + font-size: 120%; + line-height: 1; + font-family: inherit; + font-weight: inherit; + color: #cc9a9a; + + float: right; + border: 0; + background: none; + cursor: pointer; + + transform: translateY(-2px); + opacity: 0; + transition: color 0.2s ease-out; + + &:hover { + color: #af5b5e; + } + } + + &:hover &__remove { + opacity: 1; + } + + &__title-field { + width: 100%; + padding: 11px 14px; + + font-size: inherit; + line-height: inherit; + font-family: inherit; + font-weight: inherit; + color: inherit; + + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + + &::placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; + } + } + + .overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 58px; + + opacity: 0.5; + } +} + +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 000000000..956245312 --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,7 @@ +export enum Errors { + Load = 'Unable to load todos', + TitleEmpty = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} 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/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..b96cf3d50 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,41 @@ +const BASE_URL = 'https://mate.academy/students-api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data?: D, +): Promise { + const options: RequestInit = { method }; + + if (data !== undefined) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + 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?: D) => request(url, 'POST', data), + patch: (url: string, data?: D) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; From cef4d051c519f566c02a4577192b3e8b261e78d1 Mon Sep 17 00:00:00 2001 From: Yana Date: Thu, 18 Jun 2026 01:55:13 +0300 Subject: [PATCH 2/3] Solution --- cypress/tsconfig.json | 2 +- src/utils/fetchClient.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 0211a3074..f6a2ae7aa 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -3,4 +3,4 @@ "compilerOptions": { "sourceMap": false } -} +} \ No newline at end of file diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index b96cf3d50..acd865416 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -35,7 +35,9 @@ function request( export const client = { get: (url: string) => request(url), - post: (url: string, data?: D) => request(url, 'POST', data), - patch: (url: string, data?: D) => request(url, 'PATCH', data), - delete: (url: string) => request(url, 'DELETE'), + post: (url: string, data?: D) => + request(url, 'POST', data), + patch: (url: string, data?: D) => + request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), }; From 36ba2d13721b71cc6e99e8621fc12d374ff2ee72 Mon Sep 17 00:00:00 2001 From: Yana Date: Thu, 18 Jun 2026 13:30:17 +0300 Subject: [PATCH 3/3] Solution --- src/components/TodoItem.tsx | 4 ++-- src/styles/index.scss | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx index f8221d438..b30ac7463 100644 --- a/src/components/TodoItem.tsx +++ b/src/components/TodoItem.tsx @@ -49,7 +49,7 @@ export const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => { editing: isEditing, })} > -
+
+ {isEditing ? (
diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..ac00217e1 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,12 @@ body { pointer-events: none; } +input:focus, +.todo__input:focus, +.todoapp__new-todo:focus { + outline: none; +} + @import './todoapp'; @import './todo-list'; @import './filters';