From 8d79d1c5c91fc7fc60ef551e2bc5135fd2fb5211 Mon Sep 17 00:00:00 2001 From: Nazarii AlieKsieiev Date: Tue, 29 Oct 2024 13:55:09 +0200 Subject: [PATCH] Developed --- cypress/integration/page.spec.js | 6 +- package-lock.json | 257 ++++++++++++------- package.json | 2 +- src/App.tsx | 180 +++---------- src/Store.tsx | 150 +++++++++++ src/UserWarning.tsx | 15 ++ src/api/todos.ts | 24 ++ src/components/Notification/Notification.tsx | 29 +++ src/components/TodoFilter/TodoFilter.tsx | 119 +++++++++ src/components/TodoForm/TodoForm.tsx | 122 +++++++++ src/components/TodoItem/TodoItem.tsx | 106 ++++++++ src/components/TodoList/TodoList.tsx | 171 ++++++++++++ src/index.tsx | 15 +- src/styles/index.scss | 6 +- src/styles/todo.scss | 56 +++- src/styles/todoapp.scss | 18 +- src/types/ErrorMessage.ts | 7 + src/types/Status.ts | 5 + src/types/Todo.ts | 6 + src/utils/autoCloseNotification.ts | 7 + src/utils/fetchClient.ts | 46 ++++ 21 files changed, 1082 insertions(+), 265 deletions(-) create mode 100644 src/Store.tsx create mode 100644 src/UserWarning.tsx create mode 100644 src/api/todos.ts create mode 100644 src/components/Notification/Notification.tsx create mode 100644 src/components/TodoFilter/TodoFilter.tsx create mode 100644 src/components/TodoForm/TodoForm.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/ErrorMessage.ts create mode 100644 src/types/Status.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/autoCloseNotification.ts create mode 100644 src/utils/fetchClient.ts diff --git a/cypress/integration/page.spec.js b/cypress/integration/page.spec.js index 0875764e1..5b6f0e428 100644 --- a/cypress/integration/page.spec.js +++ b/cypress/integration/page.spec.js @@ -58,9 +58,9 @@ Cypress.on('fail', e => { }); describe('', () => { - beforeEach(() => { - if (failed) Cypress.runner.stop(); - }); + // beforeEach(() => { + // if (failed) Cypress.runner.stop(); + // }); describe('Page with no todos', () => { beforeEach(() => { diff --git a/package-lock.json b/package-lock.json index 0adcc869f..fe8e1978c 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,10 +1170,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", @@ -1862,208 +1863,252 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.1.tgz", - "integrity": "sha512-lncuC4aHicncmbORnx+dUaAgzee9cm/PbIqgWz1PpXuwc+sa1Ct83tnqUDy/GFKleLiN7ZIeytM6KJ4cAn1SxA==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.2.tgz", + "integrity": "sha512-ufoveNTKDg9t/b7nqI3lwbCG/9IJMhADBNjjz/Jn6LxIZxD7T5L8l2uO/wD99945F1Oo8FvgbbZJRguyk/BdzA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.1.tgz", - "integrity": "sha512-F/tkdw0WSs4ojqz5Ovrw5r9odqzFjb5LIgHdHZG65dFI1lWTWRVy32KDJLKRISHgJvqUeUhdIvy43fX41znyDg==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.2.tgz", + "integrity": "sha512-iZoYCiJz3Uek4NI0J06/ZxUgwAfNzqltK0MptPDO4OR0a88R4h0DSELMsflS6ibMCJ4PnLvq8f7O1d7WexUvIA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.1.tgz", - "integrity": "sha512-vk+ma8iC1ebje/ahpxpnrfVQJibTMyHdWpOGZ3JpQ7Mgn/3QNHmPq7YwjZbIE7km73dH5M1e6MRRsnEBW7v5CQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.2.tgz", + "integrity": "sha512-/UhrIxobHYCBfhi5paTkUDQ0w+jckjRZDZ1kcBL132WeHZQ6+S5v9jQPVGLVrLbNUebdIRpIt00lQ+4Z7ys4Rg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.1.tgz", - "integrity": "sha512-IgpzXKauRe1Tafcej9STjSSuG0Ghu/xGYH+qG6JwsAUxXrnkvNHcq/NL6nz1+jzvWAnQkuAJ4uIwGB48K9OCGA==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.2.tgz", + "integrity": "sha512-1F/jrfhxJtWILusgx63WeTvGTwE4vmsT9+e/z7cZLKU8sBMddwqw3UV5ERfOV+H1FuRK3YREZ46J4Gy0aP3qDA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.24.2.tgz", + "integrity": "sha512-1YWOpFcGuC6iGAS4EI+o3BV2/6S0H+m9kFOIlyFtp4xIX5rjSnL3AwbTBxROX0c8yWtiWM7ZI6mEPTI7VkSpZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.24.2.tgz", + "integrity": "sha512-3qAqTewYrCdnOD9Gl9yvPoAoFAVmPJsBvleabvx4bnu1Kt6DrB2OALeRVag7BdWGWLhP1yooeMLEi6r2nYSOjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.1.tgz", - "integrity": "sha512-P9bSiAUnSSM7EmyRK+e5wgpqai86QOSv8BwvkGjLwYuOpaeomiZWifEos517CwbG+aZl1T4clSE1YqqH2JRs+g==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.2.tgz", + "integrity": "sha512-ArdGtPHjLqWkqQuoVQ6a5UC5ebdX8INPuJuJNWRe0RGa/YNhVvxeWmCTFQ7LdmNCSUzVZzxAvUznKaYx645Rig==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.1.tgz", - "integrity": "sha512-5RnjpACoxtS+aWOI1dURKno11d7krfpGDEn19jI8BuWmSBbUC4ytIADfROM1FZrFhQPSoP+KEa3NlEScznBTyQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.2.tgz", + "integrity": "sha512-B6UHHeNnnih8xH6wRKB0mOcJGvjZTww1FV59HqJoTJ5da9LCG6R4SEBt6uPqzlawv1LoEXSS0d4fBlHNWl6iYw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.1.tgz", - "integrity": "sha512-8mwmGD668m8WaGbthrEYZ9CBmPug2QPGWxhJxh/vCgBjro5o96gL04WLlg5BA233OCWLqERy4YUzX3bJGXaJgQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.2.tgz", + "integrity": "sha512-kr3gqzczJjSAncwOS6i7fpb4dlqcvLidqrX5hpGBIM1wtt0QEVtf4wFaAwVv8QygFU8iWUMYEoJZWuWxyua4GQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.1.tgz", - "integrity": "sha512-dJX9u4r4bqInMGOAQoGYdwDP8lQiisWb9et+T84l2WXk41yEej8v2iGKodmdKimT8cTAYt0jFb+UEBxnPkbXEQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.2.tgz", + "integrity": "sha512-TDdHLKCWgPuq9vQcmyLrhg/bgbOvIQ8rtWQK7MRxJ9nvaxKx38NvY7/Lo6cYuEnNHqf6rMqnivOIPIQt6H2AoA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.1.tgz", - "integrity": "sha512-V72cXdTl4EI0x6FNmho4D502sy7ed+LuVW6Ym8aI6DRQ9hQZdp5sj0a2usYOlqvFBNKQnLQGwmYnujo2HvjCxQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.2.tgz", + "integrity": "sha512-xv9vS648T3X4AxFFZGWeB5Dou8ilsv4VVqJ0+loOIgDO20zIhYfDLkk5xoQiej2RiSQkld9ijF/fhLeonrz2mw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.1.tgz", - "integrity": "sha512-f+pJih7sxoKmbjghrM2RkWo2WHUW8UbfxIQiWo5yeCaCM0TveMEuAzKJte4QskBp1TIinpnRcxkquY+4WuY/tg==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.2.tgz", + "integrity": "sha512-tbtXwnofRoTt223WUZYiUnbxhGAOVul/3StZ947U4A5NNjnQJV5irKMm76G0LGItWs6y+SCjUn/Q0WaMLkEskg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.1.tgz", - "integrity": "sha512-qb1hMMT3Fr/Qz1OKovCuUM11MUNLUuHeBC2DPPAWUYYUAOFWaxInaTwTQmc7Fl5La7DShTEpmYwgdt2hG+4TEg==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.2.tgz", + "integrity": "sha512-gc97UebApwdsSNT3q79glOSPdfwgwj5ELuiyuiMY3pEWMxeVqLGKfpDFoum4ujivzxn6veUPzkGuSYoh5deQ2Q==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.1.tgz", - "integrity": "sha512-7O5u/p6oKUFYjRbZkL2FLbwsyoJAjyeXHCU3O4ndvzg2OFO2GinFPSJFGbiwFDaCFc+k7gs9CF243PwdPQFh5g==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.2.tgz", + "integrity": "sha512-jOG/0nXb3z+EM6SioY8RofqqmZ+9NKYvJ6QQaa9Mvd3RQxlH68/jcB/lpyVt4lCiqr04IyaC34NzhUqcXbB5FQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.1.tgz", - "integrity": "sha512-pDLkYITdYrH/9Cv/Vlj8HppDuLMDUBmgsM0+N+xLtFd18aXgM9Nyqupb/Uw+HeidhfYg2lD6CXvz6CjoVOaKjQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.2.tgz", + "integrity": "sha512-XAo7cJec80NWx9LlZFEJQxqKOMz/lX3geWs2iNT5CHIERLFfd90f3RYLLjiCBm1IMaQ4VOX/lTC9lWfzzQm14Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.1.tgz", - "integrity": "sha512-W2ZNI323O/8pJdBGil1oCauuCzmVd9lDmWBBqxYZcOqWD6aWqJtVBQ1dFrF4dYpZPks6F+xCZHfzG5hYlSHZ6g==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.2.tgz", + "integrity": "sha512-A+JAs4+EhsTjnPQvo9XY/DC0ztaws3vfqzrMNMKlwQXuniBKOIIvAAI8M0fBYiTCxQnElYu7mLk7JrhlQ+HeOw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.1.tgz", - "integrity": "sha512-ELfEX1/+eGZYMaCIbK4jqLxO1gyTSOIlZr6pbC4SRYFaSIDVKOnZNMdoZ+ON0mrFDp4+H5MhwNC1H/AhE3zQLg==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.2.tgz", + "integrity": "sha512-ZhcrakbqA1SCiJRMKSU64AZcYzlZ/9M5LaYil9QWxx9vLnkQ9Vnkve17Qn4SjlipqIIBFKjBES6Zxhnvh0EAEw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.1.tgz", - "integrity": "sha512-yjk2MAkQmoaPYCSu35RLJ62+dz358nE83VfTePJRp8CG7aMg25mEJYpXFiD+NcevhX8LxD5OP5tktPXnXN7GDw==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.2.tgz", + "integrity": "sha512-2mLH46K1u3r6uwc95hU+OR9q/ggYMpnS7pSp83Ece1HUQgF9Nh/QwTK5rcgbFnV9j+08yBrU5sA/P0RK2MSBNA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2146,10 +2191,11 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/get-port": { "version": "4.2.0", @@ -7092,10 +7138,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -8239,10 +8286,11 @@ "dev": true }, "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "dev": true, + "license": "MIT", "dependencies": { "isarray": "0.0.1" } @@ -8275,10 +8323,11 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -8396,9 +8445,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -8414,10 +8463,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -9005,12 +9055,13 @@ } }, "node_modules/rollup": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.1.tgz", - "integrity": "sha512-Elx2UT8lzxxOXMpy5HWQGZqkrQOtrVDDa/bm9l10+U4rQnVzbL/LgZ4NOM1MPIDyHk69W4InuYDF5dzRh4Kw1A==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.2.tgz", + "integrity": "sha512-do/DFGq5g6rdDhdpPq5qb2ecoczeK6y+2UAjdJ5trjQJj5f1AiVdLRWRc9A9/fFukfvJRgM0UXzxBIYMovm5ww==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -9020,22 +9071,24 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.18.1", - "@rollup/rollup-android-arm64": "4.18.1", - "@rollup/rollup-darwin-arm64": "4.18.1", - "@rollup/rollup-darwin-x64": "4.18.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.18.1", - "@rollup/rollup-linux-arm-musleabihf": "4.18.1", - "@rollup/rollup-linux-arm64-gnu": "4.18.1", - "@rollup/rollup-linux-arm64-musl": "4.18.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.18.1", - "@rollup/rollup-linux-riscv64-gnu": "4.18.1", - "@rollup/rollup-linux-s390x-gnu": "4.18.1", - "@rollup/rollup-linux-x64-gnu": "4.18.1", - "@rollup/rollup-linux-x64-musl": "4.18.1", - "@rollup/rollup-win32-arm64-msvc": "4.18.1", - "@rollup/rollup-win32-ia32-msvc": "4.18.1", - "@rollup/rollup-win32-x64-msvc": "4.18.1", + "@rollup/rollup-android-arm-eabi": "4.24.2", + "@rollup/rollup-android-arm64": "4.24.2", + "@rollup/rollup-darwin-arm64": "4.24.2", + "@rollup/rollup-darwin-x64": "4.24.2", + "@rollup/rollup-freebsd-arm64": "4.24.2", + "@rollup/rollup-freebsd-x64": "4.24.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.2", + "@rollup/rollup-linux-arm-musleabihf": "4.24.2", + "@rollup/rollup-linux-arm64-gnu": "4.24.2", + "@rollup/rollup-linux-arm64-musl": "4.24.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.2", + "@rollup/rollup-linux-riscv64-gnu": "4.24.2", + "@rollup/rollup-linux-s390x-gnu": "4.24.2", + "@rollup/rollup-linux-x64-gnu": "4.24.2", + "@rollup/rollup-linux-x64-musl": "4.24.2", + "@rollup/rollup-win32-arm64-msvc": "4.24.2", + "@rollup/rollup-win32-ia32-msvc": "4.24.2", + "@rollup/rollup-win32-x64-msvc": "4.24.2", "fsevents": "~2.3.2" } }, @@ -9368,10 +9421,11 @@ "dev": true }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10402,14 +10456,15 @@ } }, "node_modules/vite": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", - "integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==", + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -10428,6 +10483,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -10445,6 +10501,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, 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..74a900241 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,51 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import { TodoFilter } from './components/TodoFilter/TodoFilter'; +import React, { useContext, useEffect } from 'react'; +import { UserWarning } from './UserWarning'; +import { getTodos, USER_ID } from './api/todos'; +import { TodoList } from './components/TodoList/TodoList'; +import { ErrorMessage } from './types/ErrorMessage'; +import { TodoForm } from './components/TodoForm/TodoForm'; +import { Notification } from './components/Notification/Notification'; +import { DispatchContext, StateContext } from './Store'; +import { onAutoCloseNotification } from './utils/autoCloseNotification'; export const App: React.FC = () => { + const dispatch = useContext(DispatchContext); + const { todos } = useContext(StateContext); + + useEffect(() => { + dispatch({ type: 'download' }); + getTodos() + .then(downloadedTodos => { + dispatch({ type: 'downloadSuccess', todos: downloadedTodos }); + }) + .catch(() => { + dispatch({ type: 'failure', errorMessage: ErrorMessage.load }); + }) + .finally(() => { + onAutoCloseNotification(dispatch); + }); + }, []); + + 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 */} - -
+ {todos.length > 0 && }
+ +
); }; diff --git a/src/Store.tsx b/src/Store.tsx new file mode 100644 index 000000000..ef3720714 --- /dev/null +++ b/src/Store.tsx @@ -0,0 +1,150 @@ +import React, { useReducer } from 'react'; +import { ErrorMessage } from './types/ErrorMessage'; +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +export type Action = + | { type: 'download' } + | { type: 'downloadSuccess'; todos: Todo[] } + | { type: 'failure'; errorMessage: ErrorMessage | null } + | { type: 'closeNotification' } + | { type: 'filterStatus'; status: Status } + | { type: 'addTodo'; tempTodo: Todo } + | { type: 'addingSuccess'; newTodo: Todo } + | { type: 'reset' } + | { type: 'startAction'; selectedTodo: number[] } + | { type: 'deletingSuccesses' } + | { type: 'editTodoSuccess'; index: number; updatedTodo: Todo } + | { type: 'toggleAllSuccesses'; completed: boolean }; + +interface RootState { + todos: Todo[]; + tempTodo: Todo | null; + selectedTodo: number[]; + status: Status; + isProcessing: boolean; + errorMessage: ErrorMessage | null; +} + +const initialState: RootState = { + todos: [], + tempTodo: null, + selectedTodo: [], + status: Status.all, + isProcessing: false, + errorMessage: null, +}; + +const reducer = (state: RootState, action: Action): RootState => { + switch (action.type) { + case 'download': + return { + ...state, + isProcessing: true, + errorMessage: null, + }; + case 'downloadSuccess': + return { + ...state, + todos: action.todos, + isProcessing: false, + }; + case 'failure': + return { + ...state, + errorMessage: action.errorMessage, + isProcessing: false, + }; + case 'closeNotification': + return { + ...state, + errorMessage: null, + }; + case 'filterStatus': + return { + ...state, + status: action.status, + }; + case 'addTodo': + return { + ...state, + tempTodo: action.tempTodo, + isProcessing: true, + errorMessage: null, + }; + case 'addingSuccess': + return { + ...state, + isProcessing: false, + tempTodo: null, + todos: [...state.todos, action.newTodo], + }; + case 'reset': + return { + ...state, + tempTodo: null, + selectedTodo: [], + }; + case 'startAction': + return { + ...state, + isProcessing: true, + errorMessage: null, + selectedTodo: action.selectedTodo, + }; + case 'deletingSuccesses': + const newTodos = state.todos.filter( + todo => !state.selectedTodo.includes(todo.id), + ); + + return { + ...state, + todos: newTodos, + isProcessing: false, + }; + case 'editTodoSuccess': { + const updatedTodos = state.todos.map((todo, idx) => + idx === action.index ? action.updatedTodo : todo, + ); + + return { + ...state, + todos: updatedTodos, + isProcessing: false, + }; + } + + case 'toggleAllSuccesses': { + const updatedTodos = state.todos.map(todo => ({ + ...todo, + completed: action.completed, + })); + + return { + ...state, + todos: updatedTodos, + isProcessing: false, + }; + } + + default: + return state; + } +}; + +export const StateContext = React.createContext(initialState); +// eslint-disable-next-line @typescript-eslint/no-unused-vars, prettier/prettier +export const DispatchContext = React.createContext((action: Action) => { }); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + return ( + + {children} + + ); +}; 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..602161194 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1781; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (title: string) => { + return client.post(`/todos`, { title, userId: USER_ID, completed: false }); +}; + +export const toggleTodo = (id: number, status: boolean) => { + return client.patch(`/todos/${id}`, { completed: !status }); +}; + +export const editTodo = (id: number, title: string) => { + return client.patch(`/todos/${id}`, { title }); +}; diff --git a/src/components/Notification/Notification.tsx b/src/components/Notification/Notification.tsx new file mode 100644 index 000000000..f4792fd92 --- /dev/null +++ b/src/components/Notification/Notification.tsx @@ -0,0 +1,29 @@ +import React, { useContext, useMemo } from 'react'; +import cn from 'classNames'; +import { DispatchContext, StateContext } from '../../Store'; + +export const Notification: React.FC = () => { + const { errorMessage } = useContext(StateContext); + const hasError = useMemo(() => !Boolean(errorMessage), [errorMessage]); + const dispatch = useContext(DispatchContext); + const onCloseNotification = () => { + dispatch({ type: 'closeNotification' }); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx new file mode 100644 index 000000000..d39a06753 --- /dev/null +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useContext, useMemo } from 'react'; +import cn from 'classNames'; + +import { Status } from '../../types/Status'; +import { DispatchContext, StateContext } from '../../Store'; +import { deleteTodo } from '../../api/todos'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import { onAutoCloseNotification } from '../../utils/autoCloseNotification'; + +export const TodoFilter: React.FC = () => { + const { todos, status } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const onSetStatus = useCallback( + (event: React.MouseEvent) => { + const a = event.target as HTMLAnchorElement; + const statusValue = + (a.getAttribute('href')?.replace('#/', '') as Status) || Status.all; + + dispatch({ type: 'filterStatus', status: statusValue }); + }, + [dispatch], + ); + + const completedTodos = useMemo(() => { + const completed = todos.filter(todo => todo.completed).length; + + return completed; + }, [todos]); + const todosCounter = useMemo(() => { + const notCompletedTodos = todos.filter(todo => !todo.completed).length; + const message = + notCompletedTodos === 1 + ? `${notCompletedTodos} item left` + : `${notCompletedTodos} items left`; + + return message; + }, [todos]); + + const isActiveButton = useCallback( + (value: string) => value === status, + [status], + ); + + const capitalize = useCallback((word: string) => { + if (!word) { + return ''; + } + + return word[0].toUpperCase() + word.slice(1).toLowerCase(); + }, []); + + const handleClearCompleted = async () => { + const completedIds = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + dispatch({ type: 'startAction', selectedTodo: completedIds }); + + const toDeleteTodo: number[] = []; + + try { + await Promise.all( + completedIds.map(async id => { + try { + await deleteTodo(id); + toDeleteTodo.push(id); + } catch (error) { + dispatch({ type: 'failure', errorMessage: ErrorMessage.delete }); + onAutoCloseNotification(dispatch); + } + }), + ); + + dispatch({ type: 'startAction', selectedTodo: toDeleteTodo }); + toDeleteTodo.forEach(() => { + dispatch({ type: 'deletingSuccesses' }); + }); + } catch { + dispatch({ type: 'failure', errorMessage: ErrorMessage.delete }); + onAutoCloseNotification(dispatch); + } finally { + dispatch({ type: 'reset' }); + } + }; + + return ( + + ); +}; diff --git a/src/components/TodoForm/TodoForm.tsx b/src/components/TodoForm/TodoForm.tsx new file mode 100644 index 000000000..5cca68da3 --- /dev/null +++ b/src/components/TodoForm/TodoForm.tsx @@ -0,0 +1,122 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import cn from 'classNames'; +import { DispatchContext, StateContext } from '../../Store'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import { onAutoCloseNotification } from '../../utils/autoCloseNotification'; +import { addTodo, toggleTodo, USER_ID } from '../../api/todos'; + +export const TodoForm: React.FC = () => { + const [isActive, setIsActive] = useState(false); + const [todoTitle, setTodoTitle] = useState(''); + + const titleField = useRef(null); + const { todos, isProcessing, tempTodo } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const handleNewTodoForm = (event: React.FormEvent) => { + event.preventDefault(); + + if (isProcessing) { + return; + } + + if (!todoTitle.trim()) { + dispatch({ type: 'failure', errorMessage: ErrorMessage.add }); + setTodoTitle(''); + onAutoCloseNotification(dispatch); + + return; + } + + const temp = { + title: todoTitle.trim(), + userId: USER_ID, + id: 0, + completed: false, + }; + + dispatch({ type: 'addTodo', tempTodo: temp }); + + addTodo(todoTitle.trim()) + .then(newTodo => { + dispatch({ type: 'addingSuccess', newTodo: newTodo as Todo }); + setTodoTitle(''); + }) + .catch(() => { + dispatch({ type: 'failure', errorMessage: ErrorMessage.add }); + onAutoCloseNotification(dispatch); + }) + .finally(() => { + dispatch({ type: 'reset' }); + }); + }; + + const handleToggleAll = async () => { + const allCompleted = todos.every(todo => todo.completed); + const completed = !allCompleted; + const updatedIds = todos + .filter(todo => todo.completed !== completed) + .map(todo => todo.id); + + dispatch({ type: 'startAction', selectedTodo: updatedIds }); + + try { + await Promise.all( + todos + .filter(todo => todo.completed !== completed) + .map(async todo => toggleTodo(todo.id, completed)), + ); + + dispatch({ type: 'toggleAllSuccesses', completed }); + } catch { + dispatch({ type: 'failure', errorMessage: ErrorMessage.update }); + } finally { + dispatch({ type: 'reset' }); + } + }; + + useEffect(() => { + const currentField = titleField.current; + + if (currentField !== null) { + currentField.focus(); + } + }, [titleField, todos, tempTodo]); + + useEffect(() => { + const isAllCompleted = todos.every(todo => todo.completed === true); + + if (isAllCompleted && todos.length > 0) { + setIsActive(true); + } else { + setIsActive(false); + } + }, [todos]); + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..77f0fb203 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,106 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import cn from 'classNames'; +import { Todo } from '../../types/Todo'; +import { useContext, useEffect, useRef } from 'react'; +import { StateContext } from '../../Store'; + +interface Props { + index: number; + todo: Todo; + isEditing: number | null; + newTitle: string; + onDelete: (id: number) => void; + onToggle: (todo: Todo, index: number) => void; + onStartEditing: (value: string, id: number) => void; + onNewTodoTitle: (value: string) => void; + onChangeTodoTitle: ( + event: React.FormEvent, + todo: Todo, + index: number, + ) => void; + onKey: (event: React.KeyboardEvent) => void; +} + +export const TodoItem: React.FC = ({ + index, + todo, + onDelete, + onToggle, + newTitle, + isEditing, + onStartEditing, + onNewTodoTitle, + onChangeTodoTitle, + onKey, +}) => { + const newTitleFiled = useRef(null); + const { selectedTodo } = useContext(StateContext); + + useEffect(() => { + const currentField = newTitleFiled.current; + + if (currentField !== null) { + currentField.focus(); + } + }, [newTitleFiled, isEditing]); + const { id, title, completed } = todo; + + return ( +
+ + {isEditing === id ? ( +
onChangeTodoTitle(event, todo, index)}> + onNewTodoTitle(event?.target.value)} + onBlur={event => onChangeTodoTitle(event, todo, index)} + onKeyUp={onKey} + ref={newTitleFiled} + /> +
+ ) : ( + <> + onStartEditing(title, id)} + > + {title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..bc3862cf0 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,171 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useContext, useMemo, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { DispatchContext, StateContext } from '../../Store'; +import { Status } from '../../types/Status'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import { onAutoCloseNotification } from '../../utils/autoCloseNotification'; +import { deleteTodo, editTodo, toggleTodo } from '../../api/todos'; + +export const TodoList: React.FC = () => { + const [isEditing, setIsEditing] = useState(null); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [isFailed, setIsFailed] = useState(false); + const { todos, status, tempTodo } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const handleStartEdit = (prevTitle: string, id: number) => { + if (isFailed) { + return; + } + + setIsEditing(id); + setNewTodoTitle(prevTitle); + }; + + const handleKey = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(null); + setNewTodoTitle(''); + } + }; + + const handleDeleteTodo = (id: number) => { + dispatch({ type: 'startAction', selectedTodo: [id] }); + + deleteTodo(id) + .then(() => { + dispatch({ type: 'deletingSuccesses' }); + }) + .catch(() => { + dispatch({ type: 'failure', errorMessage: ErrorMessage.delete }); + onAutoCloseNotification(dispatch); + }) + .finally(() => { + dispatch({ type: 'reset' }); + }); + }; + + const handleToggleTodo = async (todo: Todo, index: number) => { + const { completed, id } = todo; + + try { + dispatch({ type: 'startAction', selectedTodo: [id] }); + const updatedTodo = await toggleTodo(id, completed); + + dispatch({ + type: 'editTodoSuccess', + index, + updatedTodo: updatedTodo as Todo, + }); + } catch { + dispatch({ type: 'failure', errorMessage: ErrorMessage.update }); + } finally { + dispatch({ type: 'reset' }); + onAutoCloseNotification(dispatch); + } + }; + + const handleEditTitle = async ( + event: React.FormEvent, + todo: Todo, + index: number, + ) => { + event.preventDefault(); + + const { title, id } = todo; + + if (title === newTodoTitle) { + setIsEditing(null); + setNewTodoTitle(''); + + return; + } + + if (!newTodoTitle.trim()) { + handleDeleteTodo(id); + + return; + } + + try { + dispatch({ type: 'startAction', selectedTodo: [id] }); + + const updatedTodo = await editTodo(id, newTodoTitle.trim()); + + dispatch({ + type: 'editTodoSuccess', + index, + updatedTodo: updatedTodo as Todo, + }); + + setNewTodoTitle(''); + setIsEditing(null); + setIsFailed(false); + } catch { + dispatch({ type: 'failure', errorMessage: ErrorMessage.update }); + setIsFailed(true); + } finally { + dispatch({ type: 'reset' }); + } + }; + + const visibleTodos = useMemo(() => { + switch (status) { + case Status.all: + return todos; + case Status.active: + return todos.filter(todo => !todo.completed); + case Status.completed: + return todos.filter(todo => todo.completed); + default: + return todos; + } + }, [status, todos]); + + return ( +
+ {visibleTodos.map((todo, i) => ( + + ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..8520c0da4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,14 @@ 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 { GlobalStateProvider } from './Store'; -const container = document.getElementById('root') as HTMLDivElement; - -createRoot(container).render(); +createRoot(document.getElementById('root') as HTMLDivElement).render( + + + , +); diff --git a/src/styles/index.scss b/src/styles/index.scss index a34eec7c6..bccd80c8b 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,6 @@ body { pointer-events: none; } -@import './todoapp'; -@import './todo'; -@import './filter'; +@import "./todoapp"; +@import "./todo"; +@import "./filter"; diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..c7f93ff6b 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -15,13 +15,13 @@ &__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-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'); + 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 { @@ -92,8 +92,58 @@ .overlay { position: absolute; - inset: 0; + 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/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..ad28bcb2f 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,5 +1,6 @@ + .todoapp { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 300; color: #4d4d4d; @@ -8,8 +9,7 @@ &__content { margin-bottom: 20px; background: #fff; - box-shadow: - 0 2px 4px 0 rgba(0, 0, 0, 0.2), + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); } @@ -49,7 +49,7 @@ } &::before { - content: '❯'; + content: "❯"; transform: translateY(2px) rotate(90deg); line-height: 0; } @@ -69,7 +69,7 @@ border: none; background: rgba(0, 0, 0, 0.01); - box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); &::placeholder { font-style: italic; @@ -97,8 +97,7 @@ text-align: center; border-top: 1px solid #e6e6e6; - box-shadow: - 0 1px 1px rgba(0, 0, 0, 0.2), + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, @@ -122,6 +121,7 @@ appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: opacity 0.3s; &:hover { text-decoration: underline; @@ -130,5 +130,9 @@ &:active { text-decoration: none; } + + &:disabled { + visibility: hidden; + } } } diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 000000000..6c93852c4 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + load = 'Unable to load todos', + title = '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/Status.ts b/src/types/Status.ts new file mode 100644 index 000000000..66910400f --- /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..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/autoCloseNotification.ts b/src/utils/autoCloseNotification.ts new file mode 100644 index 000000000..8750dc234 --- /dev/null +++ b/src/utils/autoCloseNotification.ts @@ -0,0 +1,7 @@ +import { Action } from '../Store'; + +export const onAutoCloseNotification = (dispatch: (action: Action) => void) => { + setTimeout(() => { + dispatch({ type: 'closeNotification' }); + }, 3000); +}; 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'), +};