diff --git a/inventory-manager/.gitignore b/inventory-manager/.gitignore index a547bf3..b02a1ff 100644 --- a/inventory-manager/.gitignore +++ b/inventory-manager/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +package-lock.json # Editor directories and files .vscode/* diff --git a/inventory-manager/package-lock.json b/inventory-manager/package-lock.json index 183f5ac..90284e7 100644 --- a/inventory-manager/package-lock.json +++ b/inventory-manager/package-lock.json @@ -8,6 +8,7 @@ "name": "inventory-manager", "version": "0.0.0", "dependencies": { + "@testing-library/user-event": "^14.6.1", "antd": "^5.27.1", "axios": "^1.11.0", "dayjs": "^1.11.18", @@ -27,6 +28,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "jsdom": "^26.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7", @@ -137,11 +139,31 @@ "react": ">=16.9.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -293,7 +315,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -428,6 +449,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1646,7 +1782,6 @@ "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1718,11 +1853,23 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, "license": "MIT", "peer": true }, @@ -2262,6 +2409,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2283,7 +2440,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -2382,7 +2538,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" @@ -2686,12 +2841,40 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -2716,6 +2899,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2746,7 +2936,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2756,7 +2945,6 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, "license": "MIT", "peer": true }, @@ -2781,6 +2969,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3422,6 +3623,60 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3502,6 +3757,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3513,7 +3775,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3529,6 +3790,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3653,7 +3954,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", "peer": true, "bin": { @@ -3787,6 +4087,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3850,6 +4157,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3891,7 +4211,6 @@ "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": { @@ -3950,7 +4269,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3966,7 +4284,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -4650,7 +4967,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, @@ -4785,6 +5101,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4809,6 +5132,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4965,6 +5308,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -5066,6 +5416,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5085,6 +5455,32 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5412,6 +5808,66 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5455,6 +5911,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/inventory-manager/package.json b/inventory-manager/package.json index 3fa6ac2..0c46d57 100644 --- a/inventory-manager/package.json +++ b/inventory-manager/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest", "preview": "vite preview" }, "dependencies": { @@ -21,6 +22,7 @@ "@eslint/js": "^9.36.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", @@ -29,6 +31,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "jsdom": "^26.1.0", "typescript": "~5.9.3", "typescript-eslint": "^8.45.0", "vite": "^7.1.7", diff --git a/inventory-manager/src/components/page-content/EncoraContent.tsx b/inventory-manager/src/components/page-content/EncoraContent.tsx index 9e38c8a..30ecf3d 100644 --- a/inventory-manager/src/components/page-content/EncoraContent.tsx +++ b/inventory-manager/src/components/page-content/EncoraContent.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {Col, Layout} from 'antd'; import NewProductButton from "./segment2-new_product/NewProductButton"; -import InventoryTableObj from "./segment3-table/segment/InventoryTableObj"; +import InventoryTableObj from "./segment3-table/InventoryTableObj"; import {SearchProvider} from "../../context/SearchContext"; import InventoryMetricsTable from "./segment4-metrics/InventoryMetricsTable"; import {DataProvider} from "../../context/DataContext"; diff --git a/inventory-manager/src/components/page-content/segment1-search_product/input-bars/InputSearch.tsx b/inventory-manager/src/components/page-content/segment1-search_product/input-bars/InputSearch.tsx index b50783f..8a008bc 100644 --- a/inventory-manager/src/components/page-content/segment1-search_product/input-bars/InputSearch.tsx +++ b/inventory-manager/src/components/page-content/segment1-search_product/input-bars/InputSearch.tsx @@ -8,18 +8,21 @@ interface Props { } const InputSearch: React.FC = ({parameter}) => { - - const {setParams} = useSearchContext(); + const {name, category, setParams} = useSearchContext(); + const timerRef = React.useRef(null); function onChange(e: React.ChangeEvent) { const next = e.target.value; - if (!e.target.value) { - if (parameter === 'name') setParams({name: null}); - if (parameter === 'category') setParams({category: null}); - } else { - if (parameter === 'name') setParams({name: next}); - if (parameter === 'category') setParams({category: next}); - } + if (timerRef.current) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + if (!e.target.value) { + if (parameter === 'name') setParams({name: null}); + if (parameter === 'category') setParams({category: null}); + } else { + if (parameter === 'name' && next !== name) setParams({name: next}); + if (parameter === 'category' && next !== category) setParams({category: next}); + } + }, 400); } const getPlaceholder = (): string => { @@ -34,6 +37,6 @@ const InputSearch: React.FC = ({parameter}) => { onChange={onChange} /> ); -} +}; export default InputSearch; \ No newline at end of file diff --git a/inventory-manager/src/components/page-content/segment2-new_product/NewProductButton.test.tsx b/inventory-manager/src/components/page-content/segment2-new_product/NewProductButton.test.tsx index 9057d91..614d378 100644 --- a/inventory-manager/src/components/page-content/segment2-new_product/NewProductButton.test.tsx +++ b/inventory-manager/src/components/page-content/segment2-new_product/NewProductButton.test.tsx @@ -1,25 +1,72 @@ -global.matchMedia = global.matchMedia || function() { - return { - matches: false, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - }; -}; +/* @vitest-environment jsdom */ +/// +import "@testing-library/jest-dom/vitest"; +import {describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach} from "vitest"; +import {cleanup, render} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import NewProductButton from "./NewProductButton"; -import React from 'react'; -import {render, screen, fireEvent} from '@testing-library/react'; -import NewProductButton from './NewProductButton'; +const __realGetComputedStyle = window.getComputedStyle; +const __realMatchMedia = window.matchMedia as any; -describe('NewProductButton', () => { - test('renders the button and opens the modal on click', () => { - render(); - const addButton = screen.getByText('Add new product'); - expect(addButton).toBeInTheDocument(); - fireEvent.click(addButton); - expect(screen.getByText('Add new product to inventory')).toBeInTheDocument(); +function renderWithUser() { + const user = userEvent.setup() + const view = render(); + return {user, ...view}; +} + + +describe("NewProductButton", () => { + beforeAll(() => { + window.getComputedStyle = (elt: Element, _pseudo?: string | null) => { + const style = __realGetComputedStyle(elt); + const gpv = (prop: string) => { + if (typeof (style as any).getPropertyValue === "function") { + return (style as any).getPropertyValue(prop); + } + if ( + prop === "animation-duration" || + prop === "animation-delay" || + prop === "transition-duration" || + prop === "transition-delay" + ) { + return "0s"; + } + return ""; + }; + return {...style, getPropertyValue: gpv} as CSSStyleDeclaration; + }; + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + }); + afterAll(() => { + window.getComputedStyle = __realGetComputedStyle; + window.matchMedia = __realMatchMedia; + }); + beforeEach(() => { + vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.resetAllMocks(); + }); + + it("renders the button and opens the modal on click", async () => { + const {getByRole, getByText} = renderWithUser(); + const addButton = getByRole("button", { name: /add new product/i }); + expect(addButton).toBeInTheDocument(); + await userEvent.click(addButton); + + expect(getByText(/add new product to inventory/i)).toBeInTheDocument(); + }); }); diff --git a/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.test.tsx b/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.test.tsx index 266eb32..d691d29 100644 --- a/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.test.tsx +++ b/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.test.tsx @@ -1,50 +1,123 @@ -global.matchMedia = global.matchMedia || function () { - return { - matches: false, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - }; -}; +/* @vitest-environment jsdom */ +/// +import "@testing-library/jest-dom/vitest"; +import {describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach} from "vitest"; +import {render, waitFor, cleanup} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import {createProduct, updateProduct} from "../../../services/Requests"; +import ProductForm from "./ProductForm"; +import type {Product} from "../../../types/Product.ts"; -import React from 'react'; -import {render, screen, waitFor} from '@testing-library/react'; -import ProductForm from './ProductForm'; -import {useSearchContext} from '../../context/SearchContext'; -import {useProductsData} from '../../context/DataContext'; -import {createProduct} from '../../services/Requests'; -import userEvent from '@testing-library/user-event'; +const __realGetComputedStyle = window.getComputedStyle; +const __realMatchMedia = window.matchMedia as any; -jest.mock('../../context/SearchContext'); -jest.mock('../../context/DataContext'); -jest.mock('../../services/Requests'); +vi.mock("../../../services/Requests", () => ({ + createProduct: vi.fn(), + updateProduct: vi.fn() +})); -describe('ProductForm', () => { - beforeEach(() => { - (useSearchContext as jest.Mock).mockReturnValue({ - stockQuantity: 0, - setParams: jest.fn(), - }); - (useProductsData as jest.Mock).mockReturnValue({ - categories: ['Fruit', 'Vegetables'], +const productsCreate: Product = { + id: "p1", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") +}; +const productsEdit: Product = { + id: "p1", + name: "Coke Light", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") +}; + +function renderWithUsers(mode: "create" | "edit" | undefined, initialValues?: Product) { + const onClose = vi.fn(); + const user = userEvent.setup() + const view = render( + + ); + return {user, ...view}; +} + +describe("ProductForm", () => { + beforeAll(() => { + window.getComputedStyle = (elt: Element, _pseudo?: string | null) => { + const style = __realGetComputedStyle(elt); + const gpv = (prop: string) => { + if (typeof (style as any).getPropertyValue === "function") { + return (style as any).getPropertyValue(prop); + } + if ( + prop === "animation-duration" || + prop === "animation-delay" || + prop === "transition-duration" || + prop === "transition-delay" + ) { + return "0s"; + } + return ""; + }; + return {...style, getPropertyValue: gpv} as CSSStyleDeclaration; + }; + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), }); }); + afterAll(() => { + window.getComputedStyle = __realGetComputedStyle; + window.matchMedia = __realMatchMedia; + }); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createProduct).mockResolvedValue({} as any); + }); + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it("submits form in create mode", async () => { + vi.mocked(createProduct).mockResolvedValueOnce(productsCreate); + const {getAllByText, getByLabelText, getByText} = renderWithUsers("create"); + + await userEvent.type(getByLabelText(/name/i), "Coke"); + await userEvent.click(getByLabelText(/category/i)); + await userEvent.type(getByLabelText(/category/i), "Beverage"); + const options = getAllByText("Beverage"); + await userEvent.click(options[options.length - 1]); + await userEvent.type(getByLabelText(/stock/i), "12"); + await userEvent.type(getByLabelText(/unit price/i), "1.5"); + await userEvent.click(getByText("Save")); - test('submits form in create mode', async () => { - const onClose = jest.fn(); - (createProduct as jest.Mock).mockResolvedValue({}); - render(); - await userEvent.type(screen.getByLabelText(/Name/i), 'Apple'); - await userEvent.click(screen.getByLabelText(/Category/i)); - const options = screen.getAllByText('Fruit'); - await userEvent.click(options[1]); - await userEvent.type(screen.getByLabelText(/Stock/i), '10'); - await userEvent.type(screen.getByLabelText(/Unit Price/i), '1'); - await userEvent.click(screen.getByText('Save')); await waitFor(() => { expect(createProduct).toHaveBeenCalled(); }); }); + + it("submits form in edit mode", async () => { + vi.mocked(updateProduct).mockResolvedValueOnce(productsEdit); + const {getByLabelText, getByText} = renderWithUsers("edit", productsEdit); + + await userEvent.type(getByLabelText(/name/i), "Coke Light"); + await userEvent.type(getByLabelText(/unit price/i), "1.55"); + await userEvent.click(getByText("Save")); + + await waitFor(() => { + expect(updateProduct).toHaveBeenCalled(); + }); + }); + }); diff --git a/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.tsx b/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.tsx index f6435f4..f5c0c69 100644 --- a/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.tsx +++ b/inventory-manager/src/components/page-content/segment2-new_product/ProductForm.tsx @@ -4,7 +4,7 @@ import type {Product} from "../../../types/Product"; import {createProduct, updateProduct} from "../../../services/Requests"; import {useSearchContext} from "../../../context/SearchContext"; import dayjs from "dayjs"; -import {useProductsData} from "../../../context/DataContext"; +import {useDataContext} from "../../../context/DataContext"; interface ProductFormProps { @@ -17,7 +17,7 @@ interface ProductFormProps { const ProductForm: React.FC = ({initialValues, mode = "create", onClose}) => { const [form] = Form.useForm(); const {stockQuantity, setParams} = useSearchContext(); - const {categories} = useProductsData(); + const {categories} = useDataContext(); const handleSave = async (values: Product) => { diff --git a/inventory-manager/src/components/page-content/segment3-table/InventoryTable.test.tsx b/inventory-manager/src/components/page-content/segment3-table/InventoryTable.test.tsx deleted file mode 100644 index 8ce71d7..0000000 --- a/inventory-manager/src/components/page-content/segment3-table/InventoryTable.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import {deleteProduct, markOutOfStock} from "../../../services/Requests"; - -global.matchMedia = global.matchMedia || function() { - return { - matches: false, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - }; -}; - - -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import InventoryTable from './InventoryTable'; -import { useProductsData } from '../../context/DataContext'; -import { useSearchContext } from '../../context/SearchContext'; -import {waitFor} from "@testing-library/dom"; - -jest.mock('../../context/DataContext'); -jest.mock('../../context/SearchContext'); -jest.mock('../../services/Requests'); - -beforeAll(() => { - jest.spyOn(console, 'error').mockImplementation((...args) => { - if ( - typeof args[0] === 'string' && - args[0].includes('Columns should all contain `filteredValue`') - ) { - return; - } - console.error(...args); - }); -}); - -beforeEach(() => { - (useProductsData as jest.Mock).mockReturnValue({ - products: [{ id: 1, category: 'Fruit', name: 'Apple', unitPrice: 1, expirationDate: '2023-06-22', stockQuantity: 10 }], - loading: false, - categories: ['Fruit', 'Vegetables'], - error: null, - total: 1, - summary: [], - refreshProducts: jest.fn(), - }); - (useSearchContext as jest.Mock).mockReturnValue({ - stockQuantity: 0, - page: 1, - setParams: jest.fn(), - }); -}); - -describe('InventoryTable', () => { - it('renders the table with products', async () => { - (useProductsData as jest.Mock).mockReturnValue({ - products: [ - { id: 1, category: 'Fruit', name: 'Apple', unitPrice: 1, expirationDate: '2023-06-22', stockQuantity: 10 }, - ], - loading: false, - }); - (useSearchContext as jest.Mock).mockReturnValue({ - stockQuantity: 0, - page: 1, - setParams: jest.fn(), - }); - - render(); - - expect(screen.getByText('Apple')).toBeInTheDocument(); - expect(screen.getByText('Name')).toBeInTheDocument(); - expect(screen.getByText('Fruit')).toBeInTheDocument(); - expect(screen.getByText('Category')).toBeInTheDocument(); - expect(screen.getByText('10')).toBeInTheDocument(); - expect(screen.getByText('Stock')).toBeInTheDocument(); - }); - it('opens the edit modal when Edit is clicked', async () => { - (useProductsData as jest.Mock).mockReturnValue({ - products: [ - { id: 1, category: 'Fruit', name: 'Apple', unitPrice: 1, expirationDate: '2023-06-22', stockQuantity: 10 }, - ], - loading: false, - categories: ['Fruit', 'Vegetables'], - error: null, - total: 1, - summary: [], - refreshProducts: jest.fn(), - }); - (useSearchContext as jest.Mock).mockReturnValue({ - stockQuantity: 0, - page: 1, - setParams: jest.fn(), - }); - - render(); - - const editButton = screen.getByText('Edit'); - fireEvent.click(editButton); - - expect(screen.getByText('Edit Product')).toBeInTheDocument(); - }); - test('should call deleteProduct when Delete is clicked', async () => { - (deleteProduct as jest.Mock).mockResolvedValueOnce({}); - - render(); - - const deleteLink = screen.getByText('Delete'); - fireEvent.click(deleteLink); - - const confirmButton = screen.getByText('Yes'); - fireEvent.click(confirmButton); - - await waitFor(() => { - expect(deleteProduct).toHaveBeenCalledWith(1); - }); - }); - test('should call markOutOfStock when clicking "Change availability"', async () => { - (useProductsData as jest.Mock).mockReturnValueOnce({ - products: [{ id: 1, category: 'Fruit', name: 'Apple', unitPrice: 1, expirationDate: '2023-06-22', stockQuantity: 10 }], - loading: false, - categories: ['Fruit', 'Vegetables'], - error: null, - total: 1, - summary: [], - refreshProducts: jest.fn(), - }); - render(); - - const checkbox = screen.getAllByRole('checkbox'); - fireEvent.click(checkbox[1]); - - const changeButton = screen.getByText('Change availability'); - fireEvent.click(changeButton); - - await waitFor(() => { - expect(markOutOfStock).toHaveBeenCalledWith(1); - }); - }); -}); diff --git a/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTableObj.tsx b/inventory-manager/src/components/page-content/segment3-table/InventoryTableObj.tsx similarity index 69% rename from inventory-manager/src/components/page-content/segment3-table/segment/InventoryTableObj.tsx rename to inventory-manager/src/components/page-content/segment3-table/InventoryTableObj.tsx index 6a47253..633ba9e 100644 --- a/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTableObj.tsx +++ b/inventory-manager/src/components/page-content/segment3-table/InventoryTableObj.tsx @@ -1,5 +1,5 @@ -import InventoryTable from "../InventoryTable"; -import InventoryTablePageSelector from "../InventoryTablePageSelector"; +import InventoryTable from "./segment/InventoryTable"; +import InventoryTablePageSelector from "./segment/InventoryTablePageSelector"; import React from "react"; import {Content} from "antd/es/layout/layout"; diff --git a/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTable.test.tsx b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTable.test.tsx new file mode 100644 index 0000000..c4ad053 --- /dev/null +++ b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTable.test.tsx @@ -0,0 +1,239 @@ +/* @vitest-environment jsdom */ +/// +import "@testing-library/jest-dom/vitest"; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import {type ByRoleMatcher, type ByRoleOptions, cleanup, render, waitFor, within} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import {SearchProvider} from "../../../../context/SearchContext.tsx"; +import {DataProvider} from "../../../../context/DataContext.tsx"; +import InventoryTable from "./InventoryTable.tsx"; +import {deleteProduct, getFilteredProducts} from "../../../../services/Requests"; +import type {Product} from "../../../../types/Product.ts"; +import type {AxiosResponse} from "axios"; + +const __realGetComputedStyle = window.getComputedStyle; +const __realMatchMedia = window.matchMedia as any; +vi.mock("../../../../services/Requests", () => ({ + getFilteredProducts: vi.fn(), + getCategories: vi.fn(), + getSummary: vi.fn(), + deleteProduct: vi.fn(), +})); + +const productsPage1: Product[] = [ + { + id: "p1", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p2", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, +]; +const productsAfterSort: Product[] = [ + { + id: "p2", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p1", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + } +]; +const productsAfterDelete: Product[] = [ + { + id: "p2", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, +]; + +function getFirstColumnTexts( + getByRole: ( + role: ByRoleMatcher, + options?: (ByRoleOptions | undefined) + ) => HTMLElement): string[] { + const table = getByRole("table"); + const tbody = within(table).getAllByRole("rowgroup")[1]; // [0]=thead, [1]=tbody + const rows = within(tbody).getAllByRole("row"); + return rows.map((row) => { + const cells = within(row).getAllByRole("cell"); + return cells[2].textContent?.trim() || ""; + }); +} + +function renderWithProviders() { + const user = userEvent.setup() + const view = render( + + + + + ); + return {user, ...view}; +} + +describe("InventoryTable tests", () => { + beforeAll(() => { + window.getComputedStyle = (elt: Element, _pseudo?: string | null) => { + const style = __realGetComputedStyle(elt); + const gpv = (prop: string) => { + if (typeof (style as any).getPropertyValue === "function") { + return (style as any).getPropertyValue(prop); + } + if ( + prop === "animation-duration" || + prop === "animation-delay" || + prop === "transition-duration" || + prop === "transition-delay" + ) { + return "0s"; + } + return ""; + }; + return {...style, getPropertyValue: gpv} as CSSStyleDeclaration; + }; + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + }); + afterAll(() => { + window.getComputedStyle = __realGetComputedStyle; + window.matchMedia = __realMatchMedia; + }); + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.resetAllMocks(); + }); + + it("renders rows with data after start", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + const {getByRole, getByText} = renderWithProviders(); + + await waitFor(() => { + expect(getByRole("table")).toBeInTheDocument(); + expect(getByText("Coke")).toBeVisible(); + expect(getByText("Apple")).toBeVisible(); + }); + + expect(getFilteredProducts).toHaveBeenCalledTimes(1); + }); + + it("sorts by Name column (asc/desc) when header is clicked", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsAfterSort, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + + const {getByRole, findByText} = renderWithProviders(); + await findByText("Coke"); + await findByText("Apple"); + const nameHeader = getByRole("columnheader", {name: /name/i}); + + await userEvent.click(nameHeader); + await findByText("Coke"); + await waitFor(() => { + expect(getFirstColumnTexts(getByRole)).toEqual(["Apple", "Coke"]); + }); + + await userEvent.click(nameHeader); + await findByText("Coke"); + await waitFor(() => { + expect(getFirstColumnTexts(getByRole)).toEqual(["Coke", "Apple"]); + }); + }); + + it("deletes a row after confirm and the table reflects the removal", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + vi.mocked(deleteProduct("p1")); + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsAfterDelete, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + const { + getByRole, + findByText, + queryByText, + getAllByRole, + getByText + } = renderWithProviders(); + await findByText("Coke"); + await findByText("Apple"); + + const deleteButton = getAllByRole("button", {name: /delete/i})[0]; + await userEvent.click(deleteButton); + + const confirmBtn = + getByRole("button", {name: /^yes$/i}); + await userEvent.click(confirmBtn!); + await findByText("Apple"); + + await waitFor(() => { + expect(deleteProduct).toHaveBeenCalledTimes(2); + expect(deleteProduct).toHaveBeenCalledWith("p1"); + }); + + await waitFor(() => { + expect(queryByText("Coke")).not.toBeInTheDocument(); + expect(getByText("Apple")).toBeVisible(); + }); + + expect(getFilteredProducts).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/inventory-manager/src/components/page-content/segment3-table/InventoryTable.tsx b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTable.tsx similarity index 87% rename from inventory-manager/src/components/page-content/segment3-table/InventoryTable.tsx rename to inventory-manager/src/components/page-content/segment3-table/segment/InventoryTable.tsx index 0b1e457..5de427e 100644 --- a/inventory-manager/src/components/page-content/segment3-table/InventoryTable.tsx +++ b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTable.tsx @@ -1,12 +1,12 @@ import React, {type MouseEventHandler, useRef, useState} from 'react'; import {Button, Input, type InputRef, Modal, Popconfirm, Space, Table, type TableColumnType} from 'antd'; import type {TableColumnsType, TableProps} from 'antd'; -import type {Product} from "../../../types/Product"; +import type {Product} from "../../../../types/Product"; import {SearchOutlined} from '@ant-design/icons'; -import {useSearchContext} from "../../../context/SearchContext"; -import {useProductsData} from "../../../context/DataContext"; -import {deleteProduct, markInStock, markOutOfStock} from "../../../services/Requests"; -import ProductForm from "../segment2-new_product/ProductForm"; +import {useSearchContext} from "../../../../context/SearchContext"; +import {useDataContext} from "../../../../context/DataContext"; +import {deleteProduct, markInStock, markOutOfStock} from "../../../../services/Requests"; +import ProductForm from "../../segment2-new_product/ProductForm"; type DataIndex = keyof Product; const InventoryTable: React.FC = () => { @@ -19,21 +19,28 @@ const InventoryTable: React.FC = () => { setEditingProduct(null); } - const {products, loading} = useProductsData(); const searchInput = useRef(null); - const {stockQuantity, page, setParams} = useSearchContext(); - + const {stockQuantity, name, category, page, setParams} = useSearchContext(); + const {products, loading} = useDataContext() const handleTableChange: TableProps['onChange'] = (_pagination, filters, sorter) => { setParams({page: ((page as number))}); - if ((filters.name !== undefined) && ((filters.name as unknown as string) !== '') && (filters.name !== null)) { + if ((filters.name !== undefined) && + ((filters.name as unknown as string) !== '') && + (filters.name !== null) && + (filters.name as unknown as string !== name)) { setParams({name: filters.name?.[0] as unknown as string}); } - if (filters.category !== undefined && (filters.category as unknown as string) !== '' && filters.category !== null) { + if (filters.category !== undefined && + (filters.category as unknown as string) !== '' && + filters.category !== null && + (filters.category as unknown as string !== category)) { setParams({category: filters.category?.[0] as unknown as string}); } - if (filters.stockQuantity?.[0] && filters.stockQuantity?.[0] !== null) { + if (filters.stockQuantity?.[0] && + filters.stockQuantity?.[0] !== null && + filters.stockQuantity?.[0] as unknown as number !== stockQuantity) { setParams({stockQuantity: filters.stockQuantity?.[0] as unknown as number}); } const sortObj = Array.isArray(sorter) ? sorter : [sorter]; @@ -152,27 +159,27 @@ const InventoryTable: React.FC = () => { title: 'Category', dataIndex: 'category', sorter: {multiple: 3}, - filteredValue: undefined, + filteredValue: category ? [String(category)] : null, ...getColumnSearchProps("category") }, { title: 'Name', dataIndex: 'name', sorter: {multiple: 3}, - filteredValue: undefined, + filteredValue: name ? [String(name)] : null, ...getColumnSearchProps("name") }, { title: 'Price', dataIndex: 'unitPrice', sorter: {multiple: 3}, - filteredValue: undefined + filteredValue: null }, { title: 'Expiration Date', dataIndex: 'expirationDate', sorter: {multiple: 3}, - filteredValue: undefined, + filteredValue: null, render: (_) => _ ? new Date(_).toLocaleDateString() : 'No date', }, { @@ -186,7 +193,7 @@ const InventoryTable: React.FC = () => { {text: 'No stock', value: '2'} ], filterMultiple: false, - filteredValue: stockQuantity !== undefined ? [String(stockQuantity)] : null, + filteredValue: stockQuantity ? ['1', '2'].includes(String(stockQuantity)) ? [String(stockQuantity)] : null: null, onFilter: (_value, _record) => { return true; }, @@ -195,7 +202,7 @@ const InventoryTable: React.FC = () => { { title: 'Actions', dataIndex: 'action', - filteredValue: undefined, + filteredValue: null, render: (_, record) => ( handleEdit(record)}>Edit @@ -206,7 +213,7 @@ const InventoryTable: React.FC = () => { okText="Yes" cancelText="Cancel" > - Delete + Delete ), diff --git a/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTablePageSelector.test.tsx b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTablePageSelector.test.tsx new file mode 100644 index 0000000..40ae286 --- /dev/null +++ b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTablePageSelector.test.tsx @@ -0,0 +1,267 @@ +/* @vitest-environment jsdom */ +/// +import "@testing-library/jest-dom/vitest"; +import userEvent from "@testing-library/user-event"; +import type {AxiosResponse} from "axios"; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import type {Product} from "../../../../types/Product.ts"; +import {cleanup, render, waitFor, within} from "@testing-library/react"; +import {SearchProvider} from "../../../../context/SearchContext.tsx"; +import {DataProvider} from "../../../../context/DataContext.tsx"; +import InventoryTable from "./InventoryTable.tsx"; +import InventoryTablePageSelector from "./InventoryTablePageSelector.tsx"; +import {getFilteredProducts} from "../../../../services/Requests.ts"; + +const __realGetComputedStyle = window.getComputedStyle; +const __realMatchMedia = window.matchMedia as any; +vi.mock("../../../../services/Requests", () => ({ + getFilteredProducts: vi.fn(), +})); + +const productsPage1: Product[] = [ + { + id: "p1", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p2", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p3", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p4", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p5", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p6", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p7", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p8", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p9", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p10", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p11", + name: "Coke", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + }, + { + id: "p12", + name: "Apple", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, +]; +const productsPage2: Product[] = [ + { + id: "p13", + name: "AppleP2", + category: "Fruit", + unitPrice: 0.9, + stockQuantity: 30, + creationDate: new Date("13-10-2025") + }, + { + id: "p44", + name: "CokeP2", + category: "Beverages", + unitPrice: 1.5, + stockQuantity: 12, + creationDate: new Date("13-10-2025") + } +]; + +function renderWithProviders() { + const user = userEvent.setup() + const view = render( + + + + + + ); + return {user, ...view}; +} + +describe("InventoryTablePageSelector tests", () => { + beforeAll(() => { + window.getComputedStyle = (elt: Element, _pseudo?: string | null) => { + const style = __realGetComputedStyle(elt); + const gpv = (prop: string) => { + if (typeof (style as any).getPropertyValue === "function") { + return (style as any).getPropertyValue(prop); + } + if ( + prop === "animation-duration" || + prop === "animation-delay" || + prop === "transition-duration" || + prop === "transition-delay" + ) { + return "0s"; + } + return ""; + }; + return {...style, getPropertyValue: gpv} as CSSStyleDeclaration; + }; + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + }); + afterAll(() => { + window.getComputedStyle = __realGetComputedStyle; + window.matchMedia = __realMatchMedia; + }); + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.resetAllMocks(); + }); + + it("makes call to Api whenever page is changed", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage2, + totalPages: Math.ceil(productsPage2.length / 10) + } + } as unknown as AxiosResponse); + + const {getByRole, getByText, findByText, findAllByText} = renderWithProviders(); + + await findAllByText("Coke"); + await findAllByText("Apple"); + + const pager = getByRole('navigation'); + const next = within(pager).getAllByRole('button')[1]; + await userEvent.click(next); + + await findByText("CokeP2"); + await waitFor(() => { + expect(getByText("CokeP2")).toBeVisible(); + expect(getByText("AppleP2")).toBeVisible(); + }); + + expect(getFilteredProducts).toHaveBeenCalledTimes(2); + }); + + it("it goes back and forth", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage1, + totalPages: Math.ceil(productsPage1.length / 10) + } + } as unknown as AxiosResponse); + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: productsPage2, + totalPages: Math.ceil(productsPage2.length / 10) + } + } as unknown as AxiosResponse); + + const {getByRole, getAllByText, findAllByText} = renderWithProviders(); + + await findAllByText("Coke"); + + let pager = getByRole('navigation'); + const next = within(pager).getAllByRole('button')[1]; + await userEvent.click(next); + + await findAllByText("Coke"); + await waitFor(() => { + expect(getAllByText("Coke")[0]).toBeVisible(); + expect(getAllByText("Apple")[0]).toBeVisible(); + }); + + pager = getByRole('navigation'); + const prev = within(pager).getAllByRole('button')[0]; + await userEvent.click(prev); + await findAllByText("CokeP2"); + + expect(getFilteredProducts).toHaveBeenCalledTimes(3); + }); +}); diff --git a/inventory-manager/src/components/page-content/segment3-table/InventoryTablePageSelector.tsx b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTablePageSelector.tsx similarity index 69% rename from inventory-manager/src/components/page-content/segment3-table/InventoryTablePageSelector.tsx rename to inventory-manager/src/components/page-content/segment3-table/segment/InventoryTablePageSelector.tsx index 89e962e..d15a704 100644 --- a/inventory-manager/src/components/page-content/segment3-table/InventoryTablePageSelector.tsx +++ b/inventory-manager/src/components/page-content/segment3-table/segment/InventoryTablePageSelector.tsx @@ -1,21 +1,22 @@ import React from 'react'; import {Pagination, type PaginationProps} from 'antd'; -import {useSearchContext} from "../../../context/SearchContext"; -import {useProductsData} from "../../../context/DataContext"; +import {useSearchContext} from "../../../../context/SearchContext"; +import {useDataContext} from "../../../../context/DataContext"; const InventoryTablePageSelector: React.FC = () => { const {page, setParams} = useSearchContext(); - const {total} = useProductsData(); + const {total} = useDataContext() const handlePageChange: PaginationProps['onChange'] = (pagination) => { setParams({page: ((pagination as number) - 1)}); } return ( ) } diff --git a/inventory-manager/src/components/page-content/segment4-metrics/InventoryMetricsTable.tsx b/inventory-manager/src/components/page-content/segment4-metrics/InventoryMetricsTable.tsx index 3d1fb39..79608a3 100644 --- a/inventory-manager/src/components/page-content/segment4-metrics/InventoryMetricsTable.tsx +++ b/inventory-manager/src/components/page-content/segment4-metrics/InventoryMetricsTable.tsx @@ -1,10 +1,10 @@ import React from 'react'; import {Table, type TableColumnsType} from 'antd'; import type {CategorySummary} from "../../../types/Product"; -import {useProductsData} from "../../../context/DataContext"; +import {useDataContext} from "../../../context/DataContext"; const InventoryMetricsTable: React.FC = () => { - const {summary} = useProductsData(); + const {summary} = useDataContext(); const columns: TableColumnsType = [ { diff --git a/inventory-manager/src/context/DataContext.test.tsx b/inventory-manager/src/context/DataContext.test.tsx new file mode 100644 index 0000000..9b30389 --- /dev/null +++ b/inventory-manager/src/context/DataContext.test.tsx @@ -0,0 +1,182 @@ +/* @vitest-environment jsdom */ +import React from "react"; +import {describe, it, vi, expect, beforeEach, afterEach} from "vitest"; +import userEvent from '@testing-library/user-event' +import {cleanup, render, waitFor} from "@testing-library/react"; +import {SearchProvider, useSearchContext} from "./SearchContext.tsx"; +import {getCategories, getFilteredProducts, getSummary} from "../services/Requests.ts"; +import {DataProvider, useDataContext} from "./DataContext.tsx"; +import type {AxiosResponse} from "axios"; +import type {Product} from "../types/Product.ts"; + +vi.mock("../services/Requests", () => ( + { + getProducts: vi.fn(), + getCategories: vi.fn(), + getSummary: vi.fn(), + getFilteredProducts: vi.fn(), + createProduct: vi.fn(), + updateProduct: vi.fn(), + markOutOfStock: vi.fn(), + markInStock: vi.fn(), + deleteProduct: vi.fn(), + + } + )); + +const Consumer: React.FC = () => { + const {setParams} = useSearchContext(); + const {products, loading, total, error, categories, summary} = useDataContext(); + return ( +
+
{products ? "yes" : "no"}
+
{String(loading)}
+
{error ?? ""}
+
{total}
+
{categories?.length ?? 0}
+
{summary ? "yes" : "no"}
+ + +
+ ); +}; + +function renderWithProvider() { + const user = userEvent.setup() + const view = render( + + + + + ); + return {user, ...view}; +} + +describe("DataContext initial tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.resetAllMocks(); + }); + + it("starts with nothing", () => { + const {getByTestId} = renderWithProvider(); + expect(getByTestId("products").textContent).toBe("no"); + expect(getByTestId("loading").textContent).toBe("true"); + expect(getByTestId("error").textContent).toBe(""); + expect(getByTestId("total").textContent).toBe("0"); + expect(getByTestId("categories").textContent).toBe("1"); + expect(getByTestId("summary").textContent).toBe("no"); + }); +}); + +describe("DataContext triggers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.resetAllMocks(); + }); + + it("trigger fetch", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: [ + {id: 1, name: 'Watermelon'} + ], + totalPages: 1 + } + } as unknown as AxiosResponse); + const {user, getByTestId} = renderWithProvider(); + await user.click(getByTestId("trigger-fetch")); + + expect(vi.mocked(getFilteredProducts)).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Watermelon", + category: "Fruit", + stockQuantity: 15, + page: 0, + })); + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false")); + expect(getByTestId("products").textContent).toBe("yes"); + expect(getByTestId("total").textContent).toBe("1"); + }) + + it("trigger fetches", async () => { + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: { + products: [ + {id: 1, name: 'Watermelon'}, + {id: 2, name: 'Water'}, + {id: 3, name: 'Apple'} + ], + totalPages: 1 + } + } as unknown as AxiosResponse); + const {user, getByTestId} = renderWithProvider(); + await user.click(getByTestId("trigger-fetches")); + + expect(vi.mocked(getFilteredProducts)).toHaveBeenCalledWith( + expect.objectContaining({name: "water"})); + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false")); + expect(getByTestId("products").textContent).toBe("yes"); + expect(getByTestId("total").textContent).toBe("1"); + }); + + it("error handling", async () => { + vi.mocked(getFilteredProducts).mockRejectedValueOnce(new Error("Network fail")); + + vi.mocked(getFilteredProducts).mockResolvedValueOnce({ + data: {products: [], totalPages: 0}, + } as unknown as AxiosResponse); + vi.mocked(getCategories).mockResolvedValue({data: []} as unknown as AxiosResponse); + vi.mocked(getSummary).mockResolvedValue({data: []} as unknown as AxiosResponse); + const {user, getByTestId} = renderWithProvider(); + + await user.click(getByTestId("trigger-fetch")); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false")); + expect(getByTestId("error").textContent).toMatch(/Network fail/i); + }); +}) + + + + + + + + + + + + diff --git a/inventory-manager/src/context/DataContext.tsx b/inventory-manager/src/context/DataContext.tsx index 1466ac2..866cac3 100644 --- a/inventory-manager/src/context/DataContext.tsx +++ b/inventory-manager/src/context/DataContext.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useEffect, useState} from "react"; +import React, {createContext, useContext, useEffect, useRef, useState} from "react"; import type {CategorySummary, Product} from "../types/Product"; import {getCategories, getFilteredProducts, getProducts, getSummary} from "../services/Requests"; import {useSearchContext} from "./SearchContext"; @@ -13,9 +13,26 @@ interface ProductDataContextProps { refreshProducts: () => Promise; } -const DataContext = createContext(undefined); +const defaultValues = { + products: undefined, + loading: true, + error: null, + total: null, + categories: [], + summary: undefined, + refreshProducts: async () => { + }, +}; + +const DataContext = createContext(defaultValues); + +export const useDataContext = () => useContext(DataContext); export const DataProvider: React.FC<{ children: React.ReactNode }> = ({children}) => { + const {name, category, stockQuantity, page, sort} = useSearchContext(); + const lastKeyRef = (useRef(null)); + const inFlightRef = (useRef(false)); + const [products, setProducts] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -23,48 +40,21 @@ export const DataProvider: React.FC<{ children: React.ReactNode }> = ({children} const [categories, setCategories] = useState([""]); const [summary, setSummary] = useState(); - - const refreshProducts = async () => { - setLoading(true); - try { - const fetched = await getProducts({page: 0}); - setProducts(fetched.products); - setTotal(fetched.totalPages); - const fetchedCategories = await getCategories(); - setCategories(fetchedCategories.data); - const fetchedSummary = await getSummary(); - setSummary(fetchedSummary.data); - - } catch (err: any) { - setError(err.message || "Unknown error"); - } finally { - setLoading(false); - } - }; - useEffect(() => { - refreshProducts().then(); - }, []); - - return ( - - {children} - - ); -}; + const paramsKey = JSON.stringify({ + name: name ?? null, + category: category ?? null, + stockQuantity: stockQuantity ?? null, + page: page ?? null, + sort: Array.isArray(sort) ? sort : (sort ?? null) + }); -export function useProductsData() { - const {name, category, stockQuantity, page, sort} = useSearchContext(); - - const [products, setProducts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [total, setTotal] = useState(0); - const [categories, setCategories] = useState([""]); - const [summary, setSummary] = useState(); + if (lastKeyRef.current === paramsKey) return; + if (inFlightRef.current) return; + inFlightRef.current = true; + lastKeyRef.current = paramsKey; - useEffect(() => { const fetchData = async () => { setLoading(true); try { @@ -90,14 +80,48 @@ export function useProductsData() { setSummary(fetchedSummary.data); } catch (err: any) { - setError(err.message); + let msg: string; + if (typeof err === 'object' && err !== null) { + msg = + (err as any)?.response?.data?.message ?? + (err as any)?.response?.data ?? + (err as Error)?.message ?? + 'Unknown error'; + setError(String(msg)); + + } else { + msg = String(err); + } + setError(msg); } finally { setLoading(false); } }; - fetchData().then(); + fetchData().then(() => inFlightRef.current = false); }, [name, category, stockQuantity, page, sort]); - return {products, loading, error, total, categories, summary}; -} \ No newline at end of file + const refreshProducts = async () => { + setLoading(true); + try { + const fetched = await getProducts({page: 0}); + setProducts(fetched.products); + setTotal(fetched.totalPages); + const fetchedCategories = await getCategories(); + setCategories(fetchedCategories.data); + const fetchedSummary = await getSummary(); + setSummary(fetchedSummary.data); + + } catch (err: any) { + setError(err.message || "Unknown error"); + } finally { + setLoading(false); + } + }; + + return ( + + {children} + + ); +}; diff --git a/inventory-manager/src/context/SearchContext.test.tsx b/inventory-manager/src/context/SearchContext.test.tsx new file mode 100644 index 0000000..00bbff6 --- /dev/null +++ b/inventory-manager/src/context/SearchContext.test.tsx @@ -0,0 +1,124 @@ +/* @vitest-environment jsdom */ +import React from "react"; +import {describe, it, expect, beforeEach, vi, afterEach} from "vitest"; +import userEvent from '@testing-library/user-event' +import {cleanup, render} from "@testing-library/react"; +import {SearchProvider, useSearchContext} from "./SearchContext.tsx"; + +const Consumer: React.FC = () => { + const clientSim = useSearchContext(); + return ( +
+
{clientSim.name}
+
{clientSim.category}
+
{clientSim.stockQuantity}
+
{clientSim.page}
+
{clientSim.sort}
+ + + +
+ ); +}; + +function renderWithProvider() { + const user = userEvent.setup() + const view = render( + + + ); + return {user, ...view}; +} + +describe("SearchContext tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.resetAllMocks(); + }); + + it("exposes initial state", () => { + const {getByTestId} = renderWithProvider(); + expect(getByTestId("name").textContent).toBe(""); + expect(getByTestId("category").textContent).toBe(""); + expect(getByTestId("stockQuantity").textContent).toBe('0'); + expect(getByTestId("page").textContent).toBe('0'); + }); + + it("set all to context", async () => { + const {user, getByRole, getByTestId} = renderWithProvider(); + await user.click(getByRole('button', {name: /setAll/i})); + + expect(getByTestId("name").textContent).toBe("Watermelon"); + expect(getByTestId("category").textContent).toBe("Fruits"); + expect(getByTestId("stockQuantity").textContent).toBe('15'); + expect(getByTestId("page").textContent).toBe('1'); + }); + + it("make small change to context", async () => { + const {user, getByRole, getByTestId} = renderWithProvider(); + await user.click(getByRole('button', {name: /setChange/i})); + + expect(getByTestId("name").textContent).toBe("water"); + expect(getByTestId("category").textContent).toBe(""); + expect(getByTestId("stockQuantity").textContent).toBe('0'); + expect(getByTestId("page").textContent).toBe('0'); + }); + + it("reset context state", async () => { + const {user, getByRole, getByTestId} = renderWithProvider(); + await user.click(getByRole('button', {name: /setIgnored/i})); + + expect(getByTestId("name").textContent).toBe(""); + expect(getByTestId("category").textContent).toBe(""); + expect(getByTestId("stockQuantity").textContent).toBe('0'); + expect(getByTestId("page").textContent).toBe('0'); + + const first = renderWithProvider(); + first.unmount() + + await first.user.click(first.getByRole('button', {name: /setAll/i})) + + expect(getByTestId("name").textContent).toBe("Watermelon"); + expect(getByTestId("category").textContent).toBe("Fruits"); + expect(getByTestId("stockQuantity").textContent).toBe('15'); + expect(getByTestId("page").textContent).toBe('1'); + + }); +}) diff --git a/inventory-manager/tsconfig.app.json b/inventory-manager/tsconfig.app.json index a9b5a59..efb587f 100644 --- a/inventory-manager/tsconfig.app.json +++ b/inventory-manager/tsconfig.app.json @@ -24,5 +24,8 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": [ + "src", + "node_modules/vitest/globals.d.ts" + ] } diff --git a/inventory-manager/vite.config.ts b/inventory-manager/vite.config.ts index 4217b13..bc89c30 100644 --- a/inventory-manager/vite.config.ts +++ b/inventory-manager/vite.config.ts @@ -16,4 +16,10 @@ export default defineConfig({ }, }, }, + test: { + environment: 'jsdom', + isolate: true, + env: { TZ: 'UTC' }, + sequence: { concurrent: false }, + }, }) \ No newline at end of file