diff --git a/README.md b/README.md index 903c876f9..1b44df94c 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th - Implement a solution following the [React task guidelines](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). - Open another terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your GitHub username in the [DEMO LINK](https://.github.io/react_todo-app/) and add it to the PR description. +- Replace `` with your GitHub username in the [DEMO LINK](https://Mariana-VV.github.io/react_todo-app/) and add it to the PR description. diff --git a/cypress.config.ts b/cypress.config.ts index 6aa317d01..34bee65f6 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,7 +2,7 @@ const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { - baseUrl: 'http://localhost:3000', + baseUrl: 'http://localhost:5173', specPattern: 'cypress/integration/**/*.spec.{js,ts,jsx,tsx}', }, video: true, diff --git a/cypress/integration/page.spec.js b/cypress/integration/page.spec.js index 0875764e1..57ce99b8e 100644 --- a/cypress/integration/page.spec.js +++ b/cypress/integration/page.spec.js @@ -10,7 +10,7 @@ const page = { localStorage: () => cy.getAllLocalStorage().its('http://localhost:3001'), data: () => page.localStorage().then(({ todos = '[]' }) => JSON.parse(todos)), - visit: (initialTodos) => { + visit: initialTodos => { cy.visit('/', { onBeforeLoad: win => { if (initialTodos) { @@ -138,7 +138,7 @@ describe('', () => { it('should save todos to localStorage in JSON', () => { page.localStorage().should('have.keys', 'todos'); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.be.instanceOf(Array); expect(todos).to.have.length(1); expect(todos[0].title).to.equal('First todo'); @@ -365,7 +365,7 @@ describe('', () => { it('should save updated todos to localStorage', () => { page.newTodoField().type('Test Todo{enter}'); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(6); expect(todos[5].title).to.equal('Test Todo'); expect(todos[5].completed).to.be.false; @@ -404,7 +404,7 @@ describe('', () => { page.newTodoField().type('Test Todo{enter}'); page.newTodoField().type('Hello world{enter}'); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(7); expect(todos[6].title).to.equal('Hello world'); expect(todos[6].completed).to.be.false; @@ -441,7 +441,7 @@ describe('', () => { it('should save all changes to localStorage', () => { todos.deleteButton(0).click(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(4); expect(todos[0].title).to.equal('CSS'); }); @@ -528,7 +528,7 @@ describe('', () => { it('should save all changes to localStorage', () => { page.clearCompletedButton().click(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(2); expect(todos[0].title).to.equal('TypeScript'); expect(todos[0].completed).to.be.false; @@ -588,7 +588,7 @@ describe('', () => { }); it('should save changes to localStorage', () => { - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(5); expect(todos[0].title).to.equal('HTML'); expect(todos[0].completed).to.be.false; @@ -655,7 +655,7 @@ describe('', () => { it('should save changes to localStorage', () => { page.toggleAllButton().click(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(5); expect(todos[0].completed).to.be.false; expect(todos[1].completed).to.be.false; @@ -700,7 +700,7 @@ describe('', () => { it('should save changes to localStorage', () => { page.toggleAllButton().click(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(5); expect(todos[0].completed).to.be.true; expect(todos[1].completed).to.be.true; @@ -746,7 +746,7 @@ describe('', () => { it('should save changes to localStorage', () => { page.toggleAllButton().click(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(5); expect(todos[0].completed).to.be.true; expect(todos[1].completed).to.be.true; @@ -830,7 +830,7 @@ describe('', () => { it('should save changes to localStorage', () => { todos.titleField(0).type(' Some new title {enter}'); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(5); expect(todos[0].title).to.equal('Some new title'); }); @@ -871,7 +871,7 @@ describe('', () => { todos.titleField(0).type(' Some new title '); todos.titleField(0).blur(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(5); expect(todos[0].title).to.equal('Some new title'); }); @@ -930,7 +930,7 @@ describe('', () => { it('should save changes to localStorage', () => { todos.titleField(0).type('{enter}'); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(4); expect(todos[0].title).to.equal('CSS'); }); @@ -938,8 +938,6 @@ describe('', () => { }); describe('on Escape', () => { - - it('should be closed', () => { todos.titleField(0).type('{esc}'); @@ -970,7 +968,7 @@ describe('', () => { it('should save changes to localStorage', () => { todos.titleField(0).blur(); - page.data().then((todos) => { + page.data().then(todos => { expect(todos).to.have.length(4); expect(todos[0].title).to.equal('CSS'); }); diff --git a/package-lock.json b/package-lock.json index 0adcc869f..1f19b4743 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,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", @@ -1170,9 +1170,9 @@ } }, "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, "dependencies": { "@octokit/rest": "^17.11.2", diff --git a/package.json b/package.json index e6134ce84..91d7489b9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,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 a399287bd..49bcc2b5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,6 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +/* eslint-disable object-curly-newline */ /* eslint-disable @typescript-eslint/quotes */ /* eslint-disable jsx-a11y/control-has-associated-label */ +import { TodoApp } from './components/TodoApp'; export const App: React.FC = () => { - 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 */} - -
-
-
- ); + return ; }; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..4335a0825 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,86 @@ +import { useContext } from 'react'; +import { TodoContext } from '../store/TodoContext'; +import './TodoApp.scss'; +import classNames from 'classnames'; +import { Status } from '../types/Status'; + +export const Footer = () => { + const { todos, setTodos, status, setStatus } = useContext(TodoContext); + + const notCompletedTodos = todos.filter(t => t.completed === false); + const completedTodos = todos.filter(t => t.completed === true); + + const handleChooseAll = () => { + setStatus(Status.all); + }; + + const handleChooseActive = () => { + setStatus(Status.active); + }; + + const handleChooseCompleted = () => { + setStatus(Status.completed); + }; + + function handleClearCompleted() { + const tempTodos = todos.filter(todo => todo.completed === false); + + setTodos(tempTodos); + } + + return ( +
+ + {notCompletedTodos.length} items left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..cbad95568 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,93 @@ +/* eslint-disable no-param-reassign */ +import { + ChangeEvent, + FormEvent, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +import { TodoContext } from '../store/TodoContext'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; + +export const Header = () => { + const { todos, setTodos } = useContext(TodoContext); + const [title, setTitle] = useState(''); + + const titleField = useRef(null); + + useEffect(() => { + titleField.current?.focus(); + }, [todos]); + + const isAllTodosCompleted = + todos.filter(t => t.completed === true).length === todos.length; + + function handleTitleChange(event: ChangeEvent) { + setTitle(event.target.value); + } + + function onFormSubmit(event: FormEvent) { + event.preventDefault(); + if (!title.trim()) { + setTitle(''); + + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: title.trim(), + completed: false, + }; + + setTodos([...todos, newTodo]); + + setTitle(''); + } + + function handleToggleAll() { + if (isAllTodosCompleted) { + todos.forEach(t => { + t.completed = false; + }); + } else { + todos.forEach(t => { + t.completed = true; + }); + } + + setTodos([...todos]); + } + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoApp.scss b/src/components/TodoApp.scss new file mode 100644 index 000000000..f55c9b3e8 --- /dev/null +++ b/src/components/TodoApp.scss @@ -0,0 +1,122 @@ +.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; + inset: 0; + + opacity: 0.5; + } +} + +.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/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..2e3c006bd --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,22 @@ +import { TodoList } from '../components/TodoList'; +import { Header } from '../components/Header'; +import { Footer } from '../components/Footer'; +import { TodoContext } from '../store/TodoContext'; +import { useContext } from 'react'; +import '../styles/todoapp.scss'; + +export const TodoApp = () => { + const { todos } = useContext(TodoContext); + + return ( +
+

todos

+ +
+
+ {todos.length > 0 && } + {todos.length > 0 &&
} +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..3021782a9 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,132 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable prettier/prettier */ +import { ChangeEvent, useContext, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoContext } from '../store/TodoContext'; +import classNames from 'classnames'; + +/* eslint-disable import/extensions */ +type Props = { todo: Todo }; + +export const TodoItem: React.FC = ({ todo }) => { + const { todos, setTodos } = useContext(TodoContext); + const index = todos.findIndex(t => t.id === todo.id); + const [isEdit, setIsEdit] = useState(false); + const [tempTitle, setTempTitle] = useState(todo.title); + + const handleTitleChange = (event: ChangeEvent) => { + setTempTitle(event.target.value); + }; + + function handleToggleCompleted() { + todos[index].completed = !todos[index].completed; + setTodos([...todos]); + } + + function handleDoubleClick() { + setIsEdit(true); + } + + function handleRemoveTodo() { + todos.splice(index, 1); + setTodos([...todos]); + } + + function onEditFormSubmit() { + // event.preventDefault(); + if (!tempTitle.trim()) { + handleRemoveTodo(); + } + + if (tempTitle.trim()) { + todos[index].title = tempTitle.trim(); + } + + setTodos([...todos]); + } + + function handleOnBlur() { + // event.preventDefault(); + if (!tempTitle.trim()) { + handleRemoveTodo(); + } + + if (tempTitle.trim()) { + todos[index].title = tempTitle.trim(); + } + + setTodos([...todos]); + setIsEdit(false); + } + + function handleCancelClick(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + setIsEdit(false); + } + } + + return ( + <> + {!isEdit ? ( +
+ + + + {todo.title} + + + {/* Remove button appears only on hover */} + +
+ ) : ( +
+ + + {/* This form is shown instead of the title and remove button */} +
+ +
+
+ )} + + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..d5d0cd9ab --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,19 @@ +/* eslint-disable import/extensions */ +/* eslint-disable @typescript-eslint/quotes */ + +import { useContext } from 'react'; +import { TodoContext } from '../store/TodoContext'; + +import { TodoItem } from './TodoItem'; + +export const TodoList = () => { + const { filteredTodos } = useContext(TodoContext); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx new file mode 100644 index 000000000..4633a2a17 --- /dev/null +++ b/src/hooks/useLocalStorage.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, + startTodo: Todo, +): [Todo, (t: Todo) => void] { + const [todo, setTodo] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + localStorage.setItem(key, JSON.stringify(startTodo)); + + return startTodo; + } + + try { + return JSON.parse(data); + } catch (e) { + localStorage.removeItem(key); + + return startTodo; + } + }); + + const save = (newTodo: Todo) => { + localStorage.setItem(key, JSON.stringify(newTodo)); + setTodo(newTodo); + }; + + return [todo, save]; +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..17628ea8e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,7 +5,12 @@ import './styles/todo-list.css'; import './styles/filters.css'; import { App } from './App'; +import { TodoProvider } from './store/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/store/TodoContext.tsx b/src/store/TodoContext.tsx new file mode 100644 index 000000000..ada4615b3 --- /dev/null +++ b/src/store/TodoContext.tsx @@ -0,0 +1,58 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useMemo, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { Status } from '../types/Status'; + +type T = { + todos: Todo[]; + + setTodos: (_todos: Todo[]) => void; + status: {}; + setStatus: (_status: Status) => void; + filteredTodos: Todo[]; +}; + +export const TodoContext = React.createContext({ + todos: [], + + setTodos: (_todos: Todo[]) => {}, + status: {}, + setStatus: (_status: Status) => {}, + filteredTodos: [], +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + + const [status, setStatus] = useState(Status.all); + + const filteredTodos = todos.filter(todo => { + switch (status) { + case Status.all: + return true; + case Status.active: + return todo.completed === false; + case Status.completed: + return todo.completed === true; + } + }); + + const value = useMemo( + () => ({ + status, + setStatus, + todos, + setTodos, + filteredTodos, + }), + [filteredTodos, todos, status, setStatus, setTodos], + ); + + return {children}; +}; diff --git a/src/styles/index.css b/src/styles/index.css index a34eec7c6..35bc69a61 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -20,6 +20,6 @@ body { pointer-events: none; } -@import './todoapp'; +/* @import './todoapp'; */ @import './todo'; @import './filter'; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..e3983c143 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,7 +56,7 @@ } &__new-todo { - width: 100%; + width: 85%; padding: 16px 16px 16px 60px; font-size: 24px; diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 000000000..1e0bd526c --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + all = 'All', + active = 'Active', + completed = 'Completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +} diff --git a/src/utils/completedTodos.ts b/src/utils/completedTodos.ts new file mode 100644 index 000000000..b71b738f0 --- /dev/null +++ b/src/utils/completedTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const completedTodos = (todos: Todo[]) => todos.filter(t => t.completed); + +export const activeTodos = (todos: Todo[]) => todos.filter(t => !t.completed);