diff --git a/.eslintrc.json b/.eslintrc.json index f31dc8e..1fdbba3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,7 @@ { "extends": [ "plugin:react/recommended", - "plugin:react/jsx-runtime", + "plugin:@typescript-eslint/recommended", "plugin:jsx-a11y/recommended", "prettier" ], @@ -10,13 +10,17 @@ }, "parserOptions": { "ecmaVersion": "latest", - "sourceType": "module" + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } }, "settings": { "react": { "version": "detect" } }, "rules": { + "react/react-in-jsx-scope": "off", "no-duplicate-imports": "warn", - "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], - "react/jsx-filename-extension": [1, { "allow": "as-needed" }], + "no-unused-vars": "warn", + "react/jsx-filename-extension": [1, { "extensions": [".tsx", ".jsx"] }], "react/prop-types": "off", "react/jsx-no-target-blank": "off" } diff --git a/index.html b/index.html index 5016fc3..8b17180 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -13,7 +13,7 @@ Smart Shopping List - +
diff --git a/package-lock.json b/package-lock.json index dd7f84e..2b9e2c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.6", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.10.0", "@vitejs/plugin-react": "^4.3.1", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -4381,21 +4384,31 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz", - "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==", + "version": "18.3.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.6.tgz", + "integrity": "sha512-CnGaRYNu2iZlkGXGrOYtdg5mLK8neySj0woZ4e2wF/eli2E6Sazmq5X+Nrj6OBrrFVQfJWTUFeqAzoRhWQXYvg==", "license": "MIT", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.11", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", @@ -4419,6 +4432,511 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", + "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/type-utils": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", + "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", + "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.5.0", + "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/typescript-estree": "8.5.0", + "@typescript-eslint/visitor-keys": "8.5.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", + "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/visitor-keys": "8.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz", + "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", + "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", + "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", + "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/visitor-keys": "8.5.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz", + "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", + "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", + "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", + "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/types": "8.5.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -6307,6 +6825,36 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8088,6 +8636,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", @@ -10023,6 +10581,19 @@ "node": ">=18" } }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", diff --git a/package.json b/package.json index fe826c5..5eb9f89 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.6", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.10.0", "@vitejs/plugin-react": "^4.3.1", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", diff --git a/src/App.jsx b/src/App.tsx similarity index 95% rename from src/App.jsx rename to src/App.tsx index 05c9e6c..1b2df45 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { Home, Layout, List, ManageList } from './views'; @@ -19,7 +21,7 @@ export function App() { */ const [listPath, setListPath] = useStateWithStorage( 'tcl-shopping-list-path', - null, + '/', ); /** @@ -69,7 +71,7 @@ export function App() { /> } + element={} /> } /> diff --git a/src/api/config.js b/src/api/config.ts similarity index 52% rename from src/api/config.js rename to src/api/config.ts index 102f148..daabe82 100644 --- a/src/api/config.js +++ b/src/api/config.ts @@ -4,13 +4,13 @@ import { getAuth } from 'firebase/auth'; // Your web app's Firebase configuration const firebaseConfig = { - apiKey: "AIzaSyDmGxC-ITYFOyD6U1IrJAszf1KplXbG1Bs", - authDomain: "tcl-75-smart-shopping-list.firebaseapp.com", - projectId: "tcl-75-smart-shopping-list", - storageBucket: "tcl-75-smart-shopping-list.appspot.com", - messagingSenderId: "1081566633611", - appId: "1:1081566633611:web:b07badada6839daf2e3e8e" - }; + apiKey: 'AIzaSyDmGxC-ITYFOyD6U1IrJAszf1KplXbG1Bs', + authDomain: 'tcl-75-smart-shopping-list.firebaseapp.com', + projectId: 'tcl-75-smart-shopping-list', + storageBucket: 'tcl-75-smart-shopping-list.appspot.com', + messagingSenderId: '1081566633611', + appId: '1:1081566633611:web:b07badada6839daf2e3e8e', +}; // Initialize Firebase const app = initializeApp(firebaseConfig); diff --git a/src/api/firebase.js b/src/api/firebase.ts similarity index 66% rename from src/api/firebase.js rename to src/api/firebase.ts index f0584dd..abe5df0 100644 --- a/src/api/firebase.js +++ b/src/api/firebase.ts @@ -8,23 +8,27 @@ import { doc, onSnapshot, updateDoc, + DocumentData, deleteDoc, } from 'firebase/firestore'; import { useEffect, useState } from 'react'; import { db } from './config'; import { addDaysFromToday } from '../utils'; +export type ListPath = { + [key: string]: string | null; +}; + /** * A custom hook that subscribes to the user's shopping lists in our Firestore * database and returns new data whenever the lists change. - * @param {string | null} userId - * @param {string | null} userEmail - * @returns */ -export function useShoppingLists(userId, userEmail) { +export const useShoppingLists = ( + userId?: string | null, + userEmail?: string | null, +): ListPath[] => { // Start with an empty array for our data. - const initialState = []; - const [data, setData] = useState(initialState); + const [data, setData] = useState([]); useEffect(() => { // If we don't have a userId or userEmail (the user isn't signed in), @@ -36,7 +40,8 @@ export function useShoppingLists(userId, userEmail) { onSnapshot(userDocRef, (docSnap) => { if (docSnap.exists()) { - const listRefs = docSnap.data().sharedLists; + const listRefs: ListPath[] = docSnap.data().sharedLists; + const newData = listRefs.map((listRef) => { // We keep the list's id and path so we can use them later. return { name: listRef.id, path: listRef.path }; @@ -47,13 +52,12 @@ export function useShoppingLists(userId, userEmail) { }, [userId, userEmail]); return data; -} +}; /** * Add a new user to the users collection in Firestore. - * @param {Object} user The user object from Firebase Auth. */ -export async function addUserToDatabase(user) { +export const addUserToDatabase = async (user: DocumentData): Promise => { // Check if the user already exists in the database. const userDoc = await getDoc(doc(db, 'users', user.email)); // If the user already exists, we don't need to do anything. @@ -70,15 +74,16 @@ export async function addUserToDatabase(user) { uid: user.uid, }); } -} +}; /** * Create a new list and add it to a user's lists in Firestore. - * @param {string} userId The id of the user who owns the list. - * @param {string} userEmail The email of the user who owns the list. - * @param {string} listName The name of the new list. */ -export async function createList(userId, userEmail, listName) { +export const createList = async ( + userId: string, + userEmail: string, + listName: string, +): Promise => { const listDocRef = doc(db, userId, listName); await setDoc(listDocRef, { @@ -91,24 +96,28 @@ export async function createList(userId, userEmail, listName) { sharedLists: arrayUnion(listDocRef), }); return listDocRef.path; -} +}; /** * Shares a list with another user. - * @param {string} listPath The path to the list to share. - * @param {string} recipientEmail The email of the user to share the list with. */ -export async function shareList(listPath, currentUserId, recipientEmail) { +export const shareList = async ( + listPath: string, + currentUserId: string, + recipientEmail: string, +): Promise => { // Check if current user is owner. - if (!listPath.includes(currentUserId)) { - return '!owner'; + if (!listPath?.includes(currentUserId)) { + throw new Error('You cannot share the list you do not own.'); } // Get the document for the recipient user. const usersCollectionRef = collection(db, 'users'); const recipientDoc = await getDoc(doc(usersCollectionRef, recipientEmail)); // If the recipient user doesn't exist, we can't share the list. if (!recipientDoc.exists()) { - return; + throw new Error( + "The list was not shared because the recipient's email address does not exist in the system.", + ); } // Add the list to the recipient user's sharedLists array. const listDocumentRef = doc(db, listPath); @@ -117,11 +126,13 @@ export async function shareList(listPath, currentUserId, recipientEmail) { updateDoc(userDocumentRef, { sharedLists: arrayUnion(listDocumentRef), }); - return 'shared'; - } catch { - return; + return true; + } catch (error) { + throw new Error( + `List was not shared: ${error instanceof Error ? error.message : error}`, + ); } -} +}; /** * Delete a list from a user's lists in Firestore. @@ -154,39 +165,43 @@ export async function deleteList(deletionType, listPath, userId, userEmail) { /** * Add a new item to the user's list in Firestore. - * @param {string} listPath The path of the list we're adding to. - * @param {Object} itemData Information about the new item. - * @param {string} itemData.itemName The name of the item. - * @param {number} itemData.daysUntilNextPurchase The number of days until the user thinks they'll need to buy the item again. */ -export async function addItem(listPath, { itemName, daysUntilNextPurchase }) { +export const addItem = async ( + listPath: string, + { + itemName, + daysUntilNextPurchase, + }: { + itemName: string; + daysUntilNextPurchase: string; + }, +): Promise => { const listCollectionRef = collection(db, listPath, 'items'); return addDoc(listCollectionRef, { dateCreated: new Date(), dateLastPurchased: null, - dateNextPurchased: addDaysFromToday(daysUntilNextPurchase), + dateNextPurchased: addDaysFromToday(parseInt(daysUntilNextPurchase)), name: itemName, totalPurchases: 0, }); -} +}; /** * Update an item in the user's list in Firestore with new purchase information. - * @param {string} listPath The path of the list the item belongs to. - * @param {string} itemId The ID of the item being updated. - * @param {Object} updatedData Object containing the updated item data. - * @param {Date} updatedData.dateLastPurchased The date the item was last purchased. - * @param {Date} updatedData.dateNextPurchased The estimated date for the next purchase. - * @param {number} updatedData.totalPurchases The total number of times the item has been purchased. - * @returns {Promise} A message confirming the item was successfully updated. - * @throws {Error} If the item update fails. */ -export async function updateItem( - listPath, - itemId, - { dateLastPurchased, dateNextPurchased, totalPurchases }, -) { - // reference the item path +export const updateItem = async ( + listPath: string, + itemId: string, + { + dateLastPurchased, + dateNextPurchased, + totalPurchases, + }: { + dateLastPurchased: Date; + dateNextPurchased: Date; + totalPurchases: number; + }, +): Promise => { const itemDocRef = doc(db, listPath, 'items', itemId); // update the item with the purchase date and increment the total purchases made try { @@ -197,17 +212,24 @@ export async function updateItem( }); return 'item purchased'; } catch (error) { - throw new Error(`Failed updating item: ${error.message}`); + throw new Error( + `Failed updating item: ${error instanceof Error ? error.message : error}`, + ); } -} +}; -export async function deleteItem(listPath, itemId) { +/** + * Delete an item from the user's list in Firestore. + */ +export async function deleteItem(listPath: string, itemId: string) { // reference the item path const itemDocRef = doc(db, listPath, 'items', itemId); try { // delete the item from the database await deleteDoc(itemDocRef); } catch (error) { - throw new Error(`Failed updating item: ${error.message}`); + throw new Error( + `Failed updating item: ${error instanceof Error ? error.message : error}`, + ); } } diff --git a/src/api/index.js b/src/api/index.ts similarity index 100% rename from src/api/index.js rename to src/api/index.ts diff --git a/src/components/AddItems.jsx b/src/components/AddItems.jsx deleted file mode 100644 index 7b0d01a..0000000 --- a/src/components/AddItems.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback } from 'react'; -import { normalizeItemName } from '../utils'; -import { useStateWithStorage } from '../hooks'; -import { addItem } from '../api'; -import { RadioInputElement, TextInputElement } from './index.js'; -import { toast } from 'react-toastify'; - -const daysUntilPurchaseOptions = { - Soon: 7, - 'Kind of soon': 14, - 'Not soon': 30, -}; - -export function AddItems({ items }) { - const [listPath] = useStateWithStorage('tcl-shopping-list-path', null); - - const handleSubmit = useCallback( - async (event) => { - event.preventDefault(); - - const daysUntilNextPurchase = - event.target.elements['purchase-date'].value; - - const itemName = event.target.elements['item-name'].value; - - try { - if (itemName.trim() === '') { - toast.error('Please add an item name.'); - return; - } - // normalize the name by removing all punctuation and spaces to check if the normalized item is already in the list - const normalizedItemName = normalizeItemName(itemName); - if (items) { - // normalize the existing list items to compare them to the new input - const currentItems = items.map((item) => - normalizeItemName(item.name), - ); - if (currentItems.includes(normalizedItemName)) { - toast.error('This item already exists in the list'); - return; - } - } - await addItem(listPath, { - itemName, - daysUntilNextPurchase, - }); - toast.success( - `${itemName} was added to the list! The next purchase date is set to ${daysUntilNextPurchase} days from now.`, - ); - } catch (error) { - toast.error( - `Item was not added to the database, Error: ${error.message}`, - ); - } finally { - event.target.reset(); - } - }, - [listPath], - ); - - return ( -
-
- - - {Object.entries(daysUntilPurchaseOptions).map(([key, value]) => ( - - ))} - - -
- ); -} diff --git a/src/components/AddItems.tsx b/src/components/AddItems.tsx new file mode 100644 index 0000000..ba84930 --- /dev/null +++ b/src/components/AddItems.tsx @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; +import { useStateWithStorage } from '../hooks'; +import { addItem } from '../api'; +import { normalizeAndVerifyItem } from '../utils'; +import { DocumentData } from 'firebase/firestore'; +import { toast } from 'react-toastify'; +import { RadioInputElement, TextInputElement } from '../components'; + +const daysUntilPurchaseOptions = { + Soon: 7, + 'Kind of soon': 14, + 'Not soon': 30, +}; + +const addNormalizedItem = async ( + itemName: string, + daysUntilNextPurchase: string, + listPath: string, +) => { + try { + const isItemAdded = await addItem(listPath, { + itemName, + daysUntilNextPurchase, + }); + + if (isItemAdded) { + alert( + `${itemName} was added to the list! The next purchase date is set to ${daysUntilNextPurchase} days from now.`, + ); + } + } catch (error) { + alert( + `Item was not added to the database, Error: ${error instanceof Error ? error.message : error}`, + ); + } +}; + +export function AddItems({ items }: { items: DocumentData[] }) { + const [listPath] = useStateWithStorage('tcl-shopping-list-path', '/'); + + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + + const form = event.target as HTMLFormElement; + const itemName = ( + form.elements.namedItem('item-name') as HTMLInputElement + ).value; + const daysUntilNextPurchase = ( + form.elements.namedItem('purchase-date') as HTMLInputElement + ).value; + + try { + const normalizedItemName = normalizeAndVerifyItem(items, itemName); + addNormalizedItem(normalizedItemName, daysUntilNextPurchase, listPath); + } catch (error) { + toast.error(`Item was not added, Error: ${error}`); + } finally { + form.reset(); + } + }, + [listPath, items], + ); + + return ( +
+
+ + + {Object.entries(daysUntilPurchaseOptions).map(([key, value]) => ( + + ))} + + +
+ ); +} diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.tsx similarity index 78% rename from src/components/ConfirmDialog.jsx rename to src/components/ConfirmDialog.tsx index 69eb804..e05e89c 100644 --- a/src/components/ConfirmDialog.jsx +++ b/src/components/ConfirmDialog.tsx @@ -6,16 +6,21 @@ import { Dialog, DialogTitle, Button, + SlideProps, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import { buttonStyle } from './SingleList'; import './ConfirmDialog.css'; +import { ReactJSXElement } from '@emotion/react/types/jsx-namespace'; // MUI's Dialog already comes with built-in focus management and accessibility features. // It automatically traps focus inside the modal, moves focus to the modal when it opens, // and returns it to the previously focused element when it closes. -const Transition = React.forwardRef(function Transition(props, ref) { +const Transition = React.forwardRef(function Transition( + props: SlideProps, + ref, +) { return ; }); @@ -28,16 +33,29 @@ const typographyStyle = { color: 'white', }; -export function ConfirmDialog({ props }) { +export function ConfirmDialog({ + props, +}: { + props: { + handleDelete: () => void; + title: ReactJSXElement | string; + setOpen: (open: boolean) => void; + open: boolean; + }; +}) { const { handleDelete, title, setOpen, open } = props; - const handleClose = (event) => { + const handleClose = ( + event: React.MouseEvent, + ) => { setOpen(false); - event.target.type === 'submit' && handleDelete(); + if (event.currentTarget.type === 'submit') { + handleDelete(); + } }; const dialogProps = { - open: open, + open, TransitionComponent: Transition, onClose: handleClose, PaperProps: { @@ -102,7 +120,7 @@ export function ConfirmDialog({ props }) { type="submit" color="success" variant="outlined" - fontSize="large" + // fontSize="large" sx={buttonStyle} onClick={handleClose} > diff --git a/src/components/DeleteIconWithTooltip.jsx b/src/components/DeleteIconWithTooltip.tsx similarity index 74% rename from src/components/DeleteIconWithTooltip.jsx rename to src/components/DeleteIconWithTooltip.tsx index 1d46c02..65ef5be 100644 --- a/src/components/DeleteIconWithTooltip.jsx +++ b/src/components/DeleteIconWithTooltip.tsx @@ -1,3 +1,4 @@ +import { MouseEventHandler } from 'react'; import { DeleteOutlineOutlined } from '@mui/icons-material'; import { Tooltip, IconButton } from '@mui/material'; @@ -7,7 +8,13 @@ export const tooltipStyle = { marginBlockEnd: '0', }; -export const DeleteIconWithTooltip = ({ ariaLabel, toggleDialog }) => { +export const DeleteIconWithTooltip = ({ + ariaLabel, + toggleDialog, +}: { + ariaLabel: string; + toggleDialog: MouseEventHandler; +}) => { return ( Delete

} placement="right" arrow> diff --git a/src/components/ListItem.jsx b/src/components/ListItem.tsx similarity index 72% rename from src/components/ListItem.jsx rename to src/components/ListItem.tsx index c1220a0..1892bbb 100644 --- a/src/components/ListItem.jsx +++ b/src/components/ListItem.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; import { updateItem, deleteItem } from '../api'; import { calculateDateNextPurchased, ONE_DAY_IN_MILLISECONDS } from '../utils'; +import { DocumentData, Timestamp } from 'firebase/firestore'; import { toast } from 'react-toastify'; -import { useConfirmDialog } from '../hooks/useConfirmDialog'; -import { ConfirmDialog } from './ConfirmDialog'; -import { DeleteIconWithTooltip, tooltipStyle } from './DeleteIconWithTooltip'; +import { UrgencyStatus, useConfirmDialog } from '../hooks'; +import { DeleteIconWithTooltip, tooltipStyle, ConfirmDialog } from './index'; import { ListItem as MaterialListItem, Tooltip, @@ -13,6 +13,7 @@ import { ListItemIcon, ListItemText, Checkbox, + SvgIconTypeMap, } from '@mui/material'; import { Restore as OverdueIcon, @@ -21,16 +22,21 @@ import { RemoveCircle as NotSoonIcon, RadioButtonChecked as InactiveIcon, } from '@mui/icons-material'; +import { OverridableComponent } from '@mui/material/OverridableComponent'; import './ListItem.css'; const currentDate = new Date(); -const urgencyStatusIcons = { +type UrgencyStatusIconsType = { + [key in UrgencyStatus]: OverridableComponent; +}; + +const urgencyStatusIcons: UrgencyStatusIconsType = { overdue: OverdueIcon, soon: SoonIcon, - kindOfSoon: KindOfSoonIcon, - notSoon: NotSoonIcon, + 'kind of soon': KindOfSoonIcon, + 'not soon': NotSoonIcon, inactive: InactiveIcon, }; @@ -39,13 +45,7 @@ const urgencyStatusStyle = { color: 'white', }; -const toolTipStyle = { - fontSize: '1.5rem', - marginBlockStart: '0', - marginBlockEnd: '0', -}; - -const calculateIsPurchased = (dateLastPurchased) => { +const calculateIsPurchased = (dateLastPurchased: Timestamp) => { if (!dateLastPurchased) { return false; } @@ -57,7 +57,15 @@ const calculateIsPurchased = (dateLastPurchased) => { return currentDate < oneDayLater; }; -export function ListItem({ item, listPath, itemUrgencyStatus }) { +export function ListItem({ + item, + listPath, + itemUrgencyStatus, +}: { + item: DocumentData; + listPath: string; + itemUrgencyStatus: string; +}) { const { open, isOpen, toggleDialog } = useConfirmDialog(); const [isPurchased, setIsPurchased] = useState(() => calculateIsPurchased(item.dateLastPurchased), @@ -74,35 +82,39 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { const handleChange = async () => { setIsPurchased(!isPurchased); + if (!isPurchased) { try { const updatedItem = updateItemOnPurchase(); - await updateItem(listPath, id, { ...updatedItem }); } catch (error) { - toast.error(`Item was not marked as purchased`, error.message); + toast.error( + `Item was not marked as purchased. Error: ${error instanceof Error ? error.message : error}`, + ); } } }; const handleDeleteItem = async () => { - console.log('attempting item deletion'); try { await deleteItem(listPath, id); toast.success('Item deleted'); } catch (error) { - toast.error('Item was not deleted'); + toast.error( + `Item was not deleted. Error: ${error instanceof Error ? error.message : error}`, + ); } return; }; - const UrgencyStatusIcon = urgencyStatusIcons[itemUrgencyStatus]; + const UrgencyStatusIcon = + urgencyStatusIcons[itemUrgencyStatus as UrgencyStatus]; const props = { handleDelete: handleDeleteItem, title: `Are you sure you want to delete ${name}?`, setOpen: isOpen, - open: open, + open, }; const tooltipTitle = isPurchased @@ -115,7 +127,7 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { {UrgencyStatusIcon && ( {itemUrgencyStatus}

} + title={

{itemUrgencyStatus}

} placement="left" arrow > @@ -149,7 +161,7 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) {
diff --git a/src/components/RadioInputElement.jsx b/src/components/RadioInputElement.tsx similarity index 56% rename from src/components/RadioInputElement.jsx rename to src/components/RadioInputElement.tsx index 3a38645..8f84a52 100644 --- a/src/components/RadioInputElement.jsx +++ b/src/components/RadioInputElement.tsx @@ -1,4 +1,14 @@ -export const RadioInputElement = ({ label, id, value, required }) => { +export const RadioInputElement = ({ + label, + id, + value, + required, +}: { + label: string; + id: string; + value: number; + required: boolean; +}) => { return ( <> { - const listShared = await shareList(listPath, userId, emailData); + const shareCurrentList = async (emailData: string) => { + if (!userId || !listPath) return; + try { + const listShared = await shareList(listPath, userId, emailData); - if (listShared === '!owner') { - toast.error('You cannot share the list you do not own.'); - } else if (listShared === 'shared') { - toast.success('List was shared with recipient.'); - } else { + if (listShared) { + toast.success('List was shared with recipient.'); + } + } catch (error) { toast.error( - "The list was not shared because the recipient's email address does not exist in the system.", + error instanceof Error + ? error.message + : 'An error occurred while sharing the list.', ); } }; - const handleEmailInputSubmit = (event) => { + const handleEmailInputSubmit = (event: React.FormEvent) => { event.preventDefault(); - - const emailData = event.target['email-input'].value; + const form = event.target as HTMLFormElement; + const emailData = ( + form.elements.namedItem('email-input') as HTMLInputElement + ).value; if (userEmail === emailData) { toast.error('You cannot share the list with yourself.'); @@ -35,7 +40,7 @@ export function ShareList() { shareCurrentList(emailData); } - event.target.reset(); + form.reset(); }; return ( diff --git a/src/components/SingleList.jsx b/src/components/SingleList.tsx similarity index 84% rename from src/components/SingleList.jsx rename to src/components/SingleList.tsx index f8e8ae7..c468d3e 100644 --- a/src/components/SingleList.jsx +++ b/src/components/SingleList.tsx @@ -1,13 +1,12 @@ +import { useState, Dispatch } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useState } from 'react'; +import { deleteList } from '../api'; +import { DocumentData } from 'firebase/firestore'; +import { useAuth, useConfirmDialog } from '../hooks'; import { toast } from 'react-toastify'; import { PushPin, PushPinOutlined } from '@mui/icons-material'; import { Tooltip, IconButton, Button } from '@mui/material'; -import { deleteList } from '../api'; -import { useAuth } from '../hooks'; -import { useConfirmDialog } from '../hooks/useConfirmDialog'; -import { ConfirmDialog } from './ConfirmDialog'; -import { tooltipStyle, DeleteIconWithTooltip } from './DeleteIconWithTooltip'; +import { tooltipStyle, DeleteIconWithTooltip, ConfirmDialog } from './index'; import './SingleList.css'; const deletionResponse = { @@ -26,6 +25,11 @@ export function SingleList({ setListPath, setImportantList, isImportant, +}: { + item: DocumentData; + setListPath: Dispatch; + setImportantList: Dispatch; + isImportant: string; }) { const navigate = useNavigate(); const { user } = useAuth(); @@ -58,7 +62,7 @@ export function SingleList({ toast.success(deletionResponse[deletionType]); } catch (error) { toast.error( - `List was not deleted. Error: ${error.message ? error.message : error}`, + `List was not deleted. Error: ${error instanceof Error ? error.message : error}`, ); } diff --git a/src/components/TextInputElement.jsx b/src/components/TextInputElement.tsx similarity index 64% rename from src/components/TextInputElement.jsx rename to src/components/TextInputElement.tsx index f76414c..94b7ba4 100644 --- a/src/components/TextInputElement.jsx +++ b/src/components/TextInputElement.tsx @@ -1,3 +1,5 @@ +import { ChangeEventHandler } from 'react'; + export function TextInputElement({ label, type, @@ -5,6 +7,13 @@ export function TextInputElement({ placeholder, onChange, required, +}: { + label: string; + type: string; + id: string; + placeholder: string; + onChange?: ChangeEventHandler; + required: boolean; }) { return ( <> diff --git a/src/components/index.js b/src/components/index.ts similarity index 88% rename from src/components/index.js rename to src/components/index.ts index b1b83c6..8c3c8c0 100644 --- a/src/components/index.js +++ b/src/components/index.ts @@ -1,6 +1,7 @@ export * from './ListItem'; export * from './SingleList'; export * from './AddItems'; +export * from './ShareList'; export * from './TextInputElement'; export * from './RadioInputElement'; export * from './ConfirmDialog'; diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.tsx similarity index 89% rename from src/hooks/useAuth.jsx rename to src/hooks/useAuth.tsx index d8d7b51..e3f7aed 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { auth } from '../api/config.js'; import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; import { addUserToDatabase } from '../api/firebase.js'; +import { DocumentData } from 'firebase/firestore'; /** * A button that signs the user in using Google OAuth. When clicked, @@ -32,7 +33,8 @@ export const SignOutButton = () => ( * @see https://firebase.google.com/docs/auth/web/start#set_an_authentication_state_observer_and_get_user_data */ export const useAuth = () => { - const [user, setUser] = useState(null); + const initialState = null; + const [user, setUser] = useState(initialState); useEffect(() => { auth.onAuthStateChanged((user) => { diff --git a/src/hooks/useStateWithStorage.js b/src/hooks/useStateWithStorage.ts similarity index 54% rename from src/hooks/useStateWithStorage.js rename to src/hooks/useStateWithStorage.ts index 92a06ca..2e3730f 100644 --- a/src/hooks/useStateWithStorage.js +++ b/src/hooks/useStateWithStorage.ts @@ -2,16 +2,16 @@ import { useEffect, useState } from 'react'; /** * Set some state in React, and also persist that value in localStorage. - * @param {string} storageKey The key of the value in localStorage. - * @param {string | null} initialValue The initial value to store in localStorage and React state. - * @returns {[string | null, React.Dispatch]} */ -export function useStateWithStorage(storageKey, initialValue) { +export function useStateWithStorage( + storageKey: string, + initialValue: string, +): [string, React.Dispatch] { const [value, setValue] = useState( () => localStorage.getItem(storageKey) ?? initialValue, ); useEffect(() => { - if (value === null || value === undefined) { + if (value === undefined) { return localStorage.removeItem(storageKey); } return localStorage.setItem(storageKey, value); diff --git a/src/hooks/useUrgency.js b/src/hooks/useUrgency.js deleted file mode 100644 index aedfaea..0000000 --- a/src/hooks/useUrgency.js +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from 'react'; -import { calculateUrgency } from '../utils'; - -export function useUrgency(items) { - const [urgencyObject, setUrgencyObject] = useState({ - overdue: new Set(), - soon: new Set(), - kindOfSoon: new Set(), - notSoon: new Set(), - inactive: new Set(), - }); - - useEffect(() => { - if (items.length === 0) return; - - let initialUrgencyState = { - overdue: new Set(), - soon: new Set(), - kindOfSoon: new Set(), - notSoon: new Set(), - inactive: new Set(), - }; - - items.forEach((item) => { - const urgency = calculateUrgency(item); - initialUrgencyState[urgency].add(item); - }); - - const sortedUrgencyState = Object.fromEntries( - Object.entries(initialUrgencyState).map(([key, set]) => [ - key, - Array.from(set).sort((a, b) => a.name.localeCompare(b.name)), - ]), - ); - - setUrgencyObject(sortedUrgencyState); - }, [items]); - - return { urgencyObject }; -} diff --git a/src/hooks/useUrgency.ts b/src/hooks/useUrgency.ts new file mode 100644 index 0000000..1ed1f5d --- /dev/null +++ b/src/hooks/useUrgency.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { calculateUrgency } from '../utils'; +import { DocumentData } from 'firebase/firestore'; + +export type UrgencyStatus = + | 'overdue' + | 'soon' + | 'kind of soon' + | 'not soon' + | 'inactive'; + +type UrgencyObject = { + [_key in UrgencyStatus]: Set; +}; + +export function useUrgency(items: DocumentData[]) { + const [urgencyObject, setUrgencyObject] = useState({ + overdue: new Set(), + soon: new Set(), + 'kind of soon': new Set(), + 'not soon': new Set(), + inactive: new Set(), + }); + + useEffect(() => { + if (items.length === 0) return; + + const initialUrgencyState: UrgencyObject = { + overdue: new Set(), + soon: new Set(), + 'kind of soon': new Set(), + 'not soon': new Set(), + inactive: new Set(), + }; + + items.forEach((item) => { + const urgency = calculateUrgency(item) as UrgencyStatus; + initialUrgencyState[urgency].add(item); + }); + + const sortedUrgencyState: UrgencyObject = { + overdue: new Set( + Array.from(initialUrgencyState.overdue).sort((a, b) => + a.name.localeCompare(b.name), + ), + ), + soon: new Set( + Array.from(initialUrgencyState.soon).sort((a, b) => + a.name.localeCompare(b.name), + ), + ), + 'kind of soon': new Set( + Array.from(initialUrgencyState['kind of soon']).sort((a, b) => + a.name.localeCompare(b.name), + ), + ), + 'not soon': new Set( + Array.from(initialUrgencyState['not soon']).sort((a, b) => + a.name.localeCompare(b.name), + ), + ), + inactive: new Set( + Array.from(initialUrgencyState.inactive).sort((a, b) => + a.name.localeCompare(b.name), + ), + ), + }; + + setUrgencyObject(sortedUrgencyState); + }, [items]); + + return { urgencyObject }; +} diff --git a/src/index.jsx b/src/index.jsx deleted file mode 100644 index 551896b..0000000 --- a/src/index.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { App } from './App'; - -import './index.css'; - -const root = createRoot(document.getElementById('root')); -root.render( - - - , -); diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..8f7d396 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,19 @@ +import React, { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +import './index.css'; + +const rootElement = document.getElementById('root'); +try { + if (rootElement) { + const root = createRoot(rootElement); + root.render( + + + , + ); + } +} catch (error) { + throw new Error(`Could not get root element. Error: ${error}`); +} diff --git a/src/mocks/__fixtures__/auth.js b/src/mocks/__fixtures__/auth.ts similarity index 100% rename from src/mocks/__fixtures__/auth.js rename to src/mocks/__fixtures__/auth.ts diff --git a/src/mocks/__fixtures__/shoppingListData.js b/src/mocks/__fixtures__/shoppingListData.ts similarity index 100% rename from src/mocks/__fixtures__/shoppingListData.js rename to src/mocks/__fixtures__/shoppingListData.ts diff --git a/src/mocks/__fixtures__/shoppingLists.js b/src/mocks/__fixtures__/shoppingLists.ts similarity index 100% rename from src/mocks/__fixtures__/shoppingLists.js rename to src/mocks/__fixtures__/shoppingLists.ts diff --git a/src/utils/dates.js b/src/utils/dates.js deleted file mode 100644 index 6f78a6f..0000000 --- a/src/utils/dates.js +++ /dev/null @@ -1,127 +0,0 @@ -import { calculateEstimate } from '@the-collab-lab/shopping-list-utils'; - -export const ONE_DAY_IN_MILLISECONDS = 86400000; - -/** - * Get a new JavaScript Date that is `offset` days in the future. - * @example - * // Returns a Date 3 days in the future - * addDaysFromToday(3) - * @param {number} daysOffset - */ -export function addDaysFromToday(daysOffset) { - return new Date(Date.now() + daysOffset * ONE_DAY_IN_MILLISECONDS); -} - -/** - * Calculates the estimated date for the next purchase based on current date, purchase history, - * and total purchases. - * @param {Date} currentDate - The current date to calculate against. - * @param {Object} item - The item object containing purchase data. - * @param {Date} item.dateCreated - The date the item was created. - * @param {Date} item.dateNextPurchased - The previously estimated next purchase date. - * @param {Date|null} item.dateLastPurchased - The last date the item was actually purchased, or null if not purchased yet. - * @param {number} item.totalPurchases - The total number of purchases made for the item. - * @returns {Date} - The estimated date of the next purchase. - * @throws {Error} - Throws an error if the next purchase date cannot be calculated. - */ -export const calculateDateNextPurchased = (currentDate, item) => { - try { - // get purchase intervals and get new estimation for next purchase date - const purchaseIntervals = calculatePurchaseIntervals( - currentDate, - item.dateCreated, - item.dateNextPurchased, - item.dateLastPurchased, - ); - const estimatedNextPurchaseDate = getNextPurchaseEstimate( - purchaseIntervals, - item.totalPurchases + 1, - ); - - return estimatedNextPurchaseDate; - } catch (error) { - throw new Error(`Failed getting next purchase date: ${error}`); - } -}; - -/** - * Calculate the number of days between two dates. - * @param {Date} earlierDate The starting date. - * @param {Date} laterDate The ending date. - * @returns {number} The number of days between the two dates. - */ -export function getDaysBetweenDates(earlierDate, laterDate) { - return Math.floor( - (laterDate.getTime() - earlierDate.getTime()) / ONE_DAY_IN_MILLISECONDS, - ); -} - -// takes in a Date -// calculates days since date that was passed and today -export const getDaysFromDate = (pastDate) => - getDaysBetweenDates(pastDate, new Date()); - -// takes in Timestamps, returns a Date -export const getDateLastPurchasedOrDateCreated = ( - dateLastPurchased, - dateCreated, -) => dateLastPurchased?.toDate() ?? dateCreated.toDate(); - -/** - * Calculate the purchase intervals between current, next, and last purchase dates. - * @param {Date} currentDate The current date. - * @param {Date} dateNextPurchased The previously estimated next purchase date. - * @param {Date|null} dateLastPurchased The date the item was last purchased (can be null). - * @returns {Object} An object containing the last estimated interval and days since last purchase. - */ -function calculatePurchaseIntervals( - currentDate, - dateCreated, - dateNextPurchased, - dateLastPurchased, -) { - const lastEstimatedIntervalStartDate = getDateLastPurchasedOrDateCreated( - dateLastPurchased, - dateCreated, - ); - - const lastEstimatedInterval = getDaysBetweenDates( - lastEstimatedIntervalStartDate, - dateNextPurchased.toDate(), - ); - - const daysSinceLastPurchase = - dateLastPurchased?.toDate() === undefined - ? 0 - : getDaysBetweenDates(dateLastPurchased.toDate(), currentDate); - - return { lastEstimatedInterval, daysSinceLastPurchase }; -} - -/** - * Calculate the next purchase estimate based on purchase intervals and total purchases. - * @param {Object} purchaseIntervals The intervals between the previous and current purchases. - * @param {number} purchaseIntervals.lastEstimatedInterval The previously estimated number of days between purchases. - * @param {number} purchaseIntervals.daysSinceLastPurchase The number of days since the last purchase. - * @param {number} totalPurchases The total number of purchases made. - * @returns {Date} The estimated next purchase date. - * @throws {Error} If an error occurs during the next purchase estimation process. - */ -function getNextPurchaseEstimate(purchaseIntervals, totalPurchases) { - const { lastEstimatedInterval, daysSinceLastPurchase } = purchaseIntervals; - - try { - const estimatedDaysUntilPurchase = calculateEstimate( - lastEstimatedInterval, - daysSinceLastPurchase, - totalPurchases, - ); - - const nextPurchaseEstimate = addDaysFromToday(estimatedDaysUntilPurchase); - - return nextPurchaseEstimate; - } catch (error) { - throw new Error(`Failed updaing date next purchased: ${error}`); - } -} diff --git a/src/utils/dates.ts b/src/utils/dates.ts new file mode 100644 index 0000000..6877aa7 --- /dev/null +++ b/src/utils/dates.ts @@ -0,0 +1,104 @@ +import { calculateEstimate } from '@the-collab-lab/shopping-list-utils'; +import { DocumentData, Timestamp } from 'firebase/firestore'; + +export const ONE_DAY_IN_MILLISECONDS: number = 86400000; + +/** + * Get a new JavaScript Date that is `offset` days in the future. + */ +export function addDaysFromToday(daysOffset: number): Date { + return new Date(Date.now() + daysOffset * ONE_DAY_IN_MILLISECONDS); +} + +/** + * Calculates the estimated date for the next purchase based on current date, purchase history, + * and total purchases. + */ +export const calculateDateNextPurchased = ( + currentDate: Date, + item: DocumentData, +): Date => { + try { + // get purchase intervals and get new estimation for next purchase date + const purchaseIntervals = calculatePurchaseIntervals( + currentDate, + item.dateCreated, + item.dateNextPurchased, + item.dateLastPurchased, + ); + const estimatedNextPurchaseDate = getNextPurchaseEstimate( + purchaseIntervals, + item.totalPurchases + 1, + ); + + return estimatedNextPurchaseDate; + } catch (error) { + throw new Error(`Failed getting next purchase date: ${error}`); + } +}; + +/** + * Calculate the number of days between two dates. + */ +function getDaysBetweenDates(earlierDate: Date, laterDate: Date): number { + return Math.floor( + (laterDate.getTime() - earlierDate.getTime()) / ONE_DAY_IN_MILLISECONDS, + ); +} + +/** + * Calculate the purchase intervals between current, next, and last purchase dates. + */ +function calculatePurchaseIntervals( + currentDate: Date, + dateCreated: Timestamp, + dateNextPurchased: Timestamp, + dateLastPurchased: Timestamp | null, +): { + lastEstimatedInterval: number; + daysSinceLastPurchase: number; +} { + const lastPurchaseDate = dateLastPurchased?.toDate(); + + const lastEstimatedIntervalStartDate = + lastPurchaseDate ?? dateCreated.toDate(); + + const lastEstimatedInterval = getDaysBetweenDates( + lastEstimatedIntervalStartDate, + dateNextPurchased.toDate(), + ); + + const daysSinceLastPurchase = + lastPurchaseDate === undefined + ? 0 + : getDaysBetweenDates(lastPurchaseDate, currentDate); + + return { lastEstimatedInterval, daysSinceLastPurchase }; +} + +/** + * Calculate the next purchase estimate based on purchase intervals and total purchases. + */ +function getNextPurchaseEstimate( + purchaseIntervals: { + lastEstimatedInterval: number; + daysSinceLastPurchase: number; + }, + totalPurchases: number, +): Date { + const { lastEstimatedInterval, daysSinceLastPurchase } = purchaseIntervals; + + try { + const estimatedDaysUntilPurchase = calculateEstimate( + lastEstimatedInterval, + daysSinceLastPurchase, + totalPurchases, + ); + + const nextPurchaseEstimate = addDaysFromToday(estimatedDaysUntilPurchase); + + return nextPurchaseEstimate; + } catch (error) { + throw new Error(`Failed updaing date next purchased: ${error}`); + } +} diff --git a/src/utils/importanceUtils.js b/src/utils/importanceUtils.ts similarity index 100% rename from src/utils/importanceUtils.js rename to src/utils/importanceUtils.ts diff --git a/src/utils/index.js b/src/utils/index.ts similarity index 100% rename from src/utils/index.js rename to src/utils/index.ts diff --git a/src/utils/normalize.js b/src/utils/normalize.js deleted file mode 100644 index 621af77..0000000 --- a/src/utils/normalize.js +++ /dev/null @@ -1,6 +0,0 @@ -export function normalizeItemName(itemName) { - return itemName - .trim() - .toLowerCase() - .replace(/[&\/\\#, +$!,~%.'":*?<>{}]/g, ''); -} diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts new file mode 100644 index 0000000..aac1935 --- /dev/null +++ b/src/utils/normalize.ts @@ -0,0 +1,26 @@ +import { DocumentData } from 'firebase/firestore'; + +export function normalizeItemName(itemName: string): string { + return itemName + .trim() + .toLowerCase() + .replace(/[&\/\\#, +$!,~%.'":*?<>{}]/g, ''); +} + +export function normalizeAndVerifyItem( + items: DocumentData[], + itemName: string, +) { + const normalizedItemName = normalizeItemName(itemName); + + // normalize the existing list items to compare them to the new input + const currentItems = items.map((item) => { + return normalizeItemName(item.name); + }); + + if (currentItems.includes(normalizedItemName)) { + throw new Error('This item already exists in the list'); + } + + return normalizedItemName; +} diff --git a/src/utils/urgencyUtils.js b/src/utils/urgencyUtils.js index d1aede2..a198fb9 100644 --- a/src/utils/urgencyUtils.js +++ b/src/utils/urgencyUtils.js @@ -10,9 +10,9 @@ export const sortByUrgency = (item, daysUntilNextPurchase) => { } else if (daysUntilNextPurchase < 7) { return 'soon'; } else if (daysUntilNextPurchase >= 7 && daysUntilNextPurchase < 30) { - return 'kindOfSoon'; + return 'kind of soon'; } else if (daysUntilNextPurchase >= 30) { - return 'notSoon'; + return 'not soon'; } else { throw new Error(`Failed to place [${item.name}]`); } diff --git a/src/views/Home.jsx b/src/views/Home.tsx similarity index 76% rename from src/views/Home.jsx rename to src/views/Home.tsx index dc3219b..1d50b35 100644 --- a/src/views/Home.jsx +++ b/src/views/Home.tsx @@ -1,4 +1,6 @@ import { useNavigate } from 'react-router-dom'; +import { DocumentData } from 'firebase/firestore'; +import { Dispatch } from 'react'; import { createList } from '../api'; import { toast } from 'react-toastify'; import { useImportance } from '../hooks'; @@ -11,14 +13,29 @@ export const buttonStyle = { fontSize: '1.5rem', }; -export function Home({ data, setListPath, userId, userEmail }) { +export function Home({ + data, + setListPath, + userId, + userEmail, +}: { + data: DocumentData[]; + setListPath: Dispatch; + userId?: string | null; + userEmail?: string | null; +}) { const { sortedLists, setImportantList, isListImportant } = useImportance(data); const navigate = useNavigate(); - const handleSubmit = async (event) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - const listName = event.target['list-name'].value; + + if (!userId || !userEmail) return; + + const form = event.target as HTMLFormElement; + const listName = (form.elements.namedItem('list-name') as HTMLInputElement) + .value; const currentLists = sortedLists.map((list) => { return list.name.toLowerCase(); }); @@ -28,8 +45,8 @@ export function Home({ data, setListPath, userId, userEmail }) { toast.error('The list already exists. Please enter a different name.'); return; } - const listPath = await createList(userId, userEmail, listName); + setListPath(listPath); toast.success('List added'); navigate('/list'); @@ -37,7 +54,7 @@ export function Home({ data, setListPath, userId, userEmail }) { console.error(err); toast.error('List not created'); } finally { - event.target.reset(); + form.reset(); } }; diff --git a/src/views/Layout.jsx b/src/views/Layout.tsx similarity index 53% rename from src/views/Layout.jsx rename to src/views/Layout.tsx index ff58ad7..736945f 100644 --- a/src/views/Layout.jsx +++ b/src/views/Layout.tsx @@ -1,17 +1,19 @@ +import './Layout.css'; /* eslint-disable jsx-a11y/anchor-is-valid */ import { Outlet, NavLink } from 'react-router-dom'; import { useAuth, SignInButton, SignOutButton } from '../hooks/useAuth'; -// import { Home, List, ManageList } from '../views'; -import './Layout.css'; +const navLinkOptions: { [key: string]: string } = { + '/': 'Home', + '/list': 'List', + '/manage-list': 'Manage List', +}; -/** - * TODO: The links defined in this file don't work! - * - * Instead of anchor element, they should use a component - * from `react-router-dom` to navigate to the routes - * defined in `App.jsx`. - */ +const navLinks = Object.entries(navLinkOptions).map(([path, linkText]) => ( + + {linkText} + +)); export function Layout() { const { user } = useAuth(); @@ -26,15 +28,7 @@ export function Layout() { diff --git a/src/views/List.jsx b/src/views/List.tsx similarity index 61% rename from src/views/List.jsx rename to src/views/List.tsx index 4f5727a..302adb5 100644 --- a/src/views/List.jsx +++ b/src/views/List.tsx @@ -1,45 +1,47 @@ import React, { useState } from 'react'; +import { DocumentData } from 'firebase/firestore'; import { useEnsureListPath, useUrgency } from '../hooks'; import { getUrgency } from '../utils/urgencyUtils'; -import { List as UnorderedList, Box, Grid } from '@mui/material'; +import { List as UnorderedList, Box } from '@mui/material'; +import Grid from '@mui/material/Grid2'; import { ListItem, AddItems, TextInputElement } from '../components'; -// React.memo is needed to prevent unnecessary re-renders of the List component -// when the props (data and listPath) haven't changed, -// optimizing performance by avoiding re-computation of expensive -// operations like filtering and sorting items. - -export const List = React.memo(function List({ data, listPath }) { +export const List = React.memo(function List({ + items, + listPath, +}: { + items: DocumentData[]; + listPath: string; +}) { + const { urgencyObject } = useUrgency(items); const [searchItem, setSearchItem] = useState(''); - const { urgencyObject } = useUrgency(data); // Redirect to home if no list path is null if (useEnsureListPath()) return <>; - const listName = listPath.slice(listPath.indexOf('/') + 1); - - const sortedItems = Object.values(urgencyObject).flat(); + const listName = listPath?.slice(listPath.indexOf('/') + 1); - const handleTextChange = (event) => { + const handleTextChange = (event: React.ChangeEvent) => { setSearchItem(event.target.value); }; - const filteredItems = sortedItems.filter((item) => - item?.name?.toLowerCase().includes(searchItem.toLowerCase()), + const filteredItems = items.filter((item) => + item.name.toLowerCase().includes(searchItem.toLowerCase()), ); return ( <> - {!data?.length ? ( + {!items.length ? ( <>

Welcome to {listName}!

Ready to add your first item? Start adding below!

- + ) : ( <>

{listName}

+ - - + + - +
event.preventDefault()}> ; + return (
diff --git a/src/views/index.js b/src/views/index.ts similarity index 100% rename from src/views/index.js rename to src/views/index.ts diff --git a/vite.config.js b/vite.config.ts similarity index 100% rename from vite.config.js rename to vite.config.ts