diff --git a/package-lock.json b/package-lock.json index 86328853..f8ef015b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,13 +22,12 @@ "@radix-ui/react-radio-group": "1.1.3", "@radix-ui/react-select": "2.0.0", "@radix-ui/react-slot": "1.0.2", - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0", "@vanilla-extract/css": "1.15.3", "@vanilla-extract/recipes": "0.5.2", "@zendeskgarden/container-tabs": "2.0.10", "@zodios/core": "10.9.6", "astro": "4.8.2", + "canvas-confetti": "^1.9.3", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "lucide-react": "0.372.0", @@ -50,13 +49,21 @@ "@eslint/eslintrc": "3.0.2", "@eslint/js": "8.57.0", "@playwright/test": "1.41.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/canvas-confetti": "^1.6.4", "@types/eslint__eslintrc": "2.1.1", "@types/eslint__js": "8.42.3", "@types/eslint-config-prettier": "6.11.3", "@types/node": "20.12.11", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@types/swagger-ui-react": "4.18.3", "@typescript-eslint/eslint-plugin": "7.0.2", "@vanilla-extract/vite-plugin": "4.0.11", + "@vitejs/plugin-react": "^4.3.1", "@vitest/coverage-v8": "1.6.0", "astro-eslint-parser": "0.17.0", "autoprefixer": "10.4.19", @@ -73,6 +80,7 @@ "eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-unicorn": "51.0.1", "eslint-plugin-yml": "1.14.0", + "jsdom": "^24.1.1", "jsonc-eslint-parser": "2.4.0", "postcss": "8.4.38", "prettier": "3.2.5", @@ -87,6 +95,12 @@ "node": ">=20.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", + "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", + "dev": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -390,9 +404,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "engines": { "node": ">=6.9.0" } @@ -593,11 +607,11 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz", - "integrity": "sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.7.tgz", + "integrity": "sha512-fOPQYbGSgH0HUp4UJO4sMBFjY6DuWq+2i8rixyUMb3CdGixs/gccURvYOAhajBdKDoGajFr3mUq5rH3phtkGzw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3586,6 +3600,146 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "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 + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", + "integrity": "sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.0.tgz", + "integrity": "sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", @@ -3595,6 +3749,12 @@ "@types/estree": "*" } }, + "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 + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3632,6 +3792,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.6.4.tgz", + "integrity": "sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==", + "dev": true + }, "node_modules/@types/concat-stream": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-2.0.3.tgz", @@ -4351,15 +4517,15 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", - "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz", + "integrity": "sha512-m/V2syj5CuVnaxcUJOQRel/Wr31FFXRFlnOoq1TVtkCxsY5veGMTEmpWHndrhB2U8ScHtCQB1e+4hWYExQc6Lg==", "dependencies": { - "@babel/core": "^7.23.5", - "@babel/plugin-transform-react-jsx-self": "^7.23.3", - "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@babel/core": "^7.24.5", + "@babel/plugin-transform-react-jsx-self": "^7.24.5", + "@babel/plugin-transform-react-jsx-source": "^7.24.1", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.0" + "react-refresh": "^0.14.2" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -4609,6 +4775,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "devOptional": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5923,6 +6101,15 @@ } ] }, + "node_modules/canvas-confetti": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", + "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -6357,6 +6544,24 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "devOptional": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "devOptional": true + }, "node_modules/csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", @@ -6368,6 +6573,19 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "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==", + "devOptional": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -6435,6 +6653,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "devOptional": true + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -6663,6 +6887,12 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "node_modules/dompurify": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.2.tgz", @@ -8746,6 +8976,18 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "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==", + "devOptional": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -8765,6 +9007,32 @@ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" }, + "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==", + "devOptional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "devOptional": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -8773,6 +9041,18 @@ "node": ">=16.17.0" } }, + "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==", + "devOptional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9284,6 +9564,12 @@ "node": ">=0.10.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==", + "devOptional": true + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -9588,6 +9874,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", + "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", + "devOptional": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -9950,6 +10276,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/lz-string": { + "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, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.10", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", @@ -12228,6 +12563,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", + "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", + "devOptional": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -13257,6 +13598,12 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "devOptional": true + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -13271,7 +13618,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6" } @@ -13764,6 +14111,19 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -14700,6 +15060,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "devOptional": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -14794,6 +15160,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true + }, "node_modules/sass-formatter": { "version": "0.7.9", "resolved": "https://registry.npmjs.org/sass-formatter/-/sass-formatter-0.7.9.tgz", @@ -14803,6 +15175,18 @@ "suf-log": "^2.5.3" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "devOptional": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -15653,6 +16037,12 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0" } }, + "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==", + "devOptional": true + }, "node_modules/synckit": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.0.tgz", @@ -15867,6 +16257,33 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "devOptional": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "devOptional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -16760,6 +17177,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "devOptional": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unraw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", @@ -17594,6 +18020,18 @@ "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==", "dev": true }, + "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==", + "devOptional": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-up-path": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", @@ -17623,6 +18061,49 @@ "integrity": "sha512-zKGJW9r23y3BcJusbgvnOH2OYAW40MXAOi9bi3Gcc7T4Gms9WWgXF8m6adsJWpGJEhgOzCrfiz1IzKowJWrtYw==", "optional": true }, + "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==", + "devOptional": true, + "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==", + "devOptional": true, + "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==", + "devOptional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "devOptional": true, + "dependencies": { + "tr46": "^5.0.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", @@ -17893,6 +18374,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "devOptional": true }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "devOptional": true, + "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -17906,6 +18408,21 @@ "repeat-string": "^1.5.2" } }, + "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==", + "devOptional": true, + "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==", + "devOptional": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 822ee14e..63842220 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build": "astro build", "api:stub": "curl 'https://generator3.swagger.io/api/generate' -H 'accept: application/octet-stream' -H 'content-type: application/json' -H 'sec-fetch-dest: empty' -H 'sec-fetch-mode: cors' -H 'sec-fetch-site: same-origin' --data-raw \"{\\\"lang\\\":\\\"nodejs-server\\\",\\\"type\\\":\\\"SERVER\\\",\\\"codegenVersion\\\":\\\"V3\\\",\\\"spec\\\": $(cat ./public/docs/api/v0/openapi.json)}\" > ./stub-server.zip && unzip -o ./stub-server.zip -d ./stub-server && rm ./stub-server.zip && cd ./stub-server && npm install && echo 'STUB-SURVER: http://localhost:8080 && npm start", "api:sdk": "npx openapi-zod-client@latest ./public/docs/api/v0/openapi.json -o ./src/sdk.ts -p ./prettier.config.mjs -b https://vote.tulsawebdevs.org --api-client-name=sdk --error-expr=\"status >= 300 || status < 200\" --export-schemas --export-types --complexity-threshold=10 --with-deprecated --with-description --with-docs --additional-props-default-value=false && npx -y replace-in-file@^7 --configFile=replaceInFile.config.cjs && npm run format", + "api:sdk:dev": "npx openapi-zod-client@latest ./public/docs/api/v0/openapi.json -o ./src/sdk.ts -p ./prettier.config.mjs -b http://localhost:3000 --api-client-name=sdk --error-expr=\"status >= 300 || status < 200\" --export-schemas --export-types --complexity-threshold=10 --with-deprecated --with-description --with-docs --additional-props-default-value=false && npx -y replace-in-file@^7 --configFile=replaceInFile.config.cjs && npm run format", "postinstall": "npm run api:sdk", "api:docs": "npx swagger-ui ./public/docs/api/v0/openapi.json", "test": "vitest", @@ -35,13 +36,12 @@ "@radix-ui/react-radio-group": "1.1.3", "@radix-ui/react-select": "2.0.0", "@radix-ui/react-slot": "1.0.2", - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0", "@vanilla-extract/css": "1.15.3", "@vanilla-extract/recipes": "0.5.2", "@zendeskgarden/container-tabs": "2.0.10", "@zodios/core": "10.9.6", "astro": "4.8.2", + "canvas-confetti": "^1.9.3", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "lucide-react": "0.372.0", @@ -63,13 +63,21 @@ "@eslint/eslintrc": "3.0.2", "@eslint/js": "8.57.0", "@playwright/test": "1.41.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/canvas-confetti": "^1.6.4", "@types/eslint__eslintrc": "2.1.1", "@types/eslint__js": "8.42.3", "@types/eslint-config-prettier": "6.11.3", "@types/node": "20.12.11", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@types/swagger-ui-react": "4.18.3", "@typescript-eslint/eslint-plugin": "7.0.2", "@vanilla-extract/vite-plugin": "4.0.11", + "@vitejs/plugin-react": "^4.3.1", "@vitest/coverage-v8": "1.6.0", "astro-eslint-parser": "0.17.0", "autoprefixer": "10.4.19", @@ -86,6 +94,7 @@ "eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-unicorn": "51.0.1", "eslint-plugin-yml": "1.14.0", + "jsdom": "^24.1.1", "jsonc-eslint-parser": "2.4.0", "postcss": "8.4.38", "prettier": "3.2.5", diff --git a/public/docs/api/v0/openapi.json b/public/docs/api/v0/openapi.json index 96effb4e..ac2493f5 100644 --- a/public/docs/api/v0/openapi.json +++ b/public/docs/api/v0/openapi.json @@ -16,6 +16,46 @@ } ], "paths": { + "/winner": { + "summary": "Retrieve the top voted proposal for upcoming event", + "parameters": [], + "get": { + "operationId": "getVoteWinner", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ProposalWinner" }, + "example": { + "id": 1, + "created": "2021-09-01T12:00:00Z", + "updated": "2021-09-01T12:00:00Z", + "title": "My Proposal", + "description": "A longer description of the proposal", + "summary": "A short summary of the proposal", + "authorName": "John Doe", + "type": "topic", + "status": "closed", + "results": [ + { "value": 2, "comment": null }, + { "value": 2, "comment": null }, + { "value": 2, "comment": null }, + { "value": 0, "comment": null }, + { "value": -1, "comment": null }, + { "value": -2, "comment": null } + ] + } + } + } + }, + "401": { "$ref": "#/components/responses/401" }, + "404": { "$ref": "#/components/responses/404" }, + "default": { "$ref": "#/components/responses/500" } + } + } + }, "/drafts": { "summary": "Manage the current user's proposal drafts", "parameters": [ @@ -410,6 +450,26 @@ } ] }, + "ProposalWinner": { + "type": "object", + "allOf": [ + { "$ref": "#/components/schemas/Proposal" }, + { "$ref": "#/components/schemas/DatabaseObject" }, + { + "type": "object", + "required": ["status", "results", "authorName"], + "properties": { + "authorName": { "type": "string" }, + "status": { "type": "string", "enum": ["closed"] }, + "userVote": { "$ref": "#/components/schemas/Vote" }, + "results": { + "type": "array", + "items": { "$ref": "#/components/schemas/Vote" } + } + } + } + ] + }, "Proposal": { "type": "object", "required": ["title", "summary", "type"], diff --git a/src/features/voting/ProposalCard.tsx b/src/features/voting/ProposalCard.tsx index a3230ead..1535a405 100644 --- a/src/features/voting/ProposalCard.tsx +++ b/src/features/voting/ProposalCard.tsx @@ -46,15 +46,6 @@ export default function ProposalCard(props: ProposalCardProps) { [props.created], ); - const displayName = useMemo( - () => - props.authorName - .split(' ') - .map((name) => name[0]?.toUpperCase() ?? '') - .join(''), - [props.authorName], - ); - const votes = useMemo(() => { if (props.status === 'closed') { return props.results.reduce( diff --git a/src/features/voting/SelectWinner.tsx b/src/features/voting/SelectWinner.tsx new file mode 100644 index 00000000..ee88e1d2 --- /dev/null +++ b/src/features/voting/SelectWinner.tsx @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import confetti from 'canvas-confetti'; +import { toast } from 'sonner'; +import { isErrorFromAlias } from '@zodios/core'; +import { Button } from '../ui/button.tsx'; +import WinningProposal from './WinningProposal.tsx'; +import { sdk, type ProposalWinner } from '../../sdk.ts'; +import { useClerk } from '../auth/hooks.ts'; + +export default function SelectWinner() { + const clerkClient = useClerk(); + const intervalRef = useRef>(); + const [loading, setLoading] = useState(false); + const [countdown, setCountdown] = useState(5); + const [start, setStart] = useState(false); + const [winner, setWinner] = useState(); + + const onStartCountdown = () => { + setStart(true); + + intervalRef.current = setInterval(() => { + setCountdown((previous) => previous - 1); + }, 1000); + }; + + const handleGetWinner = useCallback(async () => { + if (!clerkClient) return; + + try { + setLoading(true); + const token = await clerkClient.session?.getToken(); + + const result = await sdk.getVoteWinner({ + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + setWinner(result); + launchConfetti(); + } catch (error) { + handleError(error); + } finally { + setLoading(false); + setCountdown(5); + } + }, [clerkClient]); + + useEffect(() => { + if (start && countdown < 1) { + clearInterval(intervalRef.current); + intervalRef.current = undefined; + setStart(false); + void handleGetWinner(); + } + }, [countdown, start, handleGetWinner]); + + return ( +
+ {start && ( +
+ {countdown} +
+ )} + + {winner && ( +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + +
+ )} + + {!start && !winner && !loading && ( + + )} +
+ ); +} + +function launchConfetti() { + const _fire = (particleRatio: number, options: confetti.Options) => { + void confetti({ + origin: { y: 0.7 }, + particleCount: Math.floor(200 * particleRatio), + ...options, + }); + }; + + _fire(0.25, { spread: 26, startVelocity: 55 }); + _fire(0.2, { spread: 60 }); + _fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 }); + _fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 }); + _fire(0.1, { spread: 120, startVelocity: 45 }); +} + +function handleError(error: unknown) { + if (isErrorFromAlias(sdk.api, 'getVoteWinner', error)) { + if (error.response.status === 401) { + toast.error('You do not have permission to view winners.'); + } + + if (error.response.status === 404) { + toast.warning(error.response.data.message); + } + } else { + toast.error('Unable to get winner. Please try again.'); + } +} diff --git a/src/features/voting/VotingStatistics.test.tsx b/src/features/voting/VotingStatistics.test.tsx new file mode 100644 index 00000000..9243b78f --- /dev/null +++ b/src/features/voting/VotingStatistics.test.tsx @@ -0,0 +1,206 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test, expect } from 'vitest'; +import VotingStatistics from './VotingStatistics.tsx'; + +describe('Voting Statistics Component', () => { + test('renders default', () => { + render(); + + expect(screen.getByText('Voting Results')).toBeInTheDocument(); + expect(screen.getByTestId('vote-score')).toHaveTextContent('0'); + expect(screen.getByTestId('down-count')).toHaveTextContent('0'); + expect(screen.getByTestId('neutral-count')).toHaveTextContent('0'); + expect(screen.getByTestId('up-count')).toHaveTextContent('0'); + expect(screen.getByTestId('breakdown-down')).toHaveAttribute( + 'data-breakdown', + '0', + ); + expect(screen.getByTestId('breakdown-neutral')).toHaveAttribute( + 'data-breakdown', + '0', + ); + expect(screen.getByTestId('breakdown-up')).toHaveAttribute( + 'data-breakdown', + '0', + ); + }); + + describe('Count Statistics', () => { + test('calculates correct scores', () => { + render( + , + ); + + expect(screen.getByTestId('vote-score')).toHaveTextContent('1'); + expect(screen.getByTestId('down-count')).toHaveTextContent('1'); + expect(screen.getByTestId('neutral-count')).toHaveTextContent('1'); + expect(screen.getByTestId('up-count')).toHaveTextContent('1'); + }); + + test('calculates correct negative score', () => { + render( + , + ); + + expect(screen.getByTestId('vote-score')).toHaveTextContent('-3'); + expect(screen.getByTestId('down-count')).toHaveTextContent('3'); + expect(screen.getByTestId('neutral-count')).toHaveTextContent('1'); + expect(screen.getByTestId('up-count')).toHaveTextContent('1'); + }); + + test('calculates correct positive score', () => { + render( + , + ); + + expect(screen.getByTestId('vote-score')).toHaveTextContent('3'); + expect(screen.getByTestId('down-count')).toHaveTextContent('1'); + expect(screen.getByTestId('neutral-count')).toHaveTextContent('1'); + expect(screen.getByTestId('up-count')).toHaveTextContent('3'); + }); + + test('calculates correct neutral score', () => { + render( + , + ); + + expect(screen.getByTestId('vote-score')).toHaveTextContent('0'); + expect(screen.getByTestId('down-count')).toHaveTextContent('0'); + expect(screen.getByTestId('neutral-count')).toHaveTextContent('4'); + expect(screen.getByTestId('up-count')).toHaveTextContent('0'); + }); + }); + + describe('Stacked Bar Statistics', () => { + test('calculate 1/3 per vote type', () => { + render( + , + ); + expect(screen.getByTestId('breakdown-down')).toHaveAttribute( + 'data-breakdown', + `${(1 / 3) * 100}`, + ); + expect(screen.getByTestId('breakdown-neutral')).toHaveAttribute( + 'data-breakdown', + `${(1 / 3) * 100}`, + ); + expect(screen.getByTestId('breakdown-up')).toHaveAttribute( + 'data-breakdown', + `${(1 / 3) * 100}`, + ); + }); + test('calculate majority negative votes', () => { + render( + , + ); + + expect(screen.getByTestId('breakdown-down')).toHaveAttribute( + 'data-breakdown', + `${(3 / 5) * 100}`, + ); + expect(screen.getByTestId('breakdown-neutral')).toHaveAttribute( + 'data-breakdown', + `${(1 / 5) * 100}`, + ); + expect(screen.getByTestId('breakdown-up')).toHaveAttribute( + 'data-breakdown', + `${(1 / 5) * 100}`, + ); + }); + test('calculate majority positive votes', () => { + render( + , + ); + + expect(screen.getByTestId('breakdown-down')).toHaveAttribute( + 'data-breakdown', + `${(1 / 5) * 100}`, + ); + expect(screen.getByTestId('breakdown-neutral')).toHaveAttribute( + 'data-breakdown', + `${(1 / 5) * 100}`, + ); + expect(screen.getByTestId('breakdown-up')).toHaveAttribute( + 'data-breakdown', + `${(3 / 5) * 100}`, + ); + }); + test('calculate majority neutral votes', () => { + render( + , + ); + + expect(screen.getByTestId('breakdown-down')).toHaveAttribute( + 'data-breakdown', + `${(1 / 5) * 100}`, + ); + expect(screen.getByTestId('breakdown-neutral')).toHaveAttribute( + 'data-breakdown', + `${(3 / 5) * 100}`, + ); + expect(screen.getByTestId('breakdown-up')).toHaveAttribute( + 'data-breakdown', + `${(1 / 5) * 100}`, + ); + }); + }); +}); diff --git a/src/features/voting/VotingStatistics.tsx b/src/features/voting/VotingStatistics.tsx new file mode 100644 index 00000000..f31b8411 --- /dev/null +++ b/src/features/voting/VotingStatistics.tsx @@ -0,0 +1,121 @@ +import { useMemo } from 'react'; +import { Meh, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react'; +import type { ProposalWinner } from '@/sdk'; + +type Props = { + results: ProposalWinner['results']; +}; + +export default function VotingStatistics({ results }: Props) { + const stats = useMemo( + () => + results.reduce( + (accumulator, result) => { + const voteValue = result.value; + + if (voteValue < 0) { + accumulator.down.score += voteValue; + accumulator.down.count += 1; + } else if (voteValue > 0) { + accumulator.up.score += voteValue; + accumulator.up.count += 1; + } else { + accumulator.neutral.score += voteValue; + accumulator.neutral.count += 1; + } + + accumulator.total += voteValue; + + return accumulator; + }, + { + up: { score: 0, count: 0 }, + down: { score: 0, count: 0 }, + neutral: { score: 0, count: 0 }, + total: 0, + }, + ), + [results], + ); + + const breakdown = useMemo(() => { + if (results.length === 0) { + return { down: 0, neutral: 0, up: 0 }; + } + + return { + down: (stats.down.count / results.length) * 100, + neutral: (stats.neutral.count / results.length) * 100, + up: (stats.up.count / results.length) * 100, + }; + }, [stats, results]); + + return ( +
+

Voting Results

+

+ Total Score: {stats.total} +

+ +
+
+
+
+
+ +
+
+ Negative + +
+ + + {stats.down.count} + +
+
+
+ Neutral + +
+ + + {stats.neutral.count} + +
+
+
+ Positive + +
+ + + {stats.up.count} + +
+
+
+
+ ); +} diff --git a/src/features/voting/WinningProposal.test.tsx b/src/features/voting/WinningProposal.test.tsx new file mode 100644 index 00000000..319deaff --- /dev/null +++ b/src/features/voting/WinningProposal.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { expect, test, describe, beforeEach } from 'vitest'; +import WinningProposal from './WinningProposal.tsx'; +import type { ProposalWinner } from '@/sdk'; + +const testProposal: ProposalWinner = { + id: 1, + created: '2021-09-01T12:00:00Z', + updated: '2021-09-01T12:00:00Z', + title: 'My Proposal', + description: 'A longer description of the proposal', + summary: 'A short summary of the proposal', + authorName: 'John Doe', + type: 'topic', + status: 'closed', + results: [ + { value: 2, comment: null }, + { value: 2, comment: null }, + { value: 2, comment: null }, + { value: 0, comment: null }, + { value: -1, comment: null }, + { value: -2, comment: null }, + ], +}; + +describe('WinningProposal Component', () => { + describe('default render', () => { + beforeEach(() => { + // eslint-disable-next-line react/jsx-props-no-spreading + render(); + }); + + test('renders element', () => { + expect(screen.getByTestId('winning-proposal')).toBeInTheDocument(); + }); + + test('renders title', () => { + expect(screen.getByText('My Proposal')).toBeInTheDocument(); + }); + test('renders summary', () => { + expect( + screen.getByText('A short summary of the proposal'), + ).toBeInTheDocument(); + }); + test('renders description', () => { + expect( + screen.getByText('A longer description of the proposal'), + ).toBeInTheDocument(); + }); + test('renders voting statistics', () => { + expect(screen.getByTestId('voting-statistics')).toBeInTheDocument(); + }); + }); + + test('do not display optional description', () => { + const { description, ...proposal } = testProposal; + + // eslint-disable-next-line react/jsx-props-no-spreading + render(); + + expect( + screen.queryByTestId('proposal-description'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/voting/WinningProposal.tsx b/src/features/voting/WinningProposal.tsx new file mode 100644 index 00000000..6f77a288 --- /dev/null +++ b/src/features/voting/WinningProposal.tsx @@ -0,0 +1,37 @@ +import type { ProposalWinner } from '@/sdk'; +import VotingStatistics from './VotingStatistics.tsx'; + +type Props = ProposalWinner; + +export default function WinningProposal(props: Props) { + return ( +
+
+
+

+ {props.title} +

+

+ {props.summary} +

+
+ + {props.description && ( +

+ {props.description} +

+ )} +
+ +
+ +
+
+ ); +} diff --git a/src/pages/winner.astro b/src/pages/winner.astro new file mode 100644 index 00000000..449a4e75 --- /dev/null +++ b/src/pages/winner.astro @@ -0,0 +1,10 @@ +--- +import Layout from '../layouts/Layout.astro'; +import SelectWinner from '../features/voting/SelectWinner.tsx'; +--- + + +
+ +
+
diff --git a/src/sdk.ts b/src/sdk.ts index d1298e2f..23446ffb 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -37,25 +37,36 @@ export type DatabaseObject = { created: string; updated: string; }; -export type ProposalState = - | { - authorName: string; - /** - * @enum closed - */ - status: 'closed'; - userVote?: Vote | undefined; - results: Array; - } - | { - authorName: string; - /** - * @enum open - */ - status: 'open'; - userVote?: Vote | undefined; - results?: (Array | null) | undefined; - }; +export type ProposalWinner = Proposal & + DatabaseObject & { + authorName: string; + /** + * @enum closed + */ + status: 'closed'; + userVote?: Vote | undefined; + results: Array; + }; +export type Proposal = { + /** + * @minLength 8 + * @maxLength 48 + */ + title: string; + /** + * @minLength 30 + * @maxLength 255 + */ + summary: string; + description?: /** + * @maxLength 2048 + */ + string | undefined; + /** + * @enum topic, project + */ + type: 'topic' | 'project'; +}; export type Vote = { /** * Ranking values: -2 (strong disinterest), -1 (slight disinterest), 0 (neutral), 1 (slight interest), 2 (strong interest) @@ -78,30 +89,65 @@ export type Vote = { ) | undefined; }; +export type ProposalState = + | { + authorName: string; + /** + * @enum closed + */ + status: 'closed'; + userVote?: Vote | undefined; + results: Array; + } + | { + authorName: string; + /** + * @enum open + */ + status: 'open'; + userVote?: Vote | undefined; + results?: (Array | null) | undefined; + }; export type ProposalIndex = Paginated & { proposals: Array; }; -export type Proposal = { - /** - * @minLength 8 - * @maxLength 48 - */ - title: string; - /** - * @minLength 30 - * @maxLength 255 - */ - summary: string; - description?: /** - * @maxLength 2048 - */ - string | undefined; - /** - * @enum topic, project - */ - type: 'topic' | 'project'; -}; +const Proposal: z.ZodType = z.object({ + title: z.string().min(8).max(48), + summary: z.string().min(30).max(255), + description: z.string().max(2048).optional(), + type: z.enum(['topic', 'project']), +}); +const DatabaseObject: z.ZodType = z.object({ + id: z.number().int(), + created: z.string().datetime({ offset: true }), + updated: z.string().datetime({ offset: true }), +}); +const Vote: z.ZodType = z.object({ + value: z + .union([ + z.literal(-2), + z.literal(-1), + z.literal(0), + z.literal(1), + z.literal(2), + ]) + .describe( + 'Ranking values: -2 (strong disinterest), -1 (slight disinterest), 0 (neutral), 1 (slight interest), 2 (strong interest)', + ), + comment: z.union([z.string(), z.null()]).optional(), +}); +const ProposalWinner: z.ZodType = Proposal.and( + DatabaseObject, +).and( + z.object({ + authorName: z.string(), + status: z.literal('closed'), + userVote: Vote.optional(), + results: z.array(Vote), + }), +); +const Error = z.object({ message: z.string() }); const Paginated: z.ZodType = z .object({ cursor: z @@ -124,35 +170,9 @@ const Draft: z.ZodType = z type: z.enum(['topic', 'project']), }) .partial(); -const DatabaseObject: z.ZodType = z.object({ - id: z.number().int(), - created: z.string().datetime({ offset: true }), - updated: z.string().datetime({ offset: true }), -}); const DraftIndex: z.ZodType = Paginated.and( z.object({ drafts: z.array(Draft.and(DatabaseObject)) }), ); -const Error = z.object({ message: z.string() }); -const Proposal: z.ZodType = z.object({ - title: z.string().min(8).max(48), - summary: z.string().min(30).max(255), - description: z.string().max(2048).optional(), - type: z.enum(['topic', 'project']), -}); -const Vote: z.ZodType = z.object({ - value: z - .union([ - z.literal(-2), - z.literal(-1), - z.literal(0), - z.literal(1), - z.literal(2), - ]) - .describe( - 'Ranking values: -2 (strong disinterest), -1 (slight disinterest), 0 (neutral), 1 (slight interest), 2 (strong interest)', - ), - comment: z.union([z.string(), z.null()]).optional(), -}); const ProposalState: z.ZodType = z.union([ z.object({ authorName: z.string(), @@ -174,13 +194,14 @@ const ProposalIndex: z.ZodType = Paginated.and( ); export const schemas = { + Proposal, + DatabaseObject, + Vote, + ProposalWinner, + Error, Paginated, Draft, - DatabaseObject, DraftIndex, - Error, - Proposal, - Vote, ProposalState, ProposalIndex, }; @@ -566,6 +587,25 @@ const endpoints = makeApi([ }, ], }, + { + method: 'get', + path: '/winner', + alias: 'getVoteWinner', + requestFormat: 'json', + response: ProposalWinner, + errors: [ + { + status: 401, + description: `Unauthorized`, + schema: z.object({ message: z.string() }), + }, + { + status: 404, + description: `Not Found`, + schema: z.object({ message: z.string() }), + }, + ], + }, ]); export const sdk = new Zodios('https://vote.tulsawebdevs.org', endpoints); diff --git a/tailwind.config.mjs b/tailwind.config.mjs index 7340ebd5..b25e9233 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -19,6 +19,9 @@ export default { }, }, extend: { + transitionDuration: { + 3000: '3000ms', + }, keyframes: { 'accordion-down': { from: { height: '0' }, @@ -28,10 +31,28 @@ export default { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' }, }, + 'fade-in': { + from: { + opacity: '0', + }, + to: { + opacity: '1', + }, + }, + 'grow-shrink': { + '0%': { + scale: '25%', + }, + '100%': { + scale: '200%', + }, + }, }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'fade-in': 'fade-in 1.5s ease-in', + 'grow-shrink': 'grow-shrink 1s infinite', }, }, }, diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts new file mode 100644 index 00000000..9347c17d --- /dev/null +++ b/tests/vitest.setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 6c8d1e56..d65e39db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,13 +1,17 @@ import { getViteConfig } from 'astro/config'; +import react from '@vitejs/plugin-react'; export default getViteConfig({ + plugins: [react()], test: { globals: true, + environment: 'jsdom', exclude: [ '**/node_modules/**', '**/dist/**', '**/playwright/**', '**/.{idea,git,cache,output,temp}/**', ], + setupFiles: './tests/vitest.setup.ts', }, });