diff --git a/.github/workflows/auto-label.json5 b/.github/workflows/auto-label.json5 new file mode 100644 index 0000000000..37929ea97b --- /dev/null +++ b/.github/workflows/auto-label.json5 @@ -0,0 +1,8 @@ +{ + "labelsSynonyms": { + "dependencies": ["dependabot", "dependency", "dependencies"], + "security": ["security"], + "ui/ux": ["layout", "screen", "design", "figma"] + }, + "defaultLabels": ["unapproved"], +} \ No newline at end of file diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 06da465ccf..420d50adbe 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -18,12 +18,43 @@ jobs: name: Adding Issue Label runs-on: ubuntu-latest steps: - - uses: Renato66/auto-label@v2.3.0 + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/workflows/auto-label.json5 + sparse-checkout-cone-mode: false + - uses: Renato66/auto-label@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - ignore-comments: true - default-labels: '["unapproved"]' - + - uses: actions/github-script@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const apiParams = { + owner, + repo, + issue_number + }; + const labels = await github.rest.issues.listLabelsOnIssue(apiParams); + if(labels.data.reduce((a, c)=>a||["dependencies"].includes(c.name), false)) + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ["good first issue", "security"] + }); + else if(labels.data.reduce((a, c)=>a||["security", "ui/ux"].includes(c.name), false)) + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ["good first issue"] + }); + + Issue-Greeting: name: Greeting Message to User runs-on: ubuntu-latest diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e4145fafa5..48efc8efcc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -38,7 +38,7 @@ jobs: - name: Count number of lines run: | chmod +x ./.github/workflows/countline.py - ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx src/screens/ManageTag/ManageTag.tsx + ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx src/components/TagActions/TagActionsMocks.ts src/utils/interfaces.ts - name: Get changed TypeScript files id: changed-files @@ -100,6 +100,7 @@ jobs: .node-version .husky/** scripts/** + schema.graphql package.json tsconfig.json .gitignore diff --git a/jest.config.js b/jest.config.js index 4346984c74..42a132d348 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,7 +23,6 @@ export default { ], moduleNameMapper: { '^react-native$': 'react-native-web', - '^@mui/(.*)$': '/node_modules/@mui/$1', '^@dicebear/core$': '/scripts/__mocks__/@dicebear/core.ts', '^@dicebear/collection$': '/scripts/__mocks__/@dicebear/collection.ts', diff --git a/package-lock.json b/package-lock.json index 31efdaed89..a69fde6323 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,21 @@ "name": "talawa-admin", "version": "3.0.0", "dependencies": { - "@apollo/client": "^3.11.4", + "@apollo/client": "^3.11.8", "@apollo/link-error": "^2.0.0-beta.3", "@apollo/react-testing": "^4.0.0", "@dicebear/collection": "^8.0.2", "@dicebear/core": "^8.0.2", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", - "@mui/icons-material": "^5.16.7", - "@mui/material": "^5.16.7", - "@mui/private-theming": "^5.15.12", - "@mui/system": "^5.14.12", - "@mui/x-charts": "^7.17.0", - "@mui/x-data-grid": "^7.22.0", - "@mui/x-date-pickers": "^7.11.1", + "@mui/base": "^5.0.0-beta.61", + "@mui/icons-material": "^6.1.6", + "@mui/material": "^6.1.6", + "@mui/private-theming": "^6.1.6", + "@mui/system": "^6.1.6", + "@mui/x-charts": "^7.22.1", + "@mui/x-data-grid": "^7.22.1", + "@mui/x-date-pickers": "^7.22.1", "@pdfme/generator": "^4.5.2", "@reduxjs/toolkit": "^2.3.0", "@vitejs/plugin-react": "^4.3.2", @@ -60,7 +61,7 @@ "redux-thunk": "^3.1.0", "sanitize-html": "^2.13.0", "typedoc": "^0.26.10", - "typedoc-plugin-markdown": "^4.2.1", + "typedoc-plugin-markdown": "^4.2.10", "typescript": "^5.6.3", "vite": "^5.4.8", "vite-plugin-environment": "^1.1.3", @@ -71,7 +72,7 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.25.7", - "@babel/preset-typescript": "^7.24.7", + "@babel/preset-typescript": "^7.26.0", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^12.1.10", @@ -82,7 +83,7 @@ "@types/node-fetch": "^2.6.10", "@types/react": "^18.3.3", "@types/react-beautiful-dnd": "^13.1.8", - "@types/react-bootstrap": "^0.32.32", + "@types/react-bootstrap": "^0.32.37", "@types/react-datepicker": "^7.0.0", "@types/react-dom": "^18.3.0", "@types/react-google-recaptcha": "^2.1.9", @@ -145,9 +146,9 @@ } }, "node_modules/@apollo/client": { - "version": "3.11.4", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.11.4.tgz", - "integrity": "sha512-bmgYKkULpym8wt8aXlAZ1heaYo0skLJ5ru0qJ+JCRoo03Pe+yIDbBCnqlDw6Mjj76hFkDw3HwFMgZC2Hxp30Mg==", + "version": "3.11.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.11.8.tgz", + "integrity": "sha512-CgG1wbtMjsV2pRGe/eYITmV5B8lXUCYljB2gB/6jWTFQcrvirUVvKg7qtFdjYkQSFbIffU1IDyxgeaN81eTjbA==", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@wry/caches": "^1.0.0", @@ -209,12 +210,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", - "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dependencies": { - "@babel/highlight": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -272,12 +273,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", - "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", "dependencies": { - "@babel/types": "^7.25.7", + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -287,13 +288,12 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -336,17 +336,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.4.tgz", - "integrity": "sha512-ro/bFs3/84MDgDmMwbcHgDa8/E6J3QKNTk4xJJnVeFtGE+tL0K26E3pNxhYz2b67fJpt7Aphw5XcploKXuCvCQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-member-expression-to-functions": "^7.24.8", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/helper-replace-supers": "^7.25.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/traverse": "^7.25.4", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", "semver": "^6.3.1" }, "engines": { @@ -408,40 +408,38 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", - "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "dev": true, "dependencies": { - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.8" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", - "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", - "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dependencies": { - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "@babel/traverse": "^7.25.2" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -451,22 +449,21 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", - "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "dev": true, "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", - "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", "engines": { "node": ">=6.9.0" } @@ -489,14 +486,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", - "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", "dev": true, "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.24.8", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/traverse": "^7.25.0" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -506,53 +503,51 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", - "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", + "dev": true, "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", - "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "dev": true, "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", - "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", - "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", - "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "engines": { "node": ">=6.9.0" } @@ -583,99 +578,12 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", - "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", - "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", - "license": "MIT", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", "dependencies": { - "@babel/types": "^7.25.8" + "@babel/types": "^7.26.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -911,13 +819,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.7.tgz", - "integrity": "sha512-ruZOnKO+ajVL/MVx+PwNBPOkrnXTXoWMtte1MBpegfCArhqOe3Bj52avVj1huLLxNKYKXYaSxZ2F+woK1ekXfw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1029,12 +936,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", - "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1430,14 +1337,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", - "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-simple-access": "^7.24.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1879,16 +1786,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.2.tgz", - "integrity": "sha512-lBwRvjSmqiMYe/pS0+1gggjJleUJi7NzjvQ1Fkqtt69hBa/0t1YuW/MLQMAPixfwaQOHUXsd6jeU3Z+vdGv3+A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", + "integrity": "sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-create-class-features-plugin": "^7.25.0", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/plugin-syntax-typescript": "^7.24.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2114,16 +2021,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz", - "integrity": "sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", + "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "@babel/plugin-syntax-jsx": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2155,30 +2062,28 @@ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/@babel/template": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", - "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", - "license": "MIT", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", - "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2187,14 +2092,12 @@ } }, "node_modules/@babel/types": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", - "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", - "license": "MIT", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "dependencies": { - "@babel/helper-string-parser": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2699,14 +2602,14 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.1.tgz", - "integrity": "sha512-dEPNKzBPU+vFPGa+z3axPRn8XVDetYORmDC0wAiej+TNcOZE70ZMJa0X7JdeoM6q/nWTMZeLpN/fTnD9o8MQBA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", + "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.0", + "@emotion/utils": "^1.4.1", "csstype": "^3.0.2" } }, @@ -2753,10 +2656,9 @@ } }, "node_modules/@emotion/utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz", - "integrity": "sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==", - "license": "MIT" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", @@ -2764,116 +2666,447 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, - "node_modules/@esbuild/win32-x64": { + "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ - "x64" + "ppc64" ], "optional": true, "os": [ - "win32" + "aix" ], "engines": { "node": ">=12" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=12" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", - "dev": true, - "license": "MIT", + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0", - "peer": true + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "type-fest": "^0.20.2" - }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0", + "peer": true + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, @@ -3475,20 +3708,20 @@ } }, "node_modules/@mui/base": { - "version": "5.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", - "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", + "version": "5.0.0-beta.61", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.61.tgz", + "integrity": "sha512-YaMOTXS3ecDNGsPKa6UdlJ8loFLvcL9+VbpCK3hfk71OaNauZRp4Yf7KeXDYr7Ms3M/XBD3SaiR6JMr6vYtfDg==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.19", + "@mui/utils": "^6.1.6", "@popperjs/core": "^2.11.8", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -3506,32 +3739,32 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.16.7", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.7.tgz", - "integrity": "sha512-RtsCt4Geed2/v74sbihWzzRs+HsIQCfclHeORh5Ynu2fS4icIKozcSubwuG7vtzq2uW3fOR1zITSP84TNt2GoQ==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.6.tgz", + "integrity": "sha512-nz1SlR9TdBYYPz4qKoNasMPRiGb4PaIHFkzLzhju0YVYS5QSuFF2+n7CsiHMIDcHv3piPu/xDWI53ruhOqvZwQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.16.7", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.7.tgz", - "integrity": "sha512-UrGwDJCXEszbDI7yV047BYU5A28eGJ79keTCP4cc74WyncuVrnurlmIRxaHL8YK+LI1Kzq+/JM52IAkNnv4u+Q==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.6.tgz", + "integrity": "sha512-5r9urIL2lxXb/sPN3LFfFYEibsXJUb986HhhIeu1gOcte460pwdSiEhBSxkAuyT8Dj7jvu9MjqSBmSumQELo8A==", "dependencies": { - "@babel/runtime": "^7.23.9" + "@babel/runtime": "^7.26.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^5.0.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@mui/material": "^6.1.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3540,25 +3773,25 @@ } }, "node_modules/@mui/material": { - "version": "5.16.7", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.7.tgz", - "integrity": "sha512-cwwVQxBhK60OIOqZOVLFt55t01zmarKJiJUWbk0+8s/Ix5IaUzAShqlJchxsIQ4mSrWqgcKCCXKtIlG5H+/Jmg==", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.16.7", - "@mui/system": "^5.16.7", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.6", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.6.tgz", + "integrity": "sha512-1yvejiQ/601l5AK3uIdUlAVElyCxoqKnl7QA+2oFB/2qYPWfRwDgavW/MoywS5Y2gZEslcJKhe0s2F3IthgFgw==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.1.6", + "@mui/system": "^6.1.6", + "@mui/types": "^7.2.19", + "@mui/utils": "^6.1.6", "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^18.3.1", "react-transition-group": "^4.4.5" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -3567,9 +3800,10 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "@mui/material-pigment-css": "^6.1.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -3578,30 +3812,33 @@ "@emotion/styled": { "optional": true }, + "@mui/material-pigment-css": { + "optional": true + }, "@types/react": { "optional": true } } }, "node_modules/@mui/private-theming": { - "version": "5.16.6", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.6.tgz", - "integrity": "sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.6.tgz", + "integrity": "sha512-ioAiFckaD/fJSnTrUMWgjl9HYBWt7ixCh7zZw7gDZ+Tae7NuprNV6QJK95EidDT7K0GetR2rU3kAeIR61Myttw==", "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.16.6", + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.1.6", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3610,17 +3847,19 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.16.6", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.6.tgz", - "integrity": "sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.6.tgz", + "integrity": "sha512-I+yS1cSuSvHnZDBO7e7VHxTWpj+R7XlSZvTC4lS/OIbUNJOMMSd3UDP6V2sfwzAdmdDNBi7NGCRv2SZ6O9hGDA==", "dependencies": { - "@babel/runtime": "^7.23.9", - "@emotion/cache": "^11.11.0", + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.1", + "@emotion/serialize": "^1.3.2", + "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -3629,7 +3868,7 @@ "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -3641,21 +3880,21 @@ } }, "node_modules/@mui/system": { - "version": "5.16.7", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.7.tgz", - "integrity": "sha512-Jncvs/r/d/itkxh7O7opOunTqbbSSzMTHzZkNLM+FjAOg+cYAZHrPDlYe1ZGKUYORwwb2XexlWnpZp0kZ4AHuA==", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.16.4", - "@mui/styled-engine": "^5.16.4", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.4", - "clsx": "^2.1.0", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.6.tgz", + "integrity": "sha512-qOf1VUE9wK8syiB0BBCp82oNBAVPYdj4Trh+G1s+L+ImYiKlubWhhqlnvWt3xqMevR+D2h1CXzA1vhX2FvA+VQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.1.6", + "@mui/styled-engine": "^6.1.6", + "@mui/types": "^7.2.19", + "@mui/utils": "^6.1.6", + "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -3664,8 +3903,8 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -3680,11 +3919,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.15", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.15.tgz", - "integrity": "sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==", + "version": "7.2.19", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.19.tgz", + "integrity": "sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3693,27 +3932,27 @@ } }, "node_modules/@mui/utils": { - "version": "5.16.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.6.tgz", - "integrity": "sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.6.tgz", + "integrity": "sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==", "dependencies": { - "@babel/runtime": "^7.23.9", - "@mui/types": "^7.2.15", - "@types/prop-types": "^15.7.12", + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.19", + "@types/prop-types": "^15.7.13", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^18.3.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3722,16 +3961,16 @@ } }, "node_modules/@mui/x-charts": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.17.0.tgz", - "integrity": "sha512-xDH/lOnb57+VBIA7q+1KlC0Ht1O46d/N2MEl1tUq1JYIXhA2Owi5cp+bcaof8Rvw5ApCmkoBxyUIjqT0guNIwA==", - "dependencies": { - "@babel/runtime": "^7.25.6", - "@mui/utils": "^5.16.6", - "@mui/x-charts-vendor": "7.16.0", - "@mui/x-internals": "7.17.0", - "@react-spring/rafz": "^9.7.4", - "@react-spring/web": "^9.7.4", + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.1.tgz", + "integrity": "sha512-zgr8CN4yLen5puqaX7Haj5+AoVG7E13HHsIiDoEAuQvuFDF0gKTxTTdLSKXqhd1qJUIIzJaztZtrr3YCVrENqw==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-charts-vendor": "7.20.0", + "@mui/x-internals": "7.21.0", + "@react-spring/rafz": "^9.7.5", + "@react-spring/web": "^9.7.5", "clsx": "^2.1.1", "prop-types": "^15.8.1" }, @@ -3756,11 +3995,11 @@ } }, "node_modules/@mui/x-charts-vendor": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-7.16.0.tgz", - "integrity": "sha512-MyMCCl7eAM53rLbjqP4zbMy5hYtdeqCjAYCH2jpvBKdgugm2eaPLKOPM8bUVfen0wHA8BXleQrIrNceytFPyZA==", + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-7.20.0.tgz", + "integrity": "sha512-pzlh7z/7KKs5o0Kk0oPcB+sY0+Dg7Q7RzqQowDQjpy5Slz6qqGsgOB5YUzn0L+2yRmvASc4Pe0914Ao3tMBogg==", "dependencies": { - "@babel/runtime": "^7.25.6", + "@babel/runtime": "^7.25.7", "@types/d3-color": "^3.1.3", "@types/d3-delaunay": "^6.0.4", "@types/d3-interpolate": "^3.0.4", @@ -3777,29 +4016,10 @@ "robust-predicates": "^3.0.2" } }, - "node_modules/@mui/x-charts/node_modules/@mui/x-internals": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.17.0.tgz", - "integrity": "sha512-FLlAGSJl/vsuaA/8hPGazXFppyzIzxApJJDZMoTS0geUmHd0hyooISV2ltllLmrZ/DGtHhI08m8GGnHL6/vVeg==", - "dependencies": { - "@babel/runtime": "^7.25.6", - "@mui/utils": "^5.16.6" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0" - } - }, "node_modules/@mui/x-data-grid": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.0.tgz", - "integrity": "sha512-gXl7+hG0YRNU3YODlPvz6Q/9+EeUsPAWn/u2YMQmYTgwAxeY5QE3lY224VRnwM5v9SfTFheo1kzAKmXPdjb9tQ==", + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.1.tgz", + "integrity": "sha512-YHF96MEv7ACG/VuiycZjEAPH7cZLNuV2+bi/MyR1t/e6E6LTolYFykvjSFq+Imz1mYbW4+9mEvrHZsIKL5KKIQ==", "dependencies": { "@babel/runtime": "^7.25.7", "@mui/utils": "^5.16.6 || ^6.0.0", @@ -3833,15 +4053,14 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.12.1.tgz", - "integrity": "sha512-Zj8kt3SCQbJp1qhMi+A3I4KqB8i5OY2Q11mdOEathFhqN/SQm1sUjIa1G09cGP1dPDgK1a6KM6qJGNtcw/nuWA==", - "dependencies": { - "@babel/runtime": "^7.24.6", - "@mui/base": "^5.0.0-beta.40", - "@mui/system": "^5.15.15", - "@mui/utils": "^5.15.14", - "@types/react-transition-group": "^4.4.10", + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.1.tgz", + "integrity": "sha512-VBgicE+7PvJrdHSL6HyieHT6a/0dENH8RaMIM2VwUFrGoZzvik50WNwY5U+Hip1BwZLIEvlqtNRQIIj6kgBR6Q==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.21.0", + "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" @@ -3856,8 +4075,9 @@ "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14", - "date-fns": "^2.25.0 || ^3.2.0", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", "dayjs": "^1.10.7", "luxon": "^3.0.2", @@ -4076,25 +4296,25 @@ } }, "node_modules/@react-spring/animated": { - "version": "9.7.4", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.4.tgz", - "integrity": "sha512-7As+8Pty2QlemJ9O5ecsuPKjmO0NKvmVkRR1n6mEotFgWar8FKuQt2xgxz3RTgxcccghpx1YdS1FCdElQNexmQ==", + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", "dependencies": { - "@react-spring/shared": "~9.7.4", - "@react-spring/types": "~9.7.4" + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/@react-spring/core": { - "version": "9.7.4", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.4.tgz", - "integrity": "sha512-GzjA44niEJBFUe9jN3zubRDDDP2E4tBlhNlSIkTChiNf9p4ZQlgXBg50qbXfSXHQPHak/ExYxwhipKVsQ/sUTw==", + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", "dependencies": { - "@react-spring/animated": "~9.7.4", - "@react-spring/shared": "~9.7.4", - "@react-spring/types": "~9.7.4" + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" }, "funding": { "type": "opencollective", @@ -4105,36 +4325,36 @@ } }, "node_modules/@react-spring/rafz": { - "version": "9.7.4", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.4.tgz", - "integrity": "sha512-mqDI6rW0Ca8IdryOMiXRhMtVGiEGLIO89vIOyFQXRIwwIMX30HLya24g9z4olDvFyeDW3+kibiKwtZnA4xhldA==" + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==" }, "node_modules/@react-spring/shared": { - "version": "9.7.4", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.4.tgz", - "integrity": "sha512-bEPI7cQp94dOtCFSEYpxvLxj0+xQfB5r9Ru1h8OMycsIq7zFZon1G0sHrBLaLQIWeMCllc4tVDYRTLIRv70C8w==", + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", "dependencies": { - "@react-spring/rafz": "~9.7.4", - "@react-spring/types": "~9.7.4" + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/@react-spring/types": { - "version": "9.7.4", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.4.tgz", - "integrity": "sha512-iQVztO09ZVfsletMiY+DpT/JRiBntdsdJ4uqk3UJFhrhS8mIC9ZOZbmfGSRs/kdbNPQkVyzucceDicQ/3Mlj9g==" + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==" }, "node_modules/@react-spring/web": { - "version": "9.7.4", - "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.4.tgz", - "integrity": "sha512-UMvCZp7I5HCVIleSa4BwbNxynqvj+mJjG2m20VO2yPoi2pnCYANy58flvz9v/YcXTAvsmL655FV3pm5fbr6akA==", + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", + "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", "dependencies": { - "@react-spring/animated": "~9.7.4", - "@react-spring/core": "~9.7.4", - "@react-spring/shared": "~9.7.4", - "@react-spring/types": "~9.7.4" + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0", @@ -4212,6 +4432,174 @@ "react": ">=16.14.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.21.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", @@ -4949,9 +5337,9 @@ "dev": true }, "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "node_modules/@types/react": { "version": "18.3.3", @@ -4972,9 +5360,9 @@ } }, "node_modules/@types/react-bootstrap": { - "version": "0.32.32", - "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.32.tgz", - "integrity": "sha512-GM9UtV7v+C2F0rbqgIpMWdCKBMdX3PQURoJQobPO4vDAeFadcExNtKffi13/MjaAks+riJKVGyiMe+6OmDYT2w==", + "version": "0.32.37", + "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.37.tgz", + "integrity": "sha512-CVHj++uxsj1pRnM3RQ/NAXcWj+JwJZ3MqQ28sS1OQUD1sI2gRlbeAjRT+ak2nuwL+CY+gtnIsMaIDq0RNfN0PA==", "dev": true, "dependencies": { "@types/react": "*" @@ -5049,9 +5437,9 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", - "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", "dependencies": { "@types/react": "*" } @@ -8024,6 +8412,21 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -9147,6 +9550,19 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -15428,6 +15844,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -16353,18 +16781,10 @@ } }, "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true }, "node_modules/to-object-path": { "version": "0.3.0", @@ -16549,6 +16969,358 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", @@ -16746,9 +17518,9 @@ } }, "node_modules/typedoc-plugin-markdown": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.2.1.tgz", - "integrity": "sha512-7hQt/1WaW/VI4+x3sxwcCGsEylP1E1GvF6OTTELK5sfTEp6AeK+83jkCOgZGp1pI2DiOammMYQMnxxOny9TKsQ==", + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.2.10.tgz", + "integrity": "sha512-PLX3pc1/7z13UJm4TDE9vo9jWGcClFUErXXtd5LdnoLjV6mynPpqZLU992DwMGFSRqJFZeKbVyqlNNeNHnk2tQ==", "engines": { "node": ">= 18" }, diff --git a/package.json b/package.json index 8ef05d105f..512dd2c54c 100644 --- a/package.json +++ b/package.json @@ -5,20 +5,21 @@ "type": "module", "config-overrides-path": "scripts/config-overrides", "dependencies": { - "@apollo/client": "^3.11.4", + "@apollo/client": "^3.11.8", "@apollo/link-error": "^2.0.0-beta.3", "@apollo/react-testing": "^4.0.0", "@dicebear/collection": "^8.0.2", "@dicebear/core": "^8.0.2", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", - "@mui/icons-material": "^5.16.7", - "@mui/material": "^5.16.7", - "@mui/private-theming": "^5.15.12", - "@mui/system": "^5.14.12", - "@mui/x-charts": "^7.17.0", - "@mui/x-data-grid": "^7.22.0", - "@mui/x-date-pickers": "^7.11.1", + "@mui/base": "^5.0.0-beta.61", + "@mui/icons-material": "^6.1.6", + "@mui/material": "^6.1.6", + "@mui/private-theming": "^6.1.6", + "@mui/system": "^6.1.6", + "@mui/x-charts": "^7.22.1", + "@mui/x-data-grid": "^7.22.1", + "@mui/x-date-pickers": "^7.22.1", "@pdfme/generator": "^4.5.2", "@reduxjs/toolkit": "^2.3.0", "@vitejs/plugin-react": "^4.3.2", @@ -57,7 +58,7 @@ "redux-thunk": "^3.1.0", "sanitize-html": "^2.13.0", "typedoc": "^0.26.10", - "typedoc-plugin-markdown": "^4.2.1", + "typedoc-plugin-markdown": "^4.2.10", "typescript": "^5.6.3", "vite": "^5.4.8", "vite-plugin-environment": "^1.1.3", @@ -105,7 +106,7 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.25.7", - "@babel/preset-typescript": "^7.24.7", + "@babel/preset-typescript": "^7.26.0", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^12.1.10", @@ -116,7 +117,7 @@ "@types/node-fetch": "^2.6.10", "@types/react": "^18.3.3", "@types/react-beautiful-dnd": "^13.1.8", - "@types/react-bootstrap": "^0.32.32", + "@types/react-bootstrap": "^0.32.37", "@types/react-datepicker": "^7.0.0", "@types/react-dom": "^18.3.0", "@types/react-google-recaptcha": "^2.1.9", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 7d2108bc06..d48f1a23c4 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "Leaderboard", + "searchByVolunteer": "Search By Volunteer", + "mostHours": "Most Hours", + "leastHours": "Least Hours", + "timeFrame": "Time Frame", + "allTime": "All Time", + "weekly": "This Week", + "monthly": "This Month", + "yearly": "This Year", + "noVolunteers": "No Volunteers Found!" + }, "loginPage": { "title": "Talawa Admin", "fromPalisadoes": "An open source application by Palisadoes Foundation volunteers", @@ -266,7 +278,9 @@ "members": "members", "admins": "admins", "requests": "requests", - "talawaApiUnavailable": "talawaApiUnavailable" + "talawaApiUnavailable": "talawaApiUnavailable", + "volunteerRankings": "Volunteer Rankings", + "noVolunteers": "No Volunteers Found!" }, "organizationPeople": { "title": "Talawa Members", @@ -320,7 +334,8 @@ "noTagsFound": "No tags found", "removeUserTag": "Delete Tag", "removeUserTagMessage": "Do you want to delete this tag?", - "addChildTag": "Add a Sub Tag" + "addChildTag": "Add a Sub Tag", + "enterTagName": "Enter Tag Name" }, "manageTag": { "title": "Tag Details", @@ -333,7 +348,6 @@ "addPeople": "Add People", "add": "Add", "subTags": "Sub Tags", - "assignedToAll": "Tag Assigned to All", "successfullyAssignedToPeople": "Tag assigned successfully", "errorOccurredWhileLoadingMembers": "Error occured while loading members", "userName": "User Name", @@ -359,7 +373,8 @@ "collapse": "Collapse", "expand": "Expand", "tagNamePlaceholder": "Write the name of the tag", - "allTags": "All Tags" + "allTags": "All Tags", + "noMoreMembersFound": "No more members found" }, "userListCard": { "addAdmin": "Add Admin", @@ -460,7 +475,7 @@ "successfulDeletion": "Action Item deleted successfully", "title": "Action Items", "category": "Category", - "allotedHours": "Alloted Hours", + "allottedHours": "Allotted Hours", "latestDueDate": "Latest Due Date", "earliestDueDate": "Earliest Due Date", "updateActionItem": "Update Action Item", @@ -469,7 +484,12 @@ "close": "close", "eventActionItems": "eventActionItems", "no": "no", - "yes": "yes" + "yes": "yes", + "individuals": "Individuals", + "groups": "Groups", + "assignTo": "Assign To", + "volunteers": "Volunteers", + "volunteerGroups": "Volunteer Groups" }, "organizationAgendaCategory": { "agendaCategoryDetails": "Agenda Category Details", @@ -732,10 +752,11 @@ "title": "Event Management", "dashboard": "Dashboard", "registrants": "Registrants", - "eventActions": "Event Actions", - "eventAgendas": "Event Agendas", - "eventStats": "Event Statistics", - "to": "TO" + "actions": "Actions", + "agendas": "Agendas", + "statistics": "Statistics", + "to": "TO", + "volunteers": "Volunteers" }, "forgotPassword": { "title": "Talawa Forgot Password", @@ -1342,5 +1363,80 @@ }, "userPledges": { "title": "My Pledges" + }, + "eventVolunteers": { + "volunteers": "Volunteers", + "volunteer": "Volunteer", + "volunteerGroups": "Volunteer Groups", + "individuals": "Individuals", + "groups": "Groups", + "status": "Status", + "noVolunteers": "No Volunteers", + "noVolunteerGroups": "No Volunteer Groups", + "add": "Add", + "mostHoursVolunteered": "Most Hours Volunteered", + "leastHoursVolunteered": "Least Hours Volunteered", + "accepted": "Accepted", + "addVolunteer": "Add Volunteer", + "removeVolunteer": "Remove Volunteer", + "volunteerAdded": "Volunteer added successfully", + "volunteerRemoved": "Volunteer removed successfully", + "volunteerGroupCreated": "Volunteer group created successfully", + "volunteerGroupUpdated": "Volunteer group updated successfully", + "volunteerGroupDeleted": "Volunteer group deleted successfully", + "removeVolunteerMsg": "Are you sure you want to remove this Volunteer?", + "deleteVolunteerGroupMsg": "Are you sure you want to delete this Volunteer Group?", + "leader": "Leader", + "group": "Group", + "createGroup": "Create Group", + "updateGroup": "Update Group", + "deleteGroup": "Delete Group", + "volunteersRequired": "Volunteers Required", + "volunteerDetails": "Volunteer Details", + "hoursVolunteered": "Hours Volunteered", + "groupDetails": "Group Details", + "creator": "Creator", + "requests": "Requests", + "noRequests": "No Requests", + "latest": "Latest", + "earliest": "Earliest", + "requestAccepted": "Request accepted successfully", + "requestRejected": "Request rejected successfully", + "details": "Details", + "manageGroup": "Manage Group", + "mostVolunteers": "Most Volunteers", + "leastVolunteers": "Least Volunteers" + }, + "userVolunteer": { + "title": "Volunteership", + "name": "Title", + "upcomingEvents": "Upcoming Events", + "requests": "Requests", + "invitations": "Invitations", + "groups": "Volunteer Groups", + "actions": "Actions", + "searchByName": "Search by Name", + "latestEndDate": "Latest End Date", + "earliestEndDate": "Earliest End Date", + "noEvents": "No Upcoming Events", + "volunteer": "Volunteer", + "volunteered": "Volunteered", + "join": "Join", + "joined": "Joined", + "searchByEventName": "Search by Event title", + "filter": "Filter", + "groupInvite": "Group Invite", + "individualInvite": "Individual Invite", + "noInvitations": "No Invitations", + "accept": "Accept", + "reject": "Reject", + "receivedLatest": "Received Latest", + "receivedEarliest": "Received Earliest", + "invitationAccepted": "Invitation accepted successfully", + "invitationRejected": "Invitation rejected successfully", + "volunteerSuccess": "Requested to volunteer successfully", + "recurring": "Recurring", + "groupInvitationSubject": "Invitation to join volunteer group", + "eventInvitationSubject": "Invitation to volunteer for event" } } diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 7f74826272..3d8cb461ee 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "Tableau des Leaders", + "searchByVolunteer": "Recherche par Bénévole", + "mostHours": "Le Plus d'Heures", + "leastHours": "Le Moins d'Heures", + "timeFrame": "Période", + "allTime": "Tout le Temps", + "weekly": "Cette Semaine", + "monthly": "Ce Mois", + "yearly": "Cette Année", + "noVolunteers": "Aucun Bénévole Trouvé!" + }, "loginPage": { "title": "Administrateur Talawa", "fromPalisadoes": "Une application open source réalisée par les bénévoles de la Fondation Palisadoes", @@ -266,7 +278,9 @@ "members": "Membres", "admins": "Administrateurs", "requests": "Demandes", - "talawaApiUnavailable": "API Talawa indisponible" + "talawaApiUnavailable": "API Talawa indisponible", + "volunteerRankings": "Classement des Bénévoles", + "noVolunteers": "Aucun Bénévole Trouvé!" }, "organizationPeople": { "title": "Membres Talawa", @@ -320,7 +334,8 @@ "noTagsFound": "Aucune étiquette trouvée", "removeUserTag": "Supprimer l'Étiquette", "removeUserTagMessage": "Voulez-vous supprimer cette étiquette ?", - "addChildTag": "Ajouter une Sous-Étiquette" + "addChildTag": "Ajouter une Sous-Étiquette", + "enterTagName": "Entrez le nom de l'étiquette" }, "manageTag": { "title": "Détails de l'étiquette", @@ -333,7 +348,6 @@ "addPeople": "Ajouter des personnes", "add": "Ajouter", "subTags": "Sous-étiquettes", - "assignedToAll": "Étiquette attribuée à tous", "successfullyAssignedToPeople": "Étiquette attribuée avec succès", "errorOccurredWhileLoadingMembers": "Erreur survenue lors du chargement des membres", "userName": "Nom d'utilisateur", @@ -359,7 +373,8 @@ "collapse": "Réduire", "expand": "Développer", "tagNamePlaceholder": "Écrire le nom de l'étiquette", - "allTags": "Toutes les étiquettes" + "allTags": "Toutes les étiquettes", + "noMoreMembersFound": "Aucun autre membre trouvé" }, "userListCard": { "addAdmin": "Ajouter un administrateur", @@ -426,50 +441,55 @@ "done": "Fait" }, "organizationActionItems": { - "actionItemCategory": "Catégorie d'élément d'action", - "actionItemDetails": "Détails de l'action", - "actionItemCompleted": "Élément d'action terminé", - "assignee": "Cessionnaire", - "assigner": "Assigner", - "assignmentDate": "Date d'affectation", + "actionItemCategory": "Catégorie de l'Action", + "actionItemDetails": "Détails de l'Action", + "actionItemCompleted": "Action Terminée", + "assignee": "Attribué à", + "assigner": "Assignateur", + "assignmentDate": "Date d'Attribution", "active": "Actif", - "clearFilters": "Effacer les filtres", - "completionDate": "Date d'achèvement", - "createActionItem": "Créer un élément d'action", - "deleteActionItem": "Supprimer l'élément d'action", - "deleteActionItemMsg": "Voulez-vous supprimer cette action ?", + "clearFilters": "Effacer les Filtres", + "completionDate": "Date de Complétion", + "createActionItem": "Créer une Action", + "deleteActionItem": "Supprimer l'Action", + "deleteActionItemMsg": "Voulez-vous supprimer cette action?", "details": "Détails", - "dueDate": "Date d'échéance", - "earliest": "Le plus tôt", - "editActionItem": "Modifier l'élément d'action", - "isCompleted": "Complété", - "latest": "Dernier", - "makeActive": "Actif", - "noActionItems": "Aucune action", - "options": "Possibilités", - "preCompletionNotes": "Notes préalables à l'achèvement", - "actionItemActive": "Actif", - "markCompletion": "Marquer l'achèvement", - "actionItemStatus": "Statut de l'action", - "postCompletionNotes": "Notes post-achèvement", - "selectActionItemCategory": "Sélectionnez une catégorie d'élément d'action", - "selectAssignee": "Sélectionnez un responsable", + "dueDate": "Date d'Échéance", + "earliest": "Le Plus Ancien", + "editActionItem": "Modifier l'Action", + "isCompleted": "Terminé", + "latest": "Le Plus Récent", + "makeActive": "Rendre Actif", + "noActionItems": "Aucune Action", + "options": "Options", + "preCompletionNotes": "Notes Pré-Complétion", + "actionItemActive": "Action Active", + "markCompletion": "Marquer comme Terminé", + "actionItemStatus": "État de l'Action", + "postCompletionNotes": "Notes Post-Complétion", + "selectActionItemCategory": "Sélectionnez une Catégorie d'Action", + "selectAssignee": "Sélectionner un Attribué", "status": "Statut", - "successfulCreation": "Élément d'action créé avec succès", - "successfulUpdation": "Élément d'action mis à jour avec succès", - "successfulDeletion": "Élément d'action supprimé avec succès", - "title": "Éléments d'action", + "successfulCreation": "Action créée avec succès", + "successfulUpdation": "Action mise à jour avec succès", + "successfulDeletion": "Action supprimée avec succès", + "title": "Actions", "category": "Catégorie", - "allotedHours": "Heures allouées", - "latestDueDate": "Date d'échéance la plus récente", - "earliestDueDate": "Date d'échéance la plus ancienne", - "updateActionItem": "Mettre à jour l'élément d'action", - "noneUpdated": "Aucun des champs n'a été mis à jour", - "updateStatusMsg": "Êtes-vous sûr de vouloir marquer cet élément d'action comme en attente?", + "allottedHours": "Heures Attribuées", + "latestDueDate": "Date d'Échéance la Plus Récente", + "earliestDueDate": "Date d'Échéance la Plus Ancienne", + "updateActionItem": "Mettre à Jour l'Action", + "noneUpdated": "Aucun champ n'a été mis à jour", + "updateStatusMsg": "Voulez-vous vraiment marquer cette action comme en attente?", "close": "Fermer", - "eventActionItems": "Éléments d'action d'événement", + "eventActionItems": "Actions de l'Événement", "no": "Non", - "yes": "Oui" + "yes": "Oui", + "individuals": "Individus", + "groups": "Groupes", + "assignTo": "Attribuer à", + "volunteers": "Bénévoles", + "volunteerGroups": "Groupes de Bénévoles" }, "organizationAgendaCategory": { "agendaCategoryDetails": "Détails de la catégorie d'ordre du jour", @@ -732,10 +752,11 @@ "title": "Gestion d'événements", "dashboard": "Tableau de bord", "registrants": "Inscrits", - "eventActions": "Actions d'événement", - "eventAgendas": "Ordres du jour des événements", - "eventStats": "Statistiques des événements", - "to": "À" + "actions": "Actions", + "agendas": "Ordres du jour", + "statistics": "Statistiques", + "to": "À", + "volunteers": "Bénévoles" }, "forgotPassword": { "title": "Talawa Mot de passe oublié", @@ -1342,5 +1363,80 @@ }, "userPledges": { "title": "Mes Promesses" + }, + "eventVolunteers": { + "volunteers": "Bénévoles", + "volunteer": "Bénévole", + "volunteerGroups": "Groupes de Bénévoles", + "individuals": "Individus", + "groups": "Groupes", + "status": "Statut", + "noVolunteers": "Aucun Bénévole", + "noVolunteerGroups": "Aucun Groupe de Bénévoles", + "add": "Ajouter", + "mostHoursVolunteered": "Le Plus d'Heures de Bénévolat", + "leastHoursVolunteered": "Le Moins d'Heures de Bénévolat", + "accepted": "Accepté", + "addVolunteer": "Ajouter un Bénévole", + "removeVolunteer": "Supprimer le Bénévole", + "volunteerAdded": "Bénévole ajouté avec succès", + "volunteerRemoved": "Bénévole supprimé avec succès", + "volunteerGroupCreated": "Groupe de bénévoles créé avec succès", + "volunteerGroupUpdated": "Groupe de bénévoles mis à jour avec succès", + "volunteerGroupDeleted": "Groupe de bénévoles supprimé avec succès", + "removeVolunteerMsg": "Êtes-vous sûr de vouloir supprimer ce bénévole?", + "deleteVolunteerGroupMsg": "Êtes-vous sûr de vouloir supprimer ce groupe de bénévoles?", + "leader": "Chef", + "group": "Groupe", + "createGroup": "Créer un Groupe", + "updateGroup": "Mettre à Jour le Groupe", + "deleteGroup": "Supprimer le Groupe", + "volunteersRequired": "Bénévoles Requis", + "volunteerDetails": "Détails du Bénévole", + "hoursVolunteered": "Heures de Bénévolat", + "groupDetails": "Détails du Groupe", + "creator": "Créateur", + "requests": "Demandes", + "noRequests": "Aucune Demande", + "latest": "Le Plus Récent", + "earliest": "Le Plus Ancien", + "requestAccepted": "Demande acceptée avec succès", + "requestRejected": "Demande rejetée avec succès", + "details": "Détails", + "manageGroup": "Gérer le Groupe", + "mostVolunteers": "Le plus de bénévoles", + "leastVolunteers": "Le moins de bénévoles" + }, + "userVolunteer": { + "title": "Volontariat", + "name": "Titre", + "upcomingEvents": "Événements à Venir", + "requests": "Demandes", + "invitations": "Invitations", + "groups": "Groupes de Bénévoles", + "actions": "Actions", + "searchByName": "Rechercher par Nom", + "latestEndDate": "Date de Fin la Plus Récente", + "earliestEndDate": "Date de Fin la Plus Ancienne", + "noEvents": "Aucun Événement à Venir", + "volunteer": "Bénévole", + "volunteered": "A Bénévolé", + "join": "Rejoindre", + "joined": "Rejoint", + "searchByEventName": "Rechercher par Titre d'Événement", + "filter": "Filtrer", + "groupInvite": "Invitation de Groupe", + "individualInvite": "Invitation Individuelle", + "noInvitations": "Aucune Invitation", + "accept": "Accepter", + "reject": "Rejeter", + "receivedLatest": "Reçu le Plus Récemment", + "receivedEarliest": "Reçu en Premier", + "invitationAccepted": "Invitation acceptée avec succès", + "invitationRejected": "Invitation rejetée avec succès", + "volunteerSuccess": "Demande de bénévolat envoyée avec succès", + "recurring": "Récurrent", + "groupInvitationSubject": "Invitation à rejoindre le groupe de bénévoles", + "eventInvitationSubject": "Invitation à faire du bénévolat pour l'événement" } } diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json index 4384648ca3..01b65dedde 100644 --- a/public/locales/hi/translation.json +++ b/public/locales/hi/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "लीडरबोर्ड", + "searchByVolunteer": "स्वयंसेवक द्वारा खोजें", + "mostHours": "सबसे अधिक घंटे", + "leastHours": "सबसे कम घंटे", + "timeFrame": "समय सीमा", + "allTime": "सभी समय", + "weekly": "इस सप्ताह", + "monthly": "इस माह", + "yearly": "इस वर्ष", + "noVolunteers": "कोई स्वयंसेवक नहीं मिला!" + }, "loginPage": { "title": "तालावा व्यवस्थापक", "fromPalisadoes": "Palisadoes फाउंडेशन स्वयंसेवकों द्वारा विकसित एक ओपन-सोर्स एप्लिकेशन", @@ -266,7 +278,9 @@ "members": "सदस्य", "admins": "प्रशासक", "requests": "अनुरोध", - "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध" + "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध", + "volunteerRankings": "स्वयंसेवक रैंकिंग", + "noVolunteers": "कोई स्वयंसेवक नहीं मिला!" }, "organizationPeople": { "title": "तालावा सदस्य", @@ -320,7 +334,8 @@ "noTagsFound": "कोई टैग नहीं मिला", "removeUserTag": "टैग हटाएँ", "removeUserTagMessage": "क्या आप इस टैग को हटाना चाहते हैं?", - "addChildTag": "उप-टैग जोड़ें" + "addChildTag": "उप-टैग जोड़ें", + "enterTagName": "टैग का नाम दर्ज करें" }, "manageTag": { "title": "टैग विवरण", @@ -333,7 +348,6 @@ "addPeople": "लोगों को जोड़ें", "add": "जोड़ें", "subTags": "उप-टैग्स", - "assignedToAll": "सभी को टैग असाइन किया गया", "successfullyAssignedToPeople": "टैग सफलतापूर्वक असाइन किया गया", "errorOccurredWhileLoadingMembers": "सदस्यों को लोड करते समय त्रुटि हुई", "userName": "उपयोगकर्ता नाम", @@ -359,7 +373,8 @@ "collapse": "संक्षिप्त करें", "expand": "विस्तारित करें", "tagNamePlaceholder": "टैग का नाम लिखें", - "allTags": "सभी टैग" + "allTags": "सभी टैग", + "noMoreMembersFound": "कोई और सदस्य नहीं मिला" }, "userListCard": { "addAdmin": "व्यवस्थापक जोड़ें", @@ -426,50 +441,55 @@ "done": "पूर्ण" }, "organizationActionItems": { - "actionItemCategory": "कार्य आइटम श्रेणी", - "actionItemDetails": "कार्रवाई मद विवरण", - "actionItemCompleted": "कार्य आइटम पूर्ण हुआ", - "assignee": "संपत्ति-भागी", - "assigner": "असाइनर", - "assignmentDate": "असाइनमेंट दिनांक", + "actionItemCategory": "क्रिया वस्तु श्रेणी", + "actionItemDetails": "क्रिया वस्तु विवरण", + "actionItemCompleted": "क्रिया वस्तु पूरी", + "assignee": "प्राप्तकर्ता", + "assigner": "सौंपने वाला", + "assignmentDate": "आवंटन तिथि", "active": "सक्रिय", "clearFilters": "फ़िल्टर साफ़ करें", - "completionDate": "पूरा करने की तिथि", - "createActionItem": "कार्रवाई आइटम बनाएं", - "deleteActionItem": "क्रिया आइटम हटाएँ", - "deleteActionItemMsg": "क्या आप इस क्रिया आइटम को हटाना चाहते हैं?", + "completionDate": "पूर्णता तिथि", + "createActionItem": "क्रिया वस्तु बनाएँ", + "deleteActionItem": "क्रिया वस्तु हटाएँ", + "deleteActionItemMsg": "क्या आप इस क्रिया वस्तु को हटाना चाहते हैं?", "details": "विवरण", - "dueDate": "नियत तारीख", - "earliest": "जल्द से जल्द", - "editActionItem": "क्रिया आइटम संपादित करें", - "isCompleted": "पुरा होना।", + "dueDate": "समाप्ति तिथि", + "earliest": "सबसे पहले", + "editActionItem": "क्रिया वस्तु संपादित करें", + "isCompleted": "पूर्ण", "latest": "नवीनतम", - "makeActive": "सक्रिय", - "noActionItems": "कोई एक्शन आइटम नहीं", + "makeActive": "सक्रिय बनाएं", + "noActionItems": "कोई क्रिया वस्तु नहीं", "options": "विकल्प", - "preCompletionNotes": "समापन पूर्व नोट्स", - "actionItemActive": "सक्रिय", - "markCompletion": "पूर्णता चिह्नित करें", - "actionItemStatus": "कार्रवाई मद स्थिति", - "postCompletionNotes": "समापन के बाद के नोट्स", - "selectActionItemCategory": "एक क्रिया आइटम श्रेणी का चयन करें", - "selectAssignee": "एक समनुदेशिती का चयन करें", + "preCompletionNotes": "पूर्व-पूर्णता नोट्स", + "actionItemActive": "सक्रिय क्रिया वस्तु", + "markCompletion": "पूर्णता को चिह्नित करें", + "actionItemStatus": "क्रिया वस्तु स्थिति", + "postCompletionNotes": "पूर्णता के बाद नोट्स", + "selectActionItemCategory": "क्रिया वस्तु श्रेणी चुनें", + "selectAssignee": "प्राप्तकर्ता चुनें", "status": "स्थिति", - "successfulCreation": "कार्रवाई आइटम सफलतापूर्वक बनाया गया", - "successfulUpdation": "कार्रवाई आइटम सफलतापूर्वक अपडेट किया गया", - "successfulDeletion": "कार्रवाई आइटम सफलतापूर्वक हटा दिया गया", - "title": "एक्शन आइटम्स", + "successfulCreation": "क्रिया वस्तु सफलतापूर्वक बनाई गई", + "successfulUpdation": "क्रिया वस्तु सफलतापूर्वक अद्यतन की गई", + "successfulDeletion": "क्रिया वस्तु सफलतापूर्वक हटाई गई", + "title": "क्रिया वस्तुएँ", "category": "श्रेणी", - "allotedHours": "आवंटित घंटे", - "latestDueDate": "सबसे अधिक नियत तिथि", - "earliestDueDate": "सबसे पहले की नियत तिथि", - "updateActionItem": "कार्य आइटम अपडेट करें", - "noneUpdated": "कोई फ़ील्ड अपडेट नहीं किया गया", - "updateStatusMsg": "क्या आप वाकई इस कार्य आइटम को लंबित के रूप में चिह्नित करना चाहते हैं?", + "allottedHours": "आवंटित घंटे", + "latestDueDate": "नवीनतम समाप्ति तिथि", + "earliestDueDate": "प्रारंभिक समाप्ति तिथि", + "updateActionItem": "क्रिया वस्तु अपडेट करें", + "noneUpdated": "कोई फ़ील्ड अपडेट नहीं की गई", + "updateStatusMsg": "क्या आप वाकई इस क्रिया वस्तु को लंबित के रूप में चिह्नित करना चाहते हैं?", "close": "बंद करें", - "eventActionItems": "कार्यक्रम कार्रवाई आइटम", + "eventActionItems": "घटना क्रिया वस्तुएं", "no": "नहीं", - "yes": "हाँ" + "yes": "हाँ", + "individuals": "व्यक्तियों", + "groups": "समूहों", + "assignTo": "सौंपें", + "volunteers": "स्वयंसेवक", + "volunteerGroups": "स्वयंसेवक समूह" }, "organizationAgendaCategory": { "agendaCategoryDetails": "एजेंडा श्रेणी विवरण", @@ -732,10 +752,11 @@ "title": "इवेंट मैनेजमेंट", "dashboard": "डैशबोर्ड", "registrants": "कुलसचिव", - "eventActions": "घटना क्रियाएँ", - "eventAgendas": "इवेंट एजेंडा", - "eventStats": "घटना सांख्यिकी", - "to": "को" + "actions": "कार्य", + "agendas": "एजेंडे", + "statistics": "आँकड़े", + "to": "को", + "volunteers": "स्वयंसेवक" }, "forgotPassword": { "title": "तलावा पासवर्ड भूल गए", @@ -1342,5 +1363,80 @@ }, "userPledges": { "title": "मेरी प्रतिज्ञाएँ" + }, + "eventVolunteers": { + "volunteers": "स्वयंसेवक", + "volunteer": "स्वयंसेवक", + "volunteerGroups": "स्वयंसेवक समूह", + "individuals": "व्यक्ति", + "groups": "समूह", + "status": "स्थिति", + "noVolunteers": "कोई स्वयंसेवक नहीं", + "noVolunteerGroups": "कोई स्वयंसेवक समूह नहीं", + "add": "जोड़ें", + "mostHoursVolunteered": "सबसे अधिक घंटे स्वयंसेवा", + "leastHoursVolunteered": "सबसे कम घंटे स्वयंसेवा", + "accepted": "स्वीकृत", + "addVolunteer": "स्वयंसेवक जोड़ें", + "removeVolunteer": "स्वयंसेवक हटाएं", + "volunteerAdded": "स्वयंसेवक सफलतापूर्वक जोड़ा गया", + "volunteerRemoved": "स्वयंसेवक सफलतापूर्वक हटाया गया", + "volunteerGroupCreated": "स्वयंसेवक समूह सफलतापूर्वक बनाया गया", + "volunteerGroupUpdated": "स्वयंसेवक समूह सफलतापूर्वक अपडेट किया गया", + "volunteerGroupDeleted": "स्वयंसेवक समूह सफलतापूर्वक हटाया गया", + "removeVolunteerMsg": "क्या आप वाकई इस स्वयंसेवक को हटाना चाहते हैं?", + "deleteVolunteerGroupMsg": "क्या आप वाकई इस स्वयंसेवक समूह को हटाना चाहते हैं?", + "leader": "नेता", + "group": "समूह", + "createGroup": "समूह बनाएं", + "updateGroup": "समूह अपडेट करें", + "deleteGroup": "समूह हटाएं", + "volunteersRequired": "आवश्यक स्वयंसेवक", + "volunteerDetails": "स्वयंसेवक विवरण", + "hoursVolunteered": "स्वयंसेवा घंटे", + "groupDetails": "समूह विवरण", + "creator": "निर्माता", + "requests": "अनुरोध", + "noRequests": "कोई अनुरोध नहीं", + "latest": "नवीनतम", + "earliest": "प्रारंभिक", + "requestAccepted": "अनुरोध सफलतापूर्वक स्वीकृत", + "requestRejected": "अनुरोध सफलतापूर्वक अस्वीकृत", + "details": "विवरण", + "manageGroup": "समूह प्रबंधित करें", + "mostVolunteers": "सबसे अधिक स्वयंसेवक", + "leastVolunteers": "सबसे कम स्वयंसेवक" + }, + "userVolunteer": { + "title": "स्वयंसेवकता", + "name": "शीर्षक", + "upcomingEvents": "आगामी कार्यक्रम", + "requests": "अनुरोध", + "invitations": "निमंत्रण", + "groups": "स्वयंसेवक समूह", + "actions": "क्रियाएँ", + "searchByName": "नाम से खोजें", + "latestEndDate": "नवीनतम समाप्ति तिथि", + "earliestEndDate": "प्रारंभिक समाप्ति तिथि", + "noEvents": "कोई आगामी कार्यक्रम नहीं", + "volunteer": "स्वयंसेवक", + "volunteered": "स्वयंसेवित", + "join": "शामिल हों", + "joined": "शामिल हुआ", + "searchByEventName": "कार्यक्रम शीर्षक से खोजें", + "filter": "फ़िल्टर", + "groupInvite": "समूह निमंत्रण", + "individualInvite": "व्यक्तिगत निमंत्रण", + "noInvitations": "कोई निमंत्रण नहीं", + "accept": "स्वीकारें", + "reject": "अस्वीकार करें", + "receivedLatest": "हाल में प्राप्त", + "receivedEarliest": "सबसे पहले प्राप्त", + "invitationAccepted": "निमंत्रण सफलतापूर्वक स्वीकार किया गया", + "invitationRejected": "निमंत्रण सफलतापूर्वक अस्वीकृत", + "volunteerSuccess": "स्वयंसेवक के रूप में अनुरोध सफलतापूर्वक किया गया", + "recurring": "पुनरावृत्ति", + "groupInvitationSubject": "स्वयंसेवक समूह में शामिल होने के लिए निमंत्रण", + "eventInvitationSubject": "कार्यक्रम के लिए स्वयंसेवक बनने का निमंत्रण" } } diff --git a/public/locales/sp/translation.json b/public/locales/sp/translation.json index 24ce0dbdec..300b14ceef 100644 --- a/public/locales/sp/translation.json +++ b/public/locales/sp/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "Tabla de Clasificación", + "searchByVolunteer": "Buscar por Voluntario", + "mostHours": "Más Horas", + "leastHours": "Menos Horas", + "timeFrame": "Período", + "allTime": "Todo el Tiempo", + "weekly": "Esta Semana", + "monthly": "Este Mes", + "yearly": "Este Año", + "noVolunteers": "¡No Se Encontraron Voluntarios!" + }, "loginPage": { "title": "Administrador Talawa", "fromPalisadoes": "Una aplicación de código abierto de los voluntarios de la Fundación palisados", @@ -266,7 +278,9 @@ "noPostsPresent": "No Hay Publicaciones Presentes", "membershipRequests": "Solicitudes de Membresía", "noMembershipRequests": "No Hay Solicitudes de Membresía", - "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "volunteerRankings": "Clasificación de Voluntarios", + "noVolunteers": "¡No Se Encontraron Voluntarios!" }, "organizationPeople": { "title": "Miembros Talawa", @@ -320,7 +334,8 @@ "noTagsFound": "No se encontraron etiquetas", "removeUserTag": "Eliminar Etiqueta", "removeUserTagMessage": "¿Desea eliminar esta etiqueta?", - "addChildTag": "Agregar una Sub Etiqueta" + "addChildTag": "Agregar una Sub Etiqueta", + "enterTagName": "Ingrese el nombre de la etiqueta" }, "manageTag": { "title": "Detalles de la Etiqueta", @@ -333,7 +348,6 @@ "addPeople": "Agregar Personas", "add": "Agregar", "subTags": "Subetiquetas", - "assignedToAll": "Etiqueta asignada a todos", "successfullyAssignedToPeople": "Etiqueta asignada con éxito", "errorOccurredWhileLoadingMembers": "Error al cargar los miembros", "userName": "Nombre de usuario", @@ -359,7 +373,8 @@ "collapse": "Colapsar", "expand": "Expandir", "tagNamePlaceholder": "Escribe el nombre de la etiqueta", - "allTags": "Todas las etiquetas" + "allTags": "Todas las etiquetas", + "noMoreMembersFound": "No se encontraron más miembros" }, "userListCard": { "joined": "Unido", @@ -426,50 +441,55 @@ "done": "Hecho" }, "organizationActionItems": { - "actionItemCategory": "Categoría del ítem de acción", - "actionItemActive": "Elemento de acción activo", - "actionItemCompleted": "Elemento de acción completado", - "actionItemDetails": "Detalles del ítem de acción", - "actionItemStatus": "Estado del elemento de acción", + "actionItemCategory": "Categoría de Acción", + "actionItemDetails": "Detalles de la Acción", + "actionItemCompleted": "Acción Completada", "assignee": "Asignado", "assigner": "Asignador", - "assignmentDate": "Fecha de asignación", + "assignmentDate": "Fecha de Asignación", "active": "Activo", - "clearFilters": "Borrar filtros", - "close": "Cerrar", - "completionDate": "Fecha de finalización", - "createActionItem": "Crear ítem de acción", - "deleteActionItem": "Eliminar ítem de acción", - "deleteActionItemMsg": "¿Desea eliminar este ítem de acción?", + "clearFilters": "Limpiar Filtros", + "completionDate": "Fecha de Finalización", + "createActionItem": "Crear Acción", + "deleteActionItem": "Eliminar Acción", + "deleteActionItemMsg": "¿Desea eliminar esta acción?", "details": "Detalles", - "dueDate": "Fecha de vencimiento", - "earliest": "Lo más temprano", - "editActionItem": "Editar ítem de acción", - "eventActionItems": "Elementos de acción del evento", + "dueDate": "Fecha de Vencimiento", + "earliest": "El Más Antiguo", + "editActionItem": "Editar Acción", "isCompleted": "Completado", - "latest": "Lo más reciente", - "makeActive": "Activar", - "markCompletion": "Marcar finalización", - "no": "No", - "noActionItems": "No hay ítems de acción", + "latest": "El Más Reciente", + "makeActive": "Hacer Activo", + "noActionItems": "No Hay Acciones", "options": "Opciones", - "preCompletionNotes": "Notas previas a la finalización", - "postCompletionNotes": "Notas posteriores a la finalización", - "selectActionItemCategory": "Seleccione una categoría de ítem de acción", - "selectAssignee": "Seleccione un asignado", + "preCompletionNotes": "Notas Pre-Compleción", + "actionItemActive": "Acción Activa", + "markCompletion": "Marcar como Completado", + "actionItemStatus": "Estado de la Acción", + "postCompletionNotes": "Notas de Finalización", + "selectActionItemCategory": "Seleccionar una Categoría de Acción", + "selectAssignee": "Seleccionar Asignado", "status": "Estado", - "successfulCreation": "Ítem de acción creado con éxito", - "successfulUpdation": "Ítem de acción actualizado con éxito", - "successfulDeletion": "Ítem de acción eliminado con éxito", - "title": "Ítems de acción", - "yes": "Sí", + "successfulCreation": "Acción creada con éxito", + "successfulUpdation": "Acción actualizada con éxito", + "successfulDeletion": "Acción eliminada con éxito", + "title": "Acciones", "category": "Categoría", - "allotedHours": "Horas Asignadas", + "allottedHours": "Horas Asignadas", "latestDueDate": "Fecha de Vencimiento Más Reciente", - "earliestDueDate": "Fecha de Vencimiento Más Temprana", - "updateActionItem": "Actualizar Elemento de Acción", - "noneUpdated": "Ninguno de los campos fue actualizado", - "updateStatusMsg": "¿Estás seguro de que deseas marcar este elemento de acción como pendiente?" + "earliestDueDate": "Fecha de Vencimiento Más Antigua", + "updateActionItem": "Actualizar Acción", + "noneUpdated": "Ningún campo fue actualizado", + "updateStatusMsg": "¿Está seguro de que desea marcar esta acción como pendiente?", + "close": "Cerrar", + "eventActionItems": "Acciones del Evento", + "no": "No", + "yes": "Sí", + "individuals": "Individuos", + "groups": "Grupos", + "assignTo": "Asignar a", + "volunteers": "Voluntarios", + "volunteerGroups": "Grupos de Voluntarios" }, "organizationAgendaCategory": { "agendaCategoryDetails": "Detalles de la categoría de la agenda", @@ -733,10 +753,11 @@ "title": "Gestión de eventos", "dashboard": "Tablero", "registrants": "Inscritos", - "eventActions": "Acciones del evento", - "eventAgendas": "Agendas de eventos", - "eventStats": "Estadísticas del evento", - "to": "A" + "actions": "Acciones", + "agendas": "Agendas", + "statistics": "Estadísticas", + "to": "A", + "volunteers": "Voluntarios" }, "forgotPassword": { "title": "Talawa olvidó su contraseña", @@ -1343,5 +1364,80 @@ }, "userPledges": { "title": "Mis Promesas" + }, + "eventVolunteers": { + "volunteers": "Voluntarios", + "volunteer": "Voluntario", + "volunteerGroups": "Grupos de Voluntarios", + "individuals": "Individuos", + "groups": "Grupos", + "status": "Estado", + "noVolunteers": "No Hay Voluntarios", + "noVolunteerGroups": "No Hay Grupos de Voluntarios", + "add": "Agregar", + "mostHoursVolunteered": "Más Horas de Voluntariado", + "leastHoursVolunteered": "Menos Horas de Voluntariado", + "accepted": "Aceptado", + "addVolunteer": "Agregar Voluntario", + "removeVolunteer": "Eliminar Voluntario", + "volunteerAdded": "Voluntario agregado con éxito", + "volunteerRemoved": "Voluntario eliminado con éxito", + "volunteerGroupCreated": "Grupo de voluntarios creado con éxito", + "volunteerGroupUpdated": "Grupo de voluntarios actualizado con éxito", + "volunteerGroupDeleted": "Grupo de voluntarios eliminado con éxito", + "removeVolunteerMsg": "¿Está seguro de que desea eliminar a este Voluntario?", + "deleteVolunteerGroupMsg": "¿Está seguro de que desea eliminar este Grupo de Voluntarios?", + "leader": "Líder", + "group": "Grupo", + "createGroup": "Crear Grupo", + "updateGroup": "Actualizar Grupo", + "deleteGroup": "Eliminar Grupo", + "volunteersRequired": "Voluntarios Necesarios", + "volunteerDetails": "Detalles del Voluntario", + "hoursVolunteered": "Horas de Voluntariado", + "groupDetails": "Detalles del Grupo", + "creator": "Creador", + "requests": "Solicitudes", + "noRequests": "No Hay Solicitudes", + "latest": "Más Reciente", + "earliest": "Más Antiguo", + "requestAccepted": "Solicitud aceptada con éxito", + "requestRejected": "Solicitud rechazada con éxito", + "details": "Detalles", + "manageGroup": "Gestionar Grupo", + "mostVolunteers": "La mayoría de voluntarios", + "leastVolunteers": "Menos voluntarios" + }, + "userVolunteer": { + "title": "Voluntariado", + "name": "Título", + "upcomingEvents": "Próximos Eventos", + "requests": "Solicitudes", + "invitations": "Invitaciones", + "groups": "Grupos de Voluntarios", + "actions": "Acciones", + "searchByName": "Buscar por Nombre", + "latestEndDate": "Fecha de Finalización Más Reciente", + "earliestEndDate": "Fecha de Finalización Más Antigua", + "noEvents": "No Hay Próximos Eventos", + "volunteer": "Voluntario", + "volunteered": "Voluntariado", + "join": "Unirse", + "joined": "Unido", + "searchByEventName": "Buscar por Título del Evento", + "filter": "Filtrar", + "groupInvite": "Invitación de Grupo", + "individualInvite": "Invitación Individual", + "noInvitations": "No Hay Invitaciones", + "accept": "Aceptar", + "reject": "Rechazar", + "receivedLatest": "Recibido Recientemente", + "receivedEarliest": "Recibido en Primer Lugar", + "invitationAccepted": "Invitación aceptada con éxito", + "invitationRejected": "Invitación rechazada con éxito", + "volunteerSuccess": "Solicitud de voluntariado realizada con éxito", + "recurring": "Recurrente", + "groupInvitationSubject": "Invitación a unirse al grupo de voluntarios", + "eventInvitationSubject": "Invitación a ser voluntario para el evento" } } diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 0c070bcf8b..c5c8c2c2c5 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -1,4 +1,16 @@ { + "leaderboard": { + "title": "排行榜", + "searchByVolunteer": "按志愿者搜索", + "mostHours": "最多时数", + "leastHours": "最少时数", + "timeFrame": "时间范围", + "allTime": "全部时间", + "weekly": "本周", + "monthly": "本月", + "yearly": "今年", + "noVolunteers": "未找到志愿者!" + }, "loginPage": { "title": "塔拉瓦管理员", "fromPalisadoes": "Palisadoes 基金会志愿者开发的开源应用程序", @@ -266,7 +278,9 @@ "members": "成员", "admins": "管理员", "requests": "请求", - "talawaApiUnavailable": "塔拉瓦 API 不可用" + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "volunteerRankings": "志愿者排名", + "noVolunteers": "未找到志愿者!" }, "organizationPeople": { "title": "塔拉瓦会员", @@ -320,7 +334,8 @@ "noTagsFound": "未找到标签", "removeUserTag": "删除标签", "removeUserTagMessage": "您确定要删除此标签吗?", - "addChildTag": "添加子标签" + "addChildTag": "添加子标签", + "enterTagName": "输入标签名称" }, "manageTag": { "title": "标签详情", @@ -333,7 +348,6 @@ "addPeople": "添加人员", "add": "添加", "subTags": "子标签", - "assignedToAll": "标签分配给所有人", "successfullyAssignedToPeople": "标签分配成功", "errorOccurredWhileLoadingMembers": "加载成员时出错", "userName": "用户名", @@ -359,7 +373,8 @@ "collapse": "收起", "expand": "展开", "tagNamePlaceholder": "输入标签名称", - "allTags": "所有标签" + "allTags": "所有标签", + "noMoreMembersFound": "未找到更多成员" }, "userListCard": { "addAdmin": "添加管理员", @@ -426,50 +441,55 @@ "done": "完成" }, "organizationActionItems": { - "actionItemCategory": "行动项目类别", - "actionItemDetails": "行动项目详情", - "actionItemCompleted": "行动项目已完成", - "assignee": "受让人", - "assigner": "转让人", + "actionItemCategory": "行动项类别", + "actionItemDetails": "行动项详情", + "actionItemCompleted": "行动项已完成", + "assignee": "受托人", + "assigner": "分配人", "assignmentDate": "分配日期", - "active": "积极的", + "active": "活跃", "clearFilters": "清除过滤器", "completionDate": "完成日期", - "createActionItem": "创建操作项", - "deleteActionItem": "删除操作项", - "deleteActionItemMsg": "您想删除此操作项吗?", - "details": "细节", - "dueDate": "到期日", + "createActionItem": "创建行动项", + "deleteActionItem": "删除行动项", + "deleteActionItemMsg": "是否要删除此行动项?", + "details": "详情", + "dueDate": "截止日期", "earliest": "最早", - "editActionItem": "编辑操作项", - "isCompleted": "完全的", - "latest": "最新的", - "makeActive": "积极的", - "noActionItems": "无行动项目", + "editActionItem": "编辑行动项", + "isCompleted": "已完成", + "latest": "最新", + "makeActive": "激活", + "noActionItems": "无行动项", "options": "选项", - "preCompletionNotes": "预完成注释", - "actionItemActive": "积极的", - "markCompletion": "标记完成", - "actionItemStatus": "行动项目状态", - "postCompletionNotes": "完成后注释", - "selectActionItemCategory": "选择操作项类别", + "preCompletionNotes": "预完成备注", + "actionItemActive": "活跃的行动项", + "markCompletion": "标记为完成", + "actionItemStatus": "行动项状态", + "postCompletionNotes": "完成后备注", + "selectActionItemCategory": "选择行动项类别", "selectAssignee": "选择受托人", - "status": "地位", - "successfulCreation": "操作项创建成功", - "successfulUpdation": "操作项已成功更新", - "successfulDeletion": "操作项已成功删除", - "title": "行动项目", + "status": "状态", + "successfulCreation": "行动项创建成功", + "successfulUpdation": "行动项更新成功", + "successfulDeletion": "行动项删除成功", + "title": "行动项", "category": "类别", - "allotedHours": "分配的小时", - "latestDueDate": "最晚到期日", - "earliestDueDate": "最早到期日", - "updateActionItem": "更新操作项", - "noneUpdated": "没有更新任何字段", - "updateStatusMsg": "您确定要将此操作项标记为待处理吗?", + "allottedHours": "分配的小时", + "latestDueDate": "最新截止日期", + "earliestDueDate": "最早截止日期", + "updateActionItem": "更新行动项", + "noneUpdated": "没有字段被更新", + "updateStatusMsg": "您确定要将此行动项标记为待处理吗?", "close": "关闭", - "eventActionItems": "活动行动项目", + "eventActionItems": "事件行动项", "no": "否", - "yes": "是" + "yes": "是", + "individuals": "个人", + "groups": "小组", + "assignTo": "分配给", + "volunteers": "志愿者", + "volunteerGroups": "志愿者小组" }, "organizationAgendaCategory": { "agendaCategoryDetails": "议程类别详情", @@ -732,10 +752,11 @@ "title": "事件管理", "dashboard": "仪表板", "registrants": "注册者", - "eventActions": "事件动作", - "eventAgendas": "活动议程", - "eventStats": "事件统计", - "to": "到" + "actions": "操作", + "agendas": "议程", + "statistics": "统计数据", + "to": "到", + "volunteers": "志愿者" }, "forgotPassword": { "title": "塔拉瓦 忘记密码", @@ -1342,5 +1363,80 @@ }, "userPledges": { "title": "我的承诺" + }, + "eventVolunteers": { + "volunteers": "志愿者", + "volunteer": "志愿者", + "volunteerGroups": "志愿者小组", + "individuals": "个人", + "groups": "小组", + "status": "状态", + "noVolunteers": "无志愿者", + "noVolunteerGroups": "无志愿者小组", + "add": "添加", + "mostHoursVolunteered": "最多志愿时数", + "leastHoursVolunteered": "最少志愿时数", + "accepted": "已接受", + "addVolunteer": "添加志愿者", + "removeVolunteer": "移除志愿者", + "volunteerAdded": "志愿者已成功添加", + "volunteerRemoved": "志愿者已成功移除", + "volunteerGroupCreated": "志愿者小组已成功创建", + "volunteerGroupUpdated": "志愿者小组已成功更新", + "volunteerGroupDeleted": "志愿者小组已成功删除", + "removeVolunteerMsg": "您确定要移除此志愿者吗?", + "deleteVolunteerGroupMsg": "您确定要删除此志愿者小组吗?", + "leader": "领导", + "group": "小组", + "createGroup": "创建小组", + "updateGroup": "更新小组", + "deleteGroup": "删除小组", + "volunteersRequired": "需要志愿者", + "volunteerDetails": "志愿者详情", + "hoursVolunteered": "志愿时数", + "groupDetails": "小组详情", + "creator": "创建者", + "requests": "请求", + "noRequests": "无请求", + "latest": "最新", + "earliest": "最早", + "requestAccepted": "请求已成功接受", + "requestRejected": "请求已成功拒绝", + "details": "详情", + "manageGroup": "管理小组", + "mostVolunteers": "最多的志愿者", + "leastVolunteers": "最少的志愿者" + }, + "userVolunteer": { + "title": "志愿服务", + "name": "标题", + "upcomingEvents": "即将举行的活动", + "requests": "请求", + "invitations": "邀请", + "groups": "志愿者小组", + "actions": "操作", + "searchByName": "按名称搜索", + "latestEndDate": "最晚结束日期", + "earliestEndDate": "最早结束日期", + "noEvents": "无即将举行的活动", + "volunteer": "志愿者", + "volunteered": "已志愿", + "join": "加入", + "joined": "已加入", + "searchByEventName": "按活动标题搜索", + "filter": "筛选", + "groupInvite": "小组邀请", + "individualInvite": "个人邀请", + "noInvitations": "无邀请", + "accept": "接受", + "reject": "拒绝", + "receivedLatest": "最近收到", + "receivedEarliest": "最早收到", + "invitationAccepted": "邀请已成功接受", + "invitationRejected": "邀请已成功拒绝", + "volunteerSuccess": "志愿请求成功", + "recurring": "重复", + "groupInvitationSubject": "邀请加入志愿者小组", + "eventInvitationSubject": "邀请参加活动志愿服务" } } diff --git a/schema.graphql b/schema.graphql index 9bfd49671c..80ca281f73 100644 --- a/schema.graphql +++ b/schema.graphql @@ -214,25 +214,6 @@ type DeletePayload { success: Boolean! } -type DirectChat { - _id: ID! - createdAt: DateTime! - creator: User - messages: [DirectChatMessage] - updatedAt: DateTime! - users: [User!]! -} - -type DirectChatMessage { - _id: ID! - createdAt: DateTime! - directChatMessageBelongsTo: DirectChat! - messageContent: String! - receiver: User! - sender: User! - updatedAt: DateTime! -} - type Donation { _id: ID! amount: Float! @@ -470,26 +451,6 @@ type Group { updatedAt: DateTime! } -type GroupChat { - _id: ID! - title: String! - createdAt: DateTime! - creator: User - messages: [GroupChatMessage] - organization: Organization! - updatedAt: DateTime! - users: [User!]! -} - -type GroupChatMessage { - _id: ID! - createdAt: DateTime! - groupChatMessageBelongsTo: GroupChat! - messageContent: String! - sender: User! - updatedAt: DateTime! -} - type InvalidCursor implements FieldError { message: String! path: [String!]! @@ -579,31 +540,6 @@ type MembershipRequest { user: User! } -type Message { - _id: ID! - createdAt: DateTime! - creator: User - imageUrl: URL - text: String! - updatedAt: DateTime! - videoUrl: URL -} - -type MessageChat { - _id: ID! - createdAt: DateTime! - languageBarrier: Boolean - message: String! - receiver: User! - sender: User! - updatedAt: DateTime! -} - -input MessageChatInput { - message: String! - receiver: ID! -} - type MinimumLengthError implements FieldError { limit: Int! message: String! @@ -659,10 +595,8 @@ type Mutation { organizationId: ID! ): UserCustomData! addUserImage(file: String!): User! - addUserToGroupChat(chatId: ID!, userId: ID!): GroupChat! addUserToUserFamily(familyId: ID!, userId: ID!): UserFamily! adminRemoveEvent(eventId: ID!): Event! - adminRemoveGroup(groupId: ID!): GroupChat! assignUserTag(input: ToggleUserTagAssignInput!): User blockPluginCreationBySuperadmin( blockUser: Boolean! @@ -692,7 +626,6 @@ type Mutation { createAgendaCategory(input: CreateAgendaCategoryInput!): AgendaCategory! createComment(data: CommentInput!, postId: ID!): Comment createChat(data: chatInput!): Chat! - createDirectChat(data: createChatInput!): DirectChat! createDonation( amount: Float! nameOfOrg: String! @@ -706,9 +639,7 @@ type Mutation { recurrenceRuleData: RecurrenceRuleInput ): Event! createEventVolunteer(data: EventVolunteerInput!): EventVolunteer! - createGroupChat(data: createGroupChatInput!): GroupChat! createMember(input: UserAndOrganizationInput!): Organization! - createMessageChat(data: MessageChatInput!): MessageChat! createOrganization(data: OrganizationInput, file: String): Organization! createPlugin( pluginCreatedBy: String! @@ -743,11 +674,9 @@ type Mutation { removeAdmin(data: UserAndOrganizationInput!): AppUserProfile! removeAdvertisement(id: ID!): Advertisement removeComment(id: ID!): Comment - removeDirectChat(chatId: ID!, organizationId: ID!): DirectChat! removeEvent(id: ID!): Event! removeEventAttendee(data: EventAttendeeInput!): User! removeEventVolunteer(id: ID!): EventVolunteer! - removeGroupChat(chatId: ID!): GroupChat! removeMember(data: UserAndOrganizationInput!): Organization! removeOrganization(id: ID!): UserData! removeOrganizationCustomField( @@ -759,7 +688,6 @@ type Mutation { removeSampleOrganization: Boolean! removeUserCustomData(organizationId: ID!): UserCustomData! removeUserFamily(familyId: ID!): UserFamily! - removeUserFromGroupChat(chatId: ID!, userId: ID!): GroupChat! removeUserFromUserFamily(familyId: ID!, userId: ID!): UserFamily! removeUserImage: User! removeUserTag(id: ID!): UserTag @@ -767,14 +695,6 @@ type Mutation { saveFcmToken(token: String): Boolean! sendMembershipRequest(organizationId: ID!): MembershipRequest! sendMessageToChat(chatId: ID!, messageContent: String!, type: String!, replyTo: ID): ChatMessage! - sendMessageToDirectChat( - chatId: ID! - messageContent: String! - ): DirectChatMessage! - sendMessageToGroupChat( - chatId: ID! - messageContent: String! - ): GroupChatMessage! signUp(data: UserInput!, file: String): AuthData! togglePostPin(id: ID!, title: String): Post! unassignUserTag(input: ToggleUserTagAssignInput!): User @@ -1096,11 +1016,6 @@ type Query { customFieldsByOrganization(id: ID!): [OrganizationCustomField] chatById(id: ID!): Chat! chatsByUserId(id: ID!): [Chat] - directChatsByUserID(id: ID!): [DirectChat] - directChatsMessagesByChatID(id: ID!): [DirectChatMessage] - directChatById(id: ID!): DirectChat - groupChatById(id: ID!): DirectChat - groupChatsByUserId(id: ID!): [GroupChat] event(id: ID!): Event eventVolunteersByEvent(id: ID!): [EventVolunteer] eventsByOrganization(id: ID, orderBy: EventOrderByInput): [Event] @@ -1195,10 +1110,7 @@ enum Status { } type Subscription { - directMessageChat: MessageChat messageSentToChat(userId: ID!): ChatMessage - messageSentToDirectChat(userId: ID!): DirectChatMessage - messageSentToGroupChat(userId: ID!): GroupChatMessage onPluginUpdate: Plugin } @@ -1592,17 +1504,6 @@ enum WeekDays { WE } -input createChatInput { - organizationId: ID - userIds: [ID!]! -} - -input createGroupChatInput { - organizationId: ID! - title: String! - userIds: [ID!]! -} - type Venue { _id: ID! capacity: Int! diff --git a/src/App.tsx b/src/App.tsx index d6e825006f..37f3bc301e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import OrganizationDashboard from 'screens/OrganizationDashboard/OrganizationDas import OrganizationEvents from 'screens/OrganizationEvents/OrganizationEvents'; import OrganizaitionFundCampiagn from 'screens/OrganizationFundCampaign/OrganizationFundCampagins'; import OrganizationFunds from 'screens/OrganizationFunds/OrganizationFunds'; +import FundCampaignPledge from 'screens/FundCampaignPledge/FundCampaignPledge'; import OrganizationPeople from 'screens/OrganizationPeople/OrganizationPeople'; import OrganizationTags from 'screens/OrganizationTags/OrganizationTags'; import ManageTag from 'screens/ManageTag/ManageTag'; @@ -27,6 +28,7 @@ import Requests from 'screens/Requests/Requests'; import Users from 'screens/Users/Users'; import CommunityProfile from 'screens/CommunityProfile/CommunityProfile'; import OrganizationVenues from 'screens/OrganizationVenues/OrganizationVenues'; +import Leaderboard from 'screens/Leaderboard/Leaderboard'; import React, { useEffect } from 'react'; // User Portal Components @@ -41,13 +43,13 @@ import { useQuery } from '@apollo/client'; import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; import Advertisements from 'components/Advertisements/Advertisements'; import SecuredRouteForUser from 'components/UserPortal/SecuredRouteForUser/SecuredRouteForUser'; -import FundCampaignPledge from 'screens/FundCampaignPledge/FundCampaignPledge'; import useLocalStorage from 'utils/useLocalstorage'; import UserScreen from 'screens/UserPortal/UserScreen/UserScreen'; import EventDashboardScreen from 'components/EventDashboardScreen/EventDashboardScreen'; import Campaigns from 'screens/UserPortal/Campaigns/Campaigns'; import Pledges from 'screens/UserPortal/Pledges/Pledges'; +import VolunteerManagement from 'screens/UserPortal/Volunteer/VolunteerManagement'; const { setItem } = useLocalStorage(); @@ -148,10 +150,10 @@ function app(): JSX.Element { } /> } /> } /> - } /> + } /> } /> } /> } /> } /> } /> + } /> {extraRoutes} @@ -195,6 +198,10 @@ function app(): JSX.Element { } /> } /> } /> + } + /> }> {}, refetchAssignedMembersData: () => {}, - t: (key: string) => translations[key], - tCommon: (key: string) => translations[key], + t: ((key: string) => translations[key]) as TFunction< + 'translation', + 'manageTag' + >, + tCommon: ((key: string) => translations[key]) as TFunction< + 'common', + undefined + >, }; +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + getUserTag: { + merge(existing = {}, incoming) { + const merged = { + ...existing, + ...incoming, + usersToAssignTo: { + ...existing.usersToAssignTo, + ...incoming.usersToAssignTo, + edges: [ + ...(existing.usersToAssignTo?.edges || []), + ...(incoming.usersToAssignTo?.edges || []), + ], + }, + }; + + return merged; + }, + }, + }, + }, + }, +}); + const renderAddPeopleToTagModal = ( props: InterfaceAddPeopleToTagProps, link: ApolloLink, ): RenderResult => { return render( - + } /> @@ -84,7 +118,7 @@ describe('Organisation Tags Page', () => { ...jest.requireActual('react-router-dom'), useParams: () => ({ orgId: 'orgId' }), })); - // cache.reset(); + cache.reset(); }); afterEach(() => { @@ -140,6 +174,72 @@ describe('Organisation Tags Page', () => { userEvent.click(screen.getAllByTestId('deselectMemberBtn')[0]); }); + test('searchs for tags where the firstName matches the provided firstName search input', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.firstName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.firstName); + fireEvent.change(input, { target: { value: 'usersToAssignTo' } }); + + // should render the two users from the mock data + // where firstName starts with "usersToAssignTo" + await waitFor(() => { + const members = screen.getAllByTestId('memberName'); + expect(members.length).toEqual(2); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'usersToAssignTo user1', + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[1]).toHaveTextContent( + 'usersToAssignTo user2', + ); + }); + }); + + test('searchs for tags where the lastName matches the provided lastName search input', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.lastName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.lastName); + fireEvent.change(input, { target: { value: 'userToAssignTo' } }); + + // should render the two users from the mock data + // where lastName starts with "usersToAssignTo" + await waitFor(() => { + const members = screen.getAllByTestId('memberName'); + expect(members.length).toEqual(2); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'first userToAssignTo', + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[1]).toHaveTextContent( + 'second userToAssignTo', + ); + }); + }); + test('Renders more members with infinite scroll', async () => { const { getByText } = renderAddPeopleToTagModal(props, link); @@ -150,13 +250,15 @@ describe('Organisation Tags Page', () => { }); // Find the infinite scroll div by test ID or another selector - const scrollableDiv = screen.getByTestId('scrollableDiv'); + const addPeopleToTagScrollableDiv = screen.getByTestId( + 'addPeopleToTagScrollableDiv', + ); const initialMemberDataLength = screen.getAllByTestId('memberName').length; // Set scroll position to the bottom - fireEvent.scroll(scrollableDiv, { - target: { scrollY: scrollableDiv.scrollHeight }, + fireEvent.scroll(addPeopleToTagScrollableDiv, { + target: { scrollY: addPeopleToTagScrollableDiv.scrollHeight }, }); await waitFor(() => { @@ -167,11 +269,27 @@ describe('Organisation Tags Page', () => { }); }); + test('Toasts error when no one is selected while assigning', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('assignPeopleBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('assignPeopleBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.noOneSelected); + }); + }); + test('Assigns tag to multiple people', async () => { renderAddPeopleToTagModal(props, link); await wait(); + // select members and assign them await waitFor(() => { expect(screen.getAllByTestId('selectMemberBtn')[0]).toBeInTheDocument(); }); diff --git a/src/components/AddPeopleToTag/AddPeopleToTag.tsx b/src/components/AddPeopleToTag/AddPeopleToTag.tsx index a43a47b006..51d21a6a1c 100644 --- a/src/components/AddPeopleToTag/AddPeopleToTag.tsx +++ b/src/components/AddPeopleToTag/AddPeopleToTag.tsx @@ -1,17 +1,16 @@ -import type { ApolloError } from '@apollo/client'; import { useMutation, useQuery } from '@apollo/client'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { DataGrid } from '@mui/x-data-grid'; -import Loader from 'components/Loader/Loader'; import { USER_TAGS_MEMBERS_TO_ASSIGN_TO } from 'GraphQl/Queries/userTagQueries'; import type { ChangeEvent } from 'react'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Modal, Form, Button } from 'react-bootstrap'; import { useParams } from 'react-router-dom'; import type { InterfaceQueryUserTagsMembersToAssignTo } from 'utils/interfaces'; import styles from './AddPeopleToTag.module.css'; +import type { InterfaceTagUsersToAssignToQuery } from 'utils/organizationTagsUtils'; import { - ADD_PEOPLE_TO_TAGS_QUERY_LIMIT, + TAGS_QUERY_DATA_CHUNK_SIZE, dataGridStyle, } from 'utils/organizationTagsUtils'; import { Stack } from '@mui/material'; @@ -20,6 +19,8 @@ import { ADD_PEOPLE_TO_TAG } from 'GraphQl/Mutations/TagMutations'; import InfiniteScroll from 'react-infinite-scroll-component'; import { WarningAmberRounded } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import type { TFunction } from 'i18next'; /** * Props for the `AddPeopleToTag` component. @@ -28,8 +29,8 @@ export interface InterfaceAddPeopleToTagProps { addPeopleToTagModalIsOpen: boolean; hideAddPeopleToTagModal: () => void; refetchAssignedMembersData: () => void; - t: (key: string) => string; - tCommon: (key: string) => string; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; } interface InterfaceMemberData { @@ -53,69 +54,68 @@ const AddPeopleToTag: React.FC = ({ [], ); + const [memberToAssignToSearchFirstName, setMemberToAssignToSearchFirstName] = + useState(''); + const [memberToAssignToSearchLastName, setMemberToAssignToSearchLastName] = + useState(''); + const { data: userTagsMembersToAssignToData, loading: userTagsMembersToAssignToLoading, error: userTagsMembersToAssignToError, + refetch: userTagsMembersToAssignToRefetch, fetchMore: fetchMoreMembersToAssignTo, - }: { - data?: { - getUserTag: InterfaceQueryUserTagsMembersToAssignTo; - }; - loading: boolean; - error?: ApolloError; - fetchMore: (options: { + }: InterfaceTagUsersToAssignToQuery = useQuery( + USER_TAGS_MEMBERS_TO_ASSIGN_TO, + { variables: { - after?: string | null; - first?: number | null; - }; - updateQuery?: ( - previousQueryResult: { - getUserTag: InterfaceQueryUserTagsMembersToAssignTo; - }, - options: { - fetchMoreResult: { - getUserTag: InterfaceQueryUserTagsMembersToAssignTo; - }; + id: currentTagId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: memberToAssignToSearchFirstName }, + lastName: { starts_with: memberToAssignToSearchLastName }, }, - ) => { getUserTag: InterfaceQueryUserTagsMembersToAssignTo }; - }) => Promise; - } = useQuery(USER_TAGS_MEMBERS_TO_ASSIGN_TO, { - variables: { - id: currentTagId, - first: ADD_PEOPLE_TO_TAGS_QUERY_LIMIT, + }, + skip: !addPeopleToTagModalIsOpen, }, - skip: !addPeopleToTagModalIsOpen, - }); + ); + + useEffect(() => { + setMemberToAssignToSearchFirstName(''); + setMemberToAssignToSearchLastName(''); + userTagsMembersToAssignToRefetch(); + }, [addPeopleToTagModalIsOpen]); const loadMoreMembersToAssignTo = (): void => { fetchMoreMembersToAssignTo({ variables: { - first: ADD_PEOPLE_TO_TAGS_QUERY_LIMIT, + first: TAGS_QUERY_DATA_CHUNK_SIZE, after: - userTagsMembersToAssignToData?.getUserTag.usersToAssignTo.pageInfo - .endCursor, // Fetch after the last loaded cursor + userTagsMembersToAssignToData?.getUsersToAssignTo.usersToAssignTo + .pageInfo.endCursor, // Fetch after the last loaded cursor }, updateQuery: ( - prevResult: { getUserTag: InterfaceQueryUserTagsMembersToAssignTo }, + prevResult: { + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }, { fetchMoreResult, }: { fetchMoreResult: { - getUserTag: InterfaceQueryUserTagsMembersToAssignTo; + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; }; }, ) => { - if (!fetchMoreResult) return prevResult; + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; return { - getUserTag: { - ...fetchMoreResult.getUserTag, + getUsersToAssignTo: { + ...fetchMoreResult.getUsersToAssignTo, usersToAssignTo: { - ...fetchMoreResult.getUserTag.usersToAssignTo, + ...fetchMoreResult.getUsersToAssignTo.usersToAssignTo, edges: [ - ...prevResult.getUserTag.usersToAssignTo.edges, - ...fetchMoreResult.getUserTag.usersToAssignTo.edges, + ...prevResult.getUsersToAssignTo.usersToAssignTo.edges, + ...fetchMoreResult.getUsersToAssignTo.usersToAssignTo.edges, ], }, }, @@ -125,9 +125,9 @@ const AddPeopleToTag: React.FC = ({ }; const userTagMembersToAssignTo = - userTagsMembersToAssignToData?.getUserTag.usersToAssignTo.edges.map( + userTagsMembersToAssignToData?.getUsersToAssignTo.usersToAssignTo.edges.map( (edge) => edge.node, - ); + ) ?? /* istanbul ignore next */ []; const handleAddOrRemoveMember = (member: InterfaceMemberData): void => { setAssignToMembers((prevMembers) => { @@ -154,6 +154,11 @@ const AddPeopleToTag: React.FC = ({ ): Promise => { e.preventDefault(); + if (!assignToMembers.length) { + toast.error(t('noOneSelected')); + return; + } + try { const { data } = await addPeople({ variables: { @@ -168,8 +173,7 @@ const AddPeopleToTag: React.FC = ({ hideAddPeopleToTagModal(); setAssignToMembers([]); } - } catch (error: unknown) { - /* istanbul ignore next */ + } catch (error: unknown) /* istanbul ignore next */ { const errorMessage = error instanceof Error ? error.message : tErrors('unknownError'); toast.error(errorMessage); @@ -267,37 +271,70 @@ const AddPeopleToTag: React.FC = ({
+
+ {assignToMembers.length === 0 ? ( +
+ {t('noOneSelected')} +
+ ) : ( + assignToMembers.map((member) => ( +
+ {member.firstName} {member.lastName} + removeMember(member._id)} + data-testid="clearSelectedMember" + /> +
+ )) + )} +
+ +
+
+ + + setMemberToAssignToSearchFirstName(e.target.value.trim()) + } + data-testid="searchByFirstName" + autoComplete="off" + /> +
+
+ + + setMemberToAssignToSearchLastName(e.target.value.trim()) + } + data-testid="searchByLastName" + autoComplete="off" + /> +
+
+ {userTagsMembersToAssignToLoading ? ( - +
+ +
) : ( <>
- {assignToMembers.length === 0 ? ( -
- {t('noOneSelected')} -
- ) : ( - assignToMembers.map((member) => ( -
- {member.firstName} {member.lastName} - removeMember(member._id)} - data-testid="clearSelectedMember" - /> -
- )) - )} -
- -
= ({ dataLength={userTagMembersToAssignTo?.length ?? 0} // This is important field to render the next data next={loadMoreMembersToAssignTo} hasMore={ - userTagsMembersToAssignToData?.getUserTag.usersToAssignTo - .pageInfo.hasNextPage ?? false - } - loader={ -
-
-
+ userTagsMembersToAssignToData?.getUsersToAssignTo + .usersToAssignTo.pageInfo.hasNextPage ?? + /* istanbul ignore next */ false } - scrollableTarget="scrollableDiv" + loader={} + scrollableTarget="addPeopleToTagScrollableDiv" > row._id} + getRowId={(row) => row.id} slots={{ noRowsOverlay: /* istanbul ignore next */ () => ( = ({ alignItems="center" justifyContent="center" > - {t('assignedToAll')} + {t('noMoreMembersFound')} ), }} - sx={dataGridStyle} + sx={{ + ...dataGridStyle, + '& .MuiDataGrid-topContainer': { + position: 'static', + }, + '& .MuiDataGrid-virtualScrollerContent': { + marginTop: '0', + }, + }} getRowClassName={() => `${styles.rowBackground}`} autoHeight rowHeight={65} diff --git a/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts b/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts index ab185bb858..fbaf812186 100644 --- a/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts +++ b/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts @@ -1,5 +1,6 @@ import { ADD_PEOPLE_TO_TAG } from 'GraphQl/Mutations/TagMutations'; import { USER_TAGS_MEMBERS_TO_ASSIGN_TO } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; export const MOCKS = [ { @@ -7,12 +8,16 @@ export const MOCKS = [ query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, variables: { id: '1', - first: 7, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, }, }, result: { data: { - getUserTag: { + getUsersToAssignTo: { name: 'tag1', usersToAssignTo: { edges: [ @@ -72,14 +77,38 @@ export const MOCKS = [ }, cursor: '7', }, + { + node: { + _id: '8', + firstName: 'member', + lastName: '8', + }, + cursor: '8', + }, + { + node: { + _id: '9', + firstName: 'member', + lastName: '9', + }, + cursor: '9', + }, + { + node: { + _id: '10', + firstName: 'member', + lastName: '10', + }, + cursor: '10', + }, ], pageInfo: { startCursor: '1', - endCursor: '7', + endCursor: '10', hasNextPage: true, hasPreviousPage: false, }, - totalCount: 10, + totalCount: 12, }, }, }, @@ -90,48 +119,138 @@ export const MOCKS = [ query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, variables: { id: '1', - first: 7, - after: '7', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, }, }, result: { data: { - getUserTag: { + getUsersToAssignTo: { name: 'tag1', usersToAssignTo: { edges: [ { node: { - _id: '8', + _id: '11', firstName: 'member', - lastName: '8', + lastName: '11', }, - cursor: '8', + cursor: '11', }, { node: { - _id: '9', + _id: '12', firstName: 'member', - lastName: '9', + lastName: '12', }, - cursor: '9', + cursor: '12', }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: 'usersToAssignTo' }, + lastName: { starts_with: '' }, + }, + }, + }, + result: { + data: { + getUsersToAssignTo: { + name: 'tag1', + usersToAssignTo: { + edges: [ { node: { - _id: '10', - firstName: 'member', - lastName: '10', + _id: '1', + firstName: 'usersToAssignTo', + lastName: 'user1', }, - cursor: '10', + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'usersToAssignTo', + lastName: 'user2', + }, + cursor: '2', }, ], pageInfo: { - startCursor: '8', - endCursor: '10', + startCursor: '1', + endCursor: '2', hasNextPage: false, - hasPreviousPage: true, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: 'userToAssignTo' }, + }, + }, + }, + result: { + data: { + getUsersToAssignTo: { + name: 'tag1', + usersToAssignTo: { + edges: [ + { + node: { + _id: '1', + firstName: 'first', + lastName: 'userToAssignTo', + }, + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'second', + lastName: 'userToAssignTo', + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, }, - totalCount: 10, + totalCount: 2, }, }, }, @@ -161,7 +280,11 @@ export const MOCKS_ERROR = [ query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, variables: { id: '1', - first: 7, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, }, }, error: new Error('Mock Graphql Error'), diff --git a/src/components/EventCalendar/EventCalendar.test.tsx b/src/components/EventCalendar/EventCalendar.test.tsx index caca516763..8e2395968a 100644 --- a/src/components/EventCalendar/EventCalendar.test.tsx +++ b/src/components/EventCalendar/EventCalendar.test.tsx @@ -286,13 +286,14 @@ describe('Calendar', () => { // expect(todayCell).toHaveClass(styles.day__today); }); it('Should handle window resize in day view', async () => { + const date = new Date().toISOString().split('T')[0]; const multipleEventData = [ { _id: '1', title: 'Event 1', description: 'This is event 1', - startDate: new Date().toISOString().split('T')[0], - endDate: new Date().toISOString().split('T')[0], + startDate: date, + endDate: date, location: 'Los Angeles', startTime: null, endTime: null, @@ -307,8 +308,8 @@ describe('Calendar', () => { _id: '2', title: 'Event 2', description: 'This is event 2', - startDate: new Date().toISOString().split('T')[0], - endDate: new Date().toISOString().split('T')[0], + startDate: date, + endDate: date, location: 'Los Angeles', startTime: null, endTime: null, @@ -323,8 +324,8 @@ describe('Calendar', () => { _id: '3', title: 'Event 3', description: 'This is event 3', - startDate: new Date().toISOString().split('T')[0], - endDate: new Date().toISOString().split('T')[0], + startDate: date, + endDate: date, location: 'Los Angeles', startTime: '14:00', endTime: '16:00', @@ -339,8 +340,8 @@ describe('Calendar', () => { _id: '4', title: 'Event 4', description: 'This is event 4', - startDate: new Date().toISOString().split('T')[0], - endDate: new Date().toISOString().split('T')[0], + startDate: date, + endDate: date, location: 'Los Angeles', startTime: '14:00', endTime: '16:00', @@ -355,8 +356,8 @@ describe('Calendar', () => { _id: '5', title: 'Event 5', description: 'This is event 5', - startDate: new Date().toISOString().split('T')[0], - endDate: new Date().toISOString().split('T')[0], + startDate: date, + endDate: date, location: 'Los Angeles', startTime: '17:00', endTime: '19:00', @@ -372,14 +373,40 @@ describe('Calendar', () => { - + - , , ); + + // Simulate window resize and check if components respond correctly + await act(async () => { + window.innerWidth = 500; // Set the window width to <= 700 + window.dispatchEvent(new Event('resize')); + }); + + // Check for "View all" button if there are more than 2 events + const viewAllButton = await screen.findAllByTestId('more'); + console.log('hi', viewAllButton); // This will show the buttons found in the test + expect(viewAllButton.length).toBeGreaterThan(0); + + // Simulate clicking the "View all" button to expand the list + fireEvent.click(viewAllButton[0]); + + const event5 = screen.queryByText('Event 5'); + expect(event5).toBeNull(); + + const viewLessButtons = screen.getAllByText('View less'); + expect(viewLessButtons.length).toBeGreaterThan(0); + + // Simulate clicking "View less" to collapse the list + fireEvent.click(viewLessButtons[0]); + const viewAllButtons = screen.getAllByText('View all'); + expect(viewAllButtons.length).toBeGreaterThan(0); + + // Reset the window size to avoid side effects for other tests await act(async () => { - window.innerWidth = 500; + window.innerWidth = 1024; window.dispatchEvent(new Event('resize')); }); }); diff --git a/src/components/EventCalendar/EventCalendar.tsx b/src/components/EventCalendar/EventCalendar.tsx index fb76eb597c..592456f295 100644 --- a/src/components/EventCalendar/EventCalendar.tsx +++ b/src/components/EventCalendar/EventCalendar.tsx @@ -558,6 +558,7 @@ const Calendar: React.FC = ({ /*istanbul ignore next*/ - + ) ) : (
+ )) + )} +
+ +
+ + setTagSearchName(e.target.value.trim())} + data-testid="searchByName" + autoComplete="off" + /> +
+ +
+ {t('allTags')} +
{orgUserTagsLoading ? ( - +
+ +
) : ( <> -
- {selectedTags.length === 0 ? ( -
- {t('noTagSelected')} -
- ) : ( - selectedTags.map((tag: InterfaceTagData) => ( -
- {tag.name} -
- )) - )} -
- -
- {t('allTags')} -
-
= ({ height: 300, overflow: 'auto', }} - className={`${styles.scrContainer}`} > = ({ orgUserTagsData?.organizations[0].userTags.pageInfo .hasNextPage ?? false } - loader={ -
-
-
- } + loader={} scrollableTarget="scrollableDiv" > {userTagsList?.map((tag) => ( -
- +
+
+ +
+ + {/* Ancestor tags breadcrumbs positioned at the end of TagNode */} + {tag.parentTag && ( +
+ <>{'('} + {tag.ancestorTags?.map((ancestorTag) => ( + + {ancestorTag.name} + + + ))} + <>{')'} +
+ )}
))} @@ -396,7 +389,7 @@ const TagActions: React.FC = ({
+ ); +} + +export default volunteerContainer; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx new file mode 100644 index 0000000000..05c2dab5ff --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './VolunteerGroups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceDeleteVolunteerGroupModal } from './VolunteerGroupDeleteModal'; +import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceDeleteVolunteerGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupDeleteModal = ( + link: ApolloLink, + props: InterfaceDeleteVolunteerGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing Group Delete Modal', () => { + it('Delete Group', async () => { + renderGroupDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupDeleted); + }); + }); + + it('Close Delete Modal', async () => { + renderGroupDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const noBtn = screen.getByTestId('deletenobtn'); + expect(noBtn).toBeInTheDocument(); + userEvent.click(noBtn); + + await waitFor(() => { + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('Delete Group -> Error', async () => { + renderGroupDeleteModal(link2, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx new file mode 100644 index 0000000000..33132bfd33 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx @@ -0,0 +1,100 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; +import { DELETE_VOLUNTEER_GROUP } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceDeleteVolunteerGroupModal { + isOpen: boolean; + hide: () => void; + group: InterfaceVolunteerGroupInfo | null; + refetchGroups: () => void; +} + +/** + * A modal dialog for confirming the deletion of a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param group - The volunteer group to be deleted. + * @param refetchGroups - Function to refetch the volunteer groups after deletion. + * + * @returns The rendered modal component. + * + * + * The `VolunteerGroupDeleteModal` component displays a confirmation dialog when a user attempts to delete a volunteer group. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deleteVolunteerGroup` mutation is called to remove the volunteer group from the database, + * and the `refetchGroups` function is invoked to update the list of volunteer groups. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deleteVolunteerGroup` mutation is used to perform the deletion operation. + */ + +const VolunteerGroupDeleteModal: React.FC< + InterfaceDeleteVolunteerGroupModal +> = ({ isOpen, hide, group, refetchGroups }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [deleteVolunteerGroup] = useMutation(DELETE_VOLUNTEER_GROUP); + + const deleteHandler = async (): Promise => { + try { + await deleteVolunteerGroup({ + variables: { + id: group?._id, + }, + }); + refetchGroups(); + hide(); + toast.success(t('volunteerGroupDeleted')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

{t('deleteGroup')}

+ +
+ +

{t('deleteVolunteerGroupMsg')}

+
+ + + + +
+ + ); +}; +export default VolunteerGroupDeleteModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx new file mode 100644 index 0000000000..2fc0b2e348 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './VolunteerGroups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceVolunteerGroupModal } from './VolunteerGroupModal'; +import GroupModal from './VolunteerGroupModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'create', + group: null, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'edit', + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: 2, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'edit', + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupModal = ( + link: ApolloLink, + props: InterfaceVolunteerGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerGroupModal', () => { + it('GroupModal -> Create', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getAllByText(t.createGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 1' } }); + expect(nameInput).toHaveValue('Group 1'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc' } }); + expect(descInput).toHaveValue('desc'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + // Select Leader + const memberSelect = await screen.findByTestId('leaderSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Volunteers + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupCreated); + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Create -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getAllByText(t.createGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 1' } }); + expect(nameInput).toHaveValue('Group 1'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc' } }); + expect(descInput).toHaveValue('desc'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + // Select Leader + const memberSelect = await screen.findByTestId('leaderSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Volunteers + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Update', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupUpdated); + expect(itemProps[1].refetchGroups).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Details -> Update -> Error', async () => { + renderGroupModal(link2, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('Try adding different values for volunteersRequired', async () => { + renderGroupModal(link1, itemProps[2]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '-1' } }); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + userEvent.clear(vrInput); + userEvent.type(vrInput, '1{backspace}'); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '0' } }); + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '19' } }); + await waitFor(() => { + expect(vrInput).toHaveValue('19'); + }); + }); + + it('GroupModal -> Update -> No values updated', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx new file mode 100644 index 0000000000..5bfb1eff2b --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx @@ -0,0 +1,341 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { + InterfaceCreateVolunteerGroup, + InterfaceUserInfo, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { Autocomplete, FormControl, TextField } from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { + CREATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_GROUP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceVolunteerGroupModal { + isOpen: boolean; + hide: () => void; + eventId: string; + orgId: string; + group: InterfaceVolunteerGroupInfo | null; + refetchGroups: () => void; + mode: 'create' | 'edit'; +} + +/** + * A modal dialog for creating or editing a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer group. + * @param orgId - The ID of the organization associated with volunteer group. + * @param group - The volunteer group object to be edited. + * @param refetchGroups - Function to refetch the volunteer groups after creation or update. + * @param mode - The mode of the modal (create or edit). + * @returns The rendered modal component. + * + * The `VolunteerGroupModal` component displays a form within a modal dialog for creating or editing a Volunteer Group. + * It includes fields for entering the group name, description, volunteersRequired, and selecting volunteers/leaders. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - An input field for entering the group name. + * - A textarea for entering the group description. + * - A multi-select dropdown for selecting leader. + * - A multi-select dropdown for selecting volunteers. + * - An input field for entering the number of volunteers required. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updatePledge` mutation to update an existing pledge, or + * - Calls `createPledge` mutation to create a new pledge. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const VolunteerGroupModal: React.FC = ({ + isOpen, + hide, + eventId, + orgId, + group, + refetchGroups, + mode, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [formState, setFormState] = useState({ + name: group?.name ?? '', + description: group?.description ?? '', + leader: group?.leader ?? null, + volunteerUsers: group?.volunteers.map((volunteer) => volunteer.user) ?? [], + volunteersRequired: group?.volunteersRequired ?? null, + }); + const [members, setMembers] = useState([]); + + const [updateVolunteerGroup] = useMutation(UPDATE_VOLUNTEER_GROUP); + const [createVolunteerGroup] = useMutation(CREATE_VOLUNTEER_GROUP); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + setFormState({ + name: group?.name ?? '', + description: group?.description ?? '', + leader: group?.leader ?? null, + volunteerUsers: + group?.volunteers.map((volunteer) => volunteer.user) ?? [], + volunteersRequired: group?.volunteersRequired ?? null, + }); + }, [group]); + + useEffect(() => { + if (memberData) { + setMembers(memberData.organizations[0].members); + } + }, [memberData]); + + const { name, description, leader, volunteerUsers, volunteersRequired } = + formState; + + const updateGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const updatedFields: { + [key: string]: number | string | undefined | null; + } = {}; + + if (name !== group?.name) { + updatedFields.name = name; + } + if (description !== group?.description) { + updatedFields.description = description; + } + if (volunteersRequired !== group?.volunteersRequired) { + updatedFields.volunteersRequired = volunteersRequired; + } + try { + await updateVolunteerGroup({ + variables: { + id: group?._id, + data: { ...updatedFields, eventId }, + }, + }); + toast.success(t('volunteerGroupUpdated')); + refetchGroups(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, group], + ); + + // Function to create a new volunteer group + const createGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await createVolunteerGroup({ + variables: { + data: { + eventId, + leaderId: leader?._id, + name, + description, + volunteersRequired, + volunteerUserIds: volunteerUsers.map((user) => user._id), + }, + }, + }); + + toast.success(t('volunteerGroupCreated')); + refetchGroups(); + setFormState({ + name: '', + description: '', + leader: null, + volunteerUsers: [], + volunteersRequired: null, + }); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, eventId], + ); + + return ( + + +

+ {t(mode === 'edit' ? 'updateGroup' : 'createGroup')} +

+ +
+ + + {/* Input field to enter the group name */} + + + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {/* Input field to enter the group description */} + + + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + {/* A dropdown to select leader for volunteer group */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newLeader): void => { + if (newLeader) { + setFormState({ + ...formState, + leader: newLeader, + volunteerUsers: [...volunteerUsers, newLeader], + }); + } else { + setFormState({ + ...formState, + leader: null, + volunteerUsers: volunteerUsers.filter( + (user) => user._id !== leader?._id, + ), + }); + } + } + } + renderInput={(params) => ( + + )} + /> + + + {/* A Multi-select dropdown to select more than one volunteer */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + disabled={mode === 'edit'} + onChange={ + /*istanbul ignore next*/ + (_, newUsers): void => { + setFormState({ + ...formState, + volunteerUsers: newUsers, + }); + } + } + renderInput={(params) => ( + + )} + /> + + + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + volunteersRequired: parseInt(e.target.value), + }); + } else if (e.target.value === '') { + setFormState({ + ...formState, + volunteersRequired: null, + }); + } + }} + /> + + + + {/* Button to submit the volunteer group form */} + + + +
+ ); +}; +export default VolunteerGroupModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx new file mode 100644 index 0000000000..94c34923a2 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import type { InterfaceVolunteerGroupViewModal } from './VolunteerGroupViewModal'; +import VolunteerGroupViewModal from './VolunteerGroupViewModal'; + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerGroupViewModal[] = [ + { + isOpen: true, + hide: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: 10, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'img--url', + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupViewModal = ( + props: InterfaceVolunteerGroupViewModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerGroupViewModal', () => { + it('Render VolunteerGroupViewModal (variation 1)', async () => { + renderGroupViewModal(itemProps[0]); + expect(screen.getByText(t.groupDetails)).toBeInTheDocument(); + expect(screen.getByTestId('leader_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('creator_avatar')).toBeInTheDocument(); + }); + + it('Render VolunteerGroupViewModal (variation 2)', async () => { + renderGroupViewModal(itemProps[1]); + expect(screen.getByText(t.groupDetails)).toBeInTheDocument(); + expect(screen.getByTestId('leader_image')).toBeInTheDocument(); + expect(screen.getByTestId('creator_image')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx new file mode 100644 index 0000000000..70994bd4e5 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx @@ -0,0 +1,235 @@ +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; + +export interface InterfaceVolunteerGroupViewModal { + isOpen: boolean; + hide: () => void; + group: InterfaceVolunteerGroupInfo; +} + +/** + * A modal dialog for viewing volunteer group information for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param group - The volunteer group to display in the modal. + * + * @returns The rendered modal component. + * + * The `VolunteerGroupViewModal` component displays all the fields of a volunteer group in a modal dialog. + * + * The modal includes: + * - A header with a title and a close button. + * - fields for volunteer name, status, hours volunteered, groups, and assignments. + */ + +const VolunteerGroupViewModal: React.FC = ({ + isOpen, + hide, + group, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const { leader, creator, name, volunteersRequired, description, volunteers } = + group; + + return ( + + +

{t('groupDetails')}

+ +
+ +
+ {/* Group name & Volunteers Required */} + + + + + {description && ( + + + + )} + + {/* Input field to enter the group description */} + {description && ( + + + + + + )} + + + + {leader.image ? ( + Volunteer + ) : ( +
+ +
+ )} + + ), + }} + /> +
+ + + + {creator.image ? ( + Volunteer + ) : ( +
+ +
+ )} + + ), + }} + /> +
+
+ {/* Table for Associated Volunteers */} + {volunteers && volunteers.length > 0 && ( + + + Volunteers + + + + + + + Sr. No. + Name + + + + {volunteers.map((volunteer, index) => { + const { firstName, lastName } = volunteer.user; + return ( + + + {index + 1} + + + {firstName + ' ' + lastName} + + + ); + })} + +
+
+
+ )} +
+
+
+ ); +}; +export default VolunteerGroupViewModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts new file mode 100644 index 0000000000..b8cc52e875 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts @@ -0,0 +1,452 @@ +import { + CREATE_VOLUNTEER_GROUP, + DELETE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_GROUP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +const group1 = { + _id: 'groupId1', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +const group2 = { + _id: 'groupId2', + name: 'Group 2', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:25:13.044Z', + creator: { + _id: 'creatorId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId2', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +const group3 = { + _id: 'groupId3', + name: 'Group 3', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'creatorId3', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Bruce', + lastName: 'Garza', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_DESC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2], + }, + }, + }, + + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_ASC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group2, group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '1', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: '', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: 'Bruce', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group3], + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_GROUP, + variables: { + data: { + eventId: 'eventId', + leaderId: 'userId', + name: 'Group 1', + description: 'desc', + volunteerUserIds: ['userId', 'userId2'], + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + createEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: DELETE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + }, + }, + result: { + data: { + removeEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_GROUP, + variables: { + data: { + eventId: 'eventId', + leaderId: 'userId', + name: 'Group 1', + description: 'desc', + volunteerUserIds: ['userId', 'userId2'], + volunteersRequired: 10, + }, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + error: new Error('An error occurred'), + }, +]; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx new file mode 100644 index 0000000000..0dcba34a3a --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx @@ -0,0 +1,232 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import VolunteerGroups from './VolunteerGroups'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './VolunteerGroups.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderVolunteerGroups = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
} + /> + + + + + + , + ); +}; + +describe('Testing VolunteerGroups Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> +
} + /> + +
+
+
+
, + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Groups screen', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by members_DESC + fireEvent.click(sortBtn); + const volunteersDESC = await screen.findByTestId('volunteers_DESC'); + expect(volunteersDESC).toBeInTheDocument(); + fireEvent.click(volunteersDESC); + + let groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + + // Sort by members_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const volunteersASC = await screen.findByTestId('volunteers_ASC'); + expect(volunteersASC).toBeInTheDocument(); + fireEvent.click(volunteersASC); + + groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 2'); + }); + + it('Search by Groups', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByGroup = await screen.findByTestId('group'); + expect(searchByGroup).toBeInTheDocument(); + userEvent.click(searchByGroup); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Leader', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLeader = await screen.findByTestId('leader'); + expect(searchByLeader).toBeInTheDocument(); + userEvent.click(searchByLeader); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'Bruce'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('should render screen with No Groups', async () => { + renderVolunteerGroups(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteerGroups)).toBeInTheDocument(); + }); + }); + + it('Error while fetching groups data', async () => { + renderVolunteerGroups(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ViewModal', async () => { + renderVolunteerGroups(link1); + + const viewGroupBtn = await screen.findAllByTestId('viewGroupBtn'); + userEvent.click(viewGroupBtn[0]); + + expect(await screen.findByText(t.groupDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('volunteerViewModalCloseBtn')); + }); + + it('Open and Close Delete Modal', async () => { + renderVolunteerGroups(link1); + + const deleteGroupBtn = await screen.findAllByTestId('deleteGroupBtn'); + userEvent.click(deleteGroupBtn[0]); + + expect(await screen.findByText(t.deleteGroup)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close GroupModal (Edit)', async () => { + renderVolunteerGroups(link1); + + const editGroupBtn = await screen.findAllByTestId('editGroupBtn'); + userEvent.click(editGroupBtn[0]); + + expect(await screen.findAllByText(t.updateGroup)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close GroupModal (Create)', async () => { + renderVolunteerGroups(link1); + + const createGroupBtn = await screen.findByTestId('createGroupBtn'); + userEvent.click(createGroupBtn); + + expect(await screen.findAllByText(t.createGroup)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx new file mode 100644 index 0000000000..fa98abc9f2 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx @@ -0,0 +1,444 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import VolunteerGroupModal from './VolunteerGroupModal'; +import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; +import VolunteerGroupViewModal from './VolunteerGroupViewModal'; + +enum ModalState { + SAME = 'same', + DELETE = 'delete', + VIEW = 'view', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing volunteer groups for an event. + * This component allows users to view, filter, sort, and create action items. It also provides a modal for creating and editing action items. + * @returns The rendered component. + */ +function volunteerGroups(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [group, setGroup] = useState(null); + const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'volunteers_ASC' | 'volunteers_DESC' | null + >(null); + const [searchBy, setSearchBy] = useState<'leader' | 'group'>('group'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.SAME]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + }); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + loading: groupsLoading, + error: groupsError, + refetch: refetchGroups, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: eventId, + leaderName: searchBy === 'leader' ? searchTerm : null, + name_contains: searchBy === 'group' ? searchTerm : null, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (group: InterfaceVolunteerGroupInfo | null, modal: ModalState): void => { + if (modal === ModalState.SAME) { + setModalMode(group ? 'edit' : 'create'); + } + setGroup(group); + openModal(modal); + }, + [openModal], + ); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + if (groupsLoading) { + return ; + } + + if (groupsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteer Groups' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'group', + headerName: 'Group', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.name} +
+ ); + }, + }, + { + field: 'leader', + headerName: 'Leader', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.leader; + return ( +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ); + }, + }, + { + field: 'actions', + headerName: 'Actions Completed', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.assignments.length} +
+ ); + }, + }, + { + field: 'volunteers', + headerName: 'No. of Volunteers', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.volunteers.length} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('leader')} + data-testid="leader" + > + {t('leader')} + + setSearchBy('group')} + data-testid="group" + > + {t('group')} + + + + + + + {tCommon('sort')} + + + setSortBy('volunteers_DESC')} + data-testid="volunteers_DESC" + > + {t('mostVolunteers')} + + setSortBy('volunteers_ASC')} + data-testid="volunteers_ASC" + > + {t('leastVolunteers')} + + + +
+
+ +
+
+
+ + {/* Table with Volunteer Groups */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteerGroups')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={groups.map((group, index) => ({ + id: index + 1, + ...group, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.SAME)} + refetchGroups={refetchGroups} + eventId={eventId} + orgId={orgId} + group={group} + mode={modalMode} + /> + + {group && ( + <> + closeModal(ModalState.VIEW)} + group={group} + /> + + closeModal(ModalState.DELETE)} + refetchGroups={refetchGroups} + group={group} + /> + + )} +
+ ); +} + +export default volunteerGroups; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx new file mode 100644 index 0000000000..cac8fe94f0 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceVolunteerCreateModal } from './VolunteerCreateModal'; +import VolunteerCreateModal from './VolunteerCreateModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerCreateModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchVolunteers: jest.fn(), + }, +]; + +const renderCreateModal = ( + link: ApolloLink, + props: InterfaceVolunteerCreateModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerCreateModal', () => { + it('VolunteerCreateModal -> Create', async () => { + renderCreateModal(link1, itemProps[0]); + expect(screen.getAllByText(t.addVolunteer)).toHaveLength(2); + + // Select Volunteers + const membersSelect = await screen.findByTestId('membersSelect'); + expect(membersSelect).toBeInTheDocument(); + const volunteerInputField = within(membersSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerAdded); + expect(itemProps[0].refetchVolunteers).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('VolunteerCreateModal -> Create -> Error', async () => { + renderCreateModal(link2, itemProps[0]); + expect(screen.getAllByText(t.addVolunteer)).toHaveLength(2); + + // Select Volunteers + const membersSelect = await screen.findByTestId('membersSelect'); + expect(membersSelect).toBeInTheDocument(); + const volunteerInputField = within(membersSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx new file mode 100644 index 0000000000..6b4a1e3f0c --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx @@ -0,0 +1,152 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceUserInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { Autocomplete, TextField } from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { ADD_VOLUNTEER } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceVolunteerCreateModal { + isOpen: boolean; + hide: () => void; + eventId: string; + orgId: string; + refetchVolunteers: () => void; +} + +/** + * A modal dialog for add a volunteer for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer. + * @param orgId - The ID of the organization associated with volunteer. + * @param refetchVolunteers - Function to refetch the volunteers after adding a volunteer. + * + * @returns The rendered modal component. + * + * The `VolunteerCreateModal` component displays a form within a modal dialog for adding a volunteer. + * It includes fields for selecting user. + * + * The modal includes: + * - A header with a title and a close button. + * - A form with: + * - A multi-select dropdown for selecting user be added as volunteer. + * - A submit button to create or update the pledge. + * + * On form submission, the component: + * - Calls `addVolunteer` mutation to add a new Volunteer. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const VolunteerCreateModal: React.FC = ({ + isOpen, + hide, + eventId, + orgId, + refetchVolunteers, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + + const [userId, setUserId] = useState(''); + const [members, setMembers] = useState([]); + const [addVolunteer] = useMutation(ADD_VOLUNTEER); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + if (memberData) { + setMembers(memberData.organizations[0].members); + } + }, [memberData]); + + // Function to add a volunteer for an event + const addVolunteerHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await addVolunteer({ + variables: { + data: { + eventId, + userId, + }, + }, + }); + + toast.success(t('volunteerAdded')); + refetchVolunteers(); + setUserId(''); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [userId, eventId], + ); + + return ( + + +

{t('addVolunteer')}

+ +
+ +
+ {/* A Multi-select dropdown enables admin to invite a member as volunteer */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newVolunteer): void => { + setUserId(newVolunteer?._id ?? ''); + } + } + renderInput={(params) => } + /> + + + {/* Button to submit the volunteer form */} + +
+
+
+ ); +}; +export default VolunteerCreateModal; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx new file mode 100644 index 0000000000..dd9d6d5985 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceDeleteVolunteerModal } from './VolunteerDeleteModal'; +import VolunteerDeleteModal from './VolunteerDeleteModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceDeleteVolunteerModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchVolunteers: jest.fn(), + volunteer: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + }, +]; + +const renderVolunteerDeleteModal = ( + link: ApolloLink, + props: InterfaceDeleteVolunteerModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing Volunteer Delete Modal', () => { + it('Delete Volunteer', async () => { + renderVolunteerDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].refetchVolunteers).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.volunteerRemoved); + }); + }); + + it('Close Delete Modal', async () => { + renderVolunteerDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const noBtn = screen.getByTestId('deletenobtn'); + expect(noBtn).toBeInTheDocument(); + userEvent.click(noBtn); + + await waitFor(() => { + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('Delete Volunteer -> Error', async () => { + renderVolunteerDeleteModal(link2, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx new file mode 100644 index 0000000000..8f253fdf50 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx @@ -0,0 +1,103 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; +import { DELETE_VOLUNTEER } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceDeleteVolunteerModal { + isOpen: boolean; + hide: () => void; + volunteer: InterfaceEventVolunteerInfo; + refetchVolunteers: () => void; +} + +/** + * A modal dialog for confirming the deletion of a volunteer. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param volunteer - The volunteer object to be deleted. + * @param refetchVolunteers - Function to refetch the volunteers after deletion. + * + * @returns The rendered modal component. + * + * + * The `VolunteerDeleteModal` component displays a confirmation dialog when a user attempts to delete a volunteer. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deleteVolunteer` mutation is called to remove the pledge from the database, + * and the `refetchVolunteers` function is invoked to update the list of volunteers. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deleteVolunteer` mutation is used to perform the deletion operation. + */ + +const VolunteerDeleteModal: React.FC = ({ + isOpen, + hide, + volunteer, + refetchVolunteers, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [deleteVolunteer] = useMutation(DELETE_VOLUNTEER); + + const deleteHandler = async (): Promise => { + try { + await deleteVolunteer({ + variables: { + id: volunteer._id, + }, + }); + refetchVolunteers(); + hide(); + toast.success(t('volunteerRemoved')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

{t('removeVolunteer')}

+ +
+ +

{t('removeVolunteerMsg')}

+
+ + + + +
+ + ); +}; +export default VolunteerDeleteModal; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx new file mode 100644 index 0000000000..155dba8464 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import type { InterfaceVolunteerViewModal } from './VolunteerViewModal'; +import VolunteerViewModal from './VolunteerViewModal'; + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerViewModal[] = [ + { + isOpen: true, + hide: jest.fn(), + volunteer: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + }, + { + isOpen: true, + hide: jest.fn(), + volunteer: { + _id: 'volunteerId2', + hasAccepted: false, + hoursVolunteered: null, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: 'img-url', + }, + assignments: [], + groups: [], + }, + }, +]; + +const renderVolunteerViewModal = ( + props: InterfaceVolunteerViewModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerViewModal', () => { + it('Render VolunteerViewModal (variation 1)', async () => { + renderVolunteerViewModal(itemProps[0]); + expect(screen.getByText(t.volunteerDetails)).toBeInTheDocument(); + expect(screen.getByTestId('volunteer_avatar')).toBeInTheDocument(); + }); + + it('Render VolunteerViewModal (variation 2)', async () => { + renderVolunteerViewModal(itemProps[1]); + expect(screen.getByText(t.volunteerDetails)).toBeInTheDocument(); + expect(screen.getByTestId('volunteer_image')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx new file mode 100644 index 0000000000..0904d34b9c --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx @@ -0,0 +1,202 @@ +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import { HistoryToggleOff, TaskAlt } from '@mui/icons-material'; + +export interface InterfaceVolunteerViewModal { + isOpen: boolean; + hide: () => void; + volunteer: InterfaceEventVolunteerInfo; +} + +/** + * A modal dialog for viewing volunteer information for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param volunteer - The volunteer object to be displayed. + * + * @returns The rendered modal component. + * + * The `VolunteerViewModal` component displays all the fields of a volunteer in a modal dialog. + * + * The modal includes: + * - A header with a title and a close button. + * - fields for volunteer name, status, hours volunteered, groups, and assignments. + */ + +const VolunteerViewModal: React.FC = ({ + isOpen, + hide, + volunteer, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const { user, hasAccepted, hoursVolunteered, groups } = volunteer; + + return ( + + +

{t('volunteerDetails')}

+ +
+ +
+ {/* Volunteer Name & Avatar */} + + + + {user.image ? ( + Volunteer + ) : ( +
+ +
+ )} + + ), + }} + /> +
+
+ {/* Status and hours volunteered */} + + + {hasAccepted ? ( + + ) : ( + + )} + + ), + style: { + color: hasAccepted ? 'green' : '#ed6c02', + }, + }} + inputProps={{ + style: { + WebkitTextFillColor: hasAccepted ? 'green' : '#ed6c02', + }, + }} + disabled + /> + + + + {/* Table for Associated Volunteer Groups */} + {groups && groups.length > 0 && ( + + + Volunteer Groups Joined + + + + + + + Sr. No. + Group Name + + No. of Members + + + + + {groups.map((group, index) => { + const { _id, name, volunteers } = group; + return ( + + + {index + 1} + + + {name} + + + {volunteers.length} + + + ); + })} + +
+
+
+ )} +
+
+
+ ); +}; +export default VolunteerViewModal; diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts b/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts new file mode 100644 index 0000000000..72c927b8ff --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts @@ -0,0 +1,303 @@ +import { + ADD_VOLUNTEER, + DELETE_VOLUNTEER, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { EVENT_VOLUNTEER_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +const volunteer1 = { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], +}; + +const volunteer2 = { + _id: 'volunteerId2', + hasAccepted: false, + hoursVolunteered: null, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: 'img-url', + }, + assignments: [], + groups: [], +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1, volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: 'hoursVolunteered_ASC', + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2, volunteer1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: 'hoursVolunteered_DESC', + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1, volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: 'T' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: false }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: false }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: true }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1], + }, + }, + }, + { + request: { + query: DELETE_VOLUNTEER, + variables: { + id: 'volunteerId1', + }, + }, + result: { + data: { + removeEventVolunteer: { + _id: 'volunteerId1', + }, + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId2', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId3', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ADD_VOLUNTEER, + variables: { + data: { + eventId: 'eventId', + userId: 'userId3', + }, + }, + }, + result: { + data: { + createEventVolunteer: { + _id: 'volunteerId1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_VOLUNTEER, + variables: { + id: 'volunteerId1', + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId2', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId3', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ADD_VOLUNTEER, + variables: { + data: { + eventId: 'eventId', + userId: 'userId3', + }, + }, + }, + error: new Error('An error occurred'), + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [], + }, + }, + }, +]; diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx new file mode 100644 index 0000000000..2af25b0b84 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx @@ -0,0 +1,245 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Volunteers from './Volunteers'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './Volunteers.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderVolunteers = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing Volunteers Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Volunteers screen', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by hoursVolunteered_DESC + fireEvent.click(sortBtn); + const hoursVolunteeredDESC = await screen.findByTestId( + 'hoursVolunteered_DESC', + ); + expect(hoursVolunteeredDESC).toBeInTheDocument(); + fireEvent.click(hoursVolunteeredDESC); + + let volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by hoursVolunteered_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const hoursVolunteeredASC = await screen.findByTestId( + 'hoursVolunteered_ASC', + ); + expect(hoursVolunteeredASC).toBeInTheDocument(); + fireEvent.click(hoursVolunteeredASC); + + volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); + }); + + it('Filter Volunteers by status (All)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by All + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAll')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName).toHaveLength(2); + }); + + it('Filter Volunteers by status (Pending)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by Pending + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusPending')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); + }); + + it('Filter Volunteers by status (Accepted)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by Accepted + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAccepted')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAccepted')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Search', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'T'); + await debounceWait(); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('should render screen with No Volunteers', async () => { + renderVolunteers(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('Error while fetching volunteers data', async () => { + renderVolunteers(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close Volunteer Modal (View)', async () => { + renderVolunteers(link1); + + const viewItemBtn = await screen.findAllByTestId('viewItemBtn'); + userEvent.click(viewItemBtn[0]); + + expect(await screen.findByText(t.volunteerDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and Close Volunteer Modal (Delete)', async () => { + renderVolunteers(link1); + + const deleteItemBtn = await screen.findAllByTestId('deleteItemBtn'); + userEvent.click(deleteItemBtn[0]); + + expect(await screen.findByText(t.removeVolunteer)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close Volunteer Modal (Create)', async () => { + renderVolunteers(link1); + + const addVolunteerBtn = await screen.findByTestId('addVolunteerBtn'); + userEvent.click(addVolunteerBtn); + + expect(await screen.findAllByText(t.addVolunteer)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx new file mode 100644 index 0000000000..770bd35ef4 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx @@ -0,0 +1,462 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { + Circle, + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import VolunteerCreateModal from './VolunteerCreateModal'; +import VolunteerDeleteModal from './VolunteerDeleteModal'; +import VolunteerViewModal from './VolunteerViewModal'; + +enum VolunteerStatus { + All = 'all', + Pending = 'pending', + Accepted = 'accepted', +} + +enum ModalState { + ADD = 'add', + DELETE = 'delete', + VIEW = 'view', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing and displaying event volunteers realted to an event. + * + * This component allows users to view, filter, sort, and create volunteers. It also handles fetching and displaying related data such as volunteer acceptance status, etc. + * + * @returns The rendered component. + */ +function volunteers(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [volunteer, setVolunteer] = + useState(null); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'hoursVolunteered_ASC' | 'hoursVolunteered_DESC' | null + >(null); + const [status, setStatus] = useState(VolunteerStatus.All); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.ADD]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + }); + + const openModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: true })); + }; + + const closeModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: false })); + }; + + const handleOpenModal = useCallback( + ( + volunteer: InterfaceEventVolunteerInfo | null, + modalType: ModalState, + ): void => { + setVolunteer(volunteer); + openModal(modalType); + }, + [openModal], + ); + + /** + * Query to fetch event volunteers for the event. + */ + const { + data: volunteersData, + loading: volunteersLoading, + error: volunteersError, + refetch: refetchVolunteers, + }: { + data?: { + getEventVolunteers: InterfaceEventVolunteerInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_LIST, { + variables: { + where: { + eventId: eventId, + hasAccepted: + status === VolunteerStatus.All + ? undefined + : status === VolunteerStatus.Accepted, + name_contains: searchTerm, + }, + orderBy: sortBy, + }, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const volunteers = useMemo( + () => volunteersData?.getEventVolunteers || [], + [volunteersData], + ); + + if (volunteersLoading) { + return ; + } + + if (volunteersError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteers' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.user; + return ( +
+ {image ? ( + volunteer + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.hasAccepted ? 'Accepted' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.hasAccepted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'hours', + headerName: 'Hours Volunteered', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.hoursVolunteered ?? '-'} +
+ ); + }, + }, + { + field: 'actionItem', + headerName: 'Actions Completed', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.assignments.length} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('sort')} + + + setSortBy('hoursVolunteered_DESC')} + data-testid="hoursVolunteered_DESC" + > + {t('mostHoursVolunteered')} + + setSortBy('hoursVolunteered_ASC')} + data-testid="hoursVolunteered_ASC" + > + {t('leastHoursVolunteered')} + + + + + + + {t('status')} + + + setStatus(VolunteerStatus.All)} + data-testid="statusAll" + > + {tCommon('all')} + + setStatus(VolunteerStatus.Pending)} + data-testid="statusPending" + > + {tCommon('pending')} + + setStatus(VolunteerStatus.Accepted)} + data-testid="statusAccepted" + > + {t('accepted')} + + + +
+
+ +
+
+
+ + {/* Table with Volunteers */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteers')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={volunteers.map((volunteer, index) => ({ + id: index + 1, + ...volunteer, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.ADD)} + eventId={eventId} + orgId={orgId} + refetchVolunteers={refetchVolunteers} + /> + + {volunteer && ( + <> + closeModal(ModalState.VIEW)} + volunteer={volunteer} + /> + closeModal(ModalState.DELETE)} + volunteer={volunteer} + refetchVolunteers={refetchVolunteers} + /> + + )} +
+ ); +} + +export default volunteers; diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx index f7e339dc89..d14ee9de06 100644 --- a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx @@ -18,7 +18,7 @@ import Avatar from 'components/Avatar/Avatar'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import type { InterfacePledgeInfo, - InterfacePledger, + InterfaceUserInfo, InterfaceQueryFundCampaignsPledges, } from 'utils/interfaces'; import ProgressBar from 'react-bootstrap/ProgressBar'; @@ -84,7 +84,7 @@ const fundCampaignPledge = (): JSX.Element => { }); const [anchor, setAnchor] = useState(null); - const [extraUsers, setExtraUsers] = useState([]); + const [extraUsers, setExtraUsers] = useState([]); const [progressIndicator, setProgressIndicator] = useState< 'raised' | 'pledged' >('pledged'); @@ -189,7 +189,7 @@ const fundCampaignPledge = (): JSX.Element => { const handleClick = ( event: React.MouseEvent, - users: InterfacePledger[], + users: InterfaceUserInfo[], ): void => { setExtraUsers(users); setAnchor(anchor ? null : event.currentTarget); @@ -226,7 +226,7 @@ const fundCampaignPledge = (): JSX.Element => {
{params.row.users .slice(0, 2) - .map((user: InterfacePledger, index: number) => ( + .map((user: InterfaceUserInfo, index: number) => (
{user.image ? ( { disablePortal className={`${styles.popup} ${extraUsers.length > 4 ? styles.popupExtra : ''}`} > - {extraUsers.map((user: InterfacePledger, index: number) => ( + {extraUsers.map((user: InterfaceUserInfo, index: number) => (
= ({ pledgeEndDate: new Date(pledge?.endDate ?? new Date()), pledgeStartDate: new Date(pledge?.startDate ?? new Date()), }); - const [pledgers, setPledgers] = useState([]); + const [pledgers, setPledgers] = useState([]); const [updatePledge] = useMutation(UPDATE_PLEDGE); const [createPledge] = useMutation(CREATE_PlEDGE); @@ -235,7 +235,7 @@ const PledgeModal: React.FC = ({ value={pledgeUsers} isOptionEqualToValue={(option, value) => option._id === value._id} filterSelectedOptions={true} - getOptionLabel={(member: InterfacePledger): string => + getOptionLabel={(member: InterfaceUserInfo): string => `${member.firstName} ${member.lastName}` } onChange={ diff --git a/src/screens/Leaderboard/Leaderboard.mocks.ts b/src/screens/Leaderboard/Leaderboard.mocks.ts new file mode 100644 index 0000000000..b6b22c832a --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.mocks.ts @@ -0,0 +1,198 @@ +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; + +const rank1 = { + rank: 1, + hoursVolunteered: 5, + user: { + _id: 'userId1', + lastName: 'Bradley', + firstName: 'Teresa', + image: 'image-url', + email: 'testuser4@example.com', + }, +}; + +const rank2 = { + rank: 2, + hoursVolunteered: 4, + user: { + _id: 'userId2', + lastName: 'Garza', + firstName: 'Bruce', + image: null, + email: 'testuser5@example.com', + }, +}; + +const rank3 = { + rank: 3, + hoursVolunteered: 3, + user: { + _id: 'userId3', + lastName: 'Doe', + firstName: 'John', + image: null, + email: 'testuser6@example.com', + }, +}; + +const rank4 = { + rank: 4, + hoursVolunteered: 2, + user: { + _id: 'userId4', + lastName: 'Doe', + firstName: 'Jane', + image: null, + email: 'testuser7@example.com', + }, +}; + +export const MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2, rank3, rank4], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_ASC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank4, rank3, rank2, rank1], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'weekly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'monthly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'yearly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2, rank3], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: 'T', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + error: new Error('Mock Graphql VOLUNTEER_RANKING Error'), + }, +]; diff --git a/src/screens/Leaderboard/Leaderboard.test.tsx b/src/screens/Leaderboard/Leaderboard.test.tsx new file mode 100644 index 0000000000..d2f12a9052 --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.test.tsx @@ -0,0 +1,264 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Leaderboard from './Leaderboard'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Leaderboard.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.leaderboard ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderLeaderboard = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Leaderboard Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> +
} + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Leaderboard screen', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + }); + + it('Check Sorting Functionality', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by hours_DESC + fireEvent.click(sortBtn); + const hoursDesc = await screen.findByTestId('hours_DESC'); + expect(hoursDesc).toBeInTheDocument(); + fireEvent.click(hoursDesc); + + let userName = await screen.findAllByTestId('userName'); + expect(userName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by hours_ASC + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const hoursAsc = await screen.findByTestId('hours_ASC'); + expect(hoursAsc).toBeInTheDocument(); + fireEvent.click(hoursAsc); + + userName = await screen.findAllByTestId('userName'); + expect(userName[0]).toHaveTextContent('Jane Doe'); + }); + + it('Check Timeframe filter Functionality (All Time)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by allTime + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const timeFrameAll = await screen.findByTestId('timeFrameAll'); + expect(timeFrameAll).toBeInTheDocument(); + + fireEvent.click(timeFrameAll); + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(4); + }); + + it('Check Timeframe filter Functionality (Weekly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + + // Filter by weekly + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameWeekly = await screen.findByTestId('timeFrameWeekly'); + expect(timeFrameWeekly).toBeInTheDocument(); + fireEvent.click(timeFrameWeekly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(1); + }); + + it('Check Timeframe filter Functionality (Monthly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by monthly + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameMonthly = await screen.findByTestId('timeFrameMonthly'); + expect(timeFrameMonthly).toBeInTheDocument(); + fireEvent.click(timeFrameMonthly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + }); + + it('Check Timeframe filter Functionality (Yearly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by yearly + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameYearly = await screen.findByTestId('timeFrameYearly'); + expect(timeFrameYearly).toBeInTheDocument(); + fireEvent.click(timeFrameYearly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(3); + }); + + it('Search Volunteers', async () => { + renderLeaderboard(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'T'); + await debounceWait(); + + await waitFor(() => { + const userName = screen.getAllByTestId('userName'); + expect(userName).toHaveLength(1); + }); + }); + + it('OnClick of Member navigate to Member Screen', async () => { + renderLeaderboard(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const userName = screen.getAllByTestId('userName'); + userEvent.click(userName[0]); + + await waitFor(() => { + expect(screen.getByTestId('memberScreen')).toBeInTheDocument(); + }); + }); + + it('should render Leaderboard screen with No Volunteers', async () => { + renderLeaderboard(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('Error while fetching volunteer data', async () => { + renderLeaderboard(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/Leaderboard/Leaderboard.tsx b/src/screens/Leaderboard/Leaderboard.tsx new file mode 100644 index 0000000000..c5ad7a2efe --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.tsx @@ -0,0 +1,372 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; + +import { + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import gold from 'assets/images/gold.png'; +import silver from 'assets/images/silver.png'; +import bronze from 'assets/images/bronze.png'; + +import type { InterfaceVolunteerRank } from 'utils/interfaces'; +import styles from '../OrganizationActionItems/OrganizationActionItems.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; +import { useQuery } from '@apollo/client'; + +enum TimeFrame { + All = 'allTime', + Weekly = 'weekly', + Monthly = 'monthly', + Yearly = 'yearly', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component to display the leaderboard of volunteers. + * + * This component shows a leaderboard of volunteers ranked by hours contributed, + * with features for filtering by time frame and sorting by hours. It displays + * volunteer details including rank, name, email, and hours volunteered. + * + * @returns The rendered component. + */ +function leaderboard(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'leaderboard', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + const navigate = useNavigate(); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'hours_ASC' | 'hours_DESC'>( + 'hours_DESC', + ); + const [timeFrame, setTimeFrame] = useState(TimeFrame.All); + + /** + * Query to fetch volunteer rankings. + */ + const { + data: rankingsData, + loading: rankingsLoading, + error: rankingsError, + }: { + data?: { + getVolunteerRanks: InterfaceVolunteerRank[]; + }; + loading: boolean; + error?: Error | undefined; + } = useQuery(VOLUNTEER_RANKING, { + variables: { + orgId, + where: { + orderBy: sortBy, + timeFrame: timeFrame, + nameContains: searchTerm, + }, + }, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const rankings = useMemo( + () => rankingsData?.getVolunteerRanks || [], + [rankingsData], + ); + + if (rankingsLoading) { + return ; + } + + if (rankingsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteer Rankings' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'rank', + headerName: 'Rank', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + if (params.row.rank === 1) { + return ( + <> + gold + + ); + } else if (params.row.rank === 2) { + return ( + <> + silver + + ); + } else if (params.row.rank === 3) { + return ( + <> + bronze + + ); + } else return <>{params.row.rank}; + }, + }, + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.user; + + return ( + <> +
+ navigate(`/member/${orgId}`, { state: { id: _id } }) + } + data-testid="userName" + > + {image ? ( + User + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ + ); + }, + }, + { + field: 'email', + headerName: 'Email', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.user.email} +
+ ); + }, + }, + { + field: 'hoursVolunteered', + headerName: 'Hours Volunteered', + flex: 2, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return
{params.row.hoursVolunteered}
; + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('sort')} + + + setSortBy('hours_DESC')} + data-testid="hours_DESC" + > + {t('mostHours')} + + setSortBy('hours_ASC')} + data-testid="hours_ASC" + > + {t('leastHours')} + + + + + + + {t('timeFrame')} + + + setTimeFrame(TimeFrame.All)} + data-testid="timeFrameAll" + > + {t('allTime')} + + setTimeFrame(TimeFrame.Weekly)} + data-testid="timeFrameWeekly" + > + {t('weekly')} + + setTimeFrame(TimeFrame.Monthly)} + data-testid="timeFrameMonthly" + > + {t('monthly')} + + setTimeFrame(TimeFrame.Yearly)} + data-testid="timeFrameYearly" + > + {t('yearly')} + + + +
+
+
+ + {/* Table with Action Items */} + row.user._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteers')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={rankings.map((ranking, index) => ({ + id: index + 1, + ...ranking, + }))} + columns={columns} + isRowSelectable={() => false} + /> +
+ ); +} + +export default leaderboard; diff --git a/src/screens/LoginPage/LoginPage.test.tsx b/src/screens/LoginPage/LoginPage.test.tsx index 3733a454dd..698c83d42e 100644 --- a/src/screens/LoginPage/LoginPage.test.tsx +++ b/src/screens/LoginPage/LoginPage.test.tsx @@ -50,8 +50,8 @@ const MOCKS = [ request: { query: SIGNUP_MUTATION, variables: { - firstName: 'John', - lastName: 'Doe', + firstName: 'John Patrick ', + lastName: 'Doe ', email: 'johndoe@gmail.com', password: 'johnDoe', }, diff --git a/src/screens/LoginPage/LoginPage.tsx b/src/screens/LoginPage/LoginPage.tsx index 2ed1e71c13..7703266afa 100644 --- a/src/screens/LoginPage/LoginPage.tsx +++ b/src/screens/LoginPage/LoginPage.tsx @@ -176,7 +176,7 @@ const loginPage = (): JSX.Element => { }); return data.recaptcha; - } catch (error) { + } catch { /* istanbul ignore next */ toast.error(t('captchaError') as string); } @@ -204,8 +204,11 @@ const loginPage = (): JSX.Element => { toast.error(t('Please_check_the_captcha') as string); return; } - const isValidatedString = (value: string): boolean => - /^[a-zA-Z]+$/.test(value); + + const isValidName = (value: string): boolean => { + // Allow letters, spaces, and hyphens, but not consecutive spaces or hyphens + return /^[a-zA-Z]+(?:[-\s][a-zA-Z]+)*$/.test(value.trim()); + }; const validatePassword = (password: string): boolean => { const lengthCheck = new RegExp('^.{6,}$'); @@ -219,10 +222,10 @@ const loginPage = (): JSX.Element => { }; if ( - isValidatedString(signfirstName) && - isValidatedString(signlastName) && - signfirstName.length > 1 && - signlastName.length > 1 && + isValidName(signfirstName) && + isValidName(signlastName) && + signfirstName.trim().length > 1 && + signlastName.trim().length > 1 && signEmail.length >= 8 && signPassword.length > 1 && validatePassword(signPassword) @@ -264,10 +267,10 @@ const loginPage = (): JSX.Element => { toast.warn(t('passwordMismatches') as string); } } else { - if (!isValidatedString(signfirstName)) { + if (!isValidName(signfirstName)) { toast.warn(t('firstName_invalid') as string); } - if (!isValidatedString(signlastName)) { + if (!isValidName(signlastName)) { toast.warn(t('lastName_invalid') as string); } if (!validatePassword(signPassword)) { diff --git a/src/screens/ManageTag/EditUserTagModal.tsx b/src/screens/ManageTag/EditUserTagModal.tsx new file mode 100644 index 0000000000..5fa8ae2771 --- /dev/null +++ b/src/screens/ManageTag/EditUserTagModal.tsx @@ -0,0 +1,89 @@ +import type { TFunction } from 'i18next'; +import type { FormEvent } from 'react'; +import React from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; + +/** + * Edit UserTag Modal component for the Manage Tag screen. + */ + +export interface InterfaceEditUserTagModalProps { + editUserTagModalIsOpen: boolean; + hideEditUserTagModal: () => void; + newTagName: string; + setNewTagName: (state: React.SetStateAction) => void; + handleEditUserTag: (e: FormEvent) => Promise; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +const EditUserTagModal: React.FC = ({ + editUserTagModalIsOpen, + hideEditUserTagModal, + newTagName, + handleEditUserTag, + setNewTagName, + t, + tCommon, +}) => { + return ( + <> + + + {t('tagDetails')} + +
): void => { + e.preventDefault(); + if (newTagName.trim()) { + handleEditUserTag(e); + } + }} + > + + {t('tagName')} + { + setNewTagName(e.target.value); + }} + /> + + + + + + +
+
+ + ); +}; + +export default EditUserTagModal; diff --git a/src/screens/ManageTag/ManageTag.module.css b/src/screens/ManageTag/ManageTag.module.css index db1886099a..deecd4a9b7 100644 --- a/src/screens/ManageTag/ManageTag.module.css +++ b/src/screens/ManageTag/ManageTag.module.css @@ -117,3 +117,11 @@ font-weight: 600; text-decoration: underline; } + +.manageTagScrollableDiv { + scrollbar-width: thin; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; +} diff --git a/src/screens/ManageTag/ManageTag.test.tsx b/src/screens/ManageTag/ManageTag.test.tsx index 5de5e97c88..598a15cc9a 100644 --- a/src/screens/ManageTag/ManageTag.test.tsx +++ b/src/screens/ManageTag/ManageTag.test.tsx @@ -4,6 +4,7 @@ import type { RenderResult } from '@testing-library/react'; import { act, cleanup, + fireEvent, render, screen, waitFor, @@ -19,12 +20,8 @@ import { store } from 'state/store'; import { StaticMockLink } from 'utils/StaticMockLink'; import i18n from 'utils/i18nForTest'; import ManageTag from './ManageTag'; -import { - MOCKS, - MOCKS_ERROR_ASSIGNED_MEMBERS, - MOCKS_ERROR_TAG_ANCESTORS, -} from './ManageTagMocks'; -import { InMemoryCache, type ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_ERROR_ASSIGNED_MEMBERS } from './ManageTagMocks'; +import { type ApolloLink } from '@apollo/client'; const translations = { ...JSON.parse( @@ -36,7 +33,6 @@ const translations = { const link = new StaticMockLink(MOCKS, true); const link2 = new StaticMockLink(MOCKS_ERROR_ASSIGNED_MEMBERS, true); -const link3 = new StaticMockLink(MOCKS_ERROR_TAG_ANCESTORS, true); async function wait(ms = 500): Promise { await act(() => { @@ -54,24 +50,19 @@ jest.mock('react-toastify', () => ({ }, })); -const cache = new InMemoryCache({ - typePolicies: { - Query: { - fields: { - getUserTag: { - keyArgs: false, - merge(_, incoming) { - return incoming; - }, - }, - }, - }, - }, +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +jest.mock('../../components/AddPeopleToTag/AddPeopleToTag', () => { + return require('./ManageTagMockComponents/MockAddPeopleToTag').default; +}); + +jest.mock('../../components/TagActions/TagActions', () => { + return require('./ManageTagMockComponents/MockTagActions').default; }); +/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const renderManageTag = (link: ApolloLink): RenderResult => { return render( - + @@ -81,11 +72,11 @@ const renderManageTag = (link: ApolloLink): RenderResult => { element={
} /> } />
} /> { ...jest.requireActual('react-router-dom'), useParams: () => ({ orgId: 'orgId' }), })); - cache.reset(); }); afterEach(() => { @@ -134,36 +124,26 @@ describe('Manage Tag Page', () => { }); }); - test('renders error component on unsuccessful userTag ancestors query', async () => { - const { queryByText } = renderManageTag(link3); - - await wait(); - - await waitFor(() => { - expect(queryByText(translations.addPeopleToTag)).not.toBeInTheDocument(); - }); - }); - test('opens and closes the add people to tag modal', async () => { renderManageTag(link); - await wait(); - await waitFor(() => { expect(screen.getByTestId('addPeopleToTagBtn')).toBeInTheDocument(); }); + userEvent.click(screen.getByTestId('addPeopleToTagBtn')); await waitFor(() => { - return expect( - screen.findByTestId('closeAddPeopleToTagModal'), - ).resolves.toBeInTheDocument(); + expect(screen.getByTestId('addPeopleToTagModal')).toBeInTheDocument(); }); + userEvent.click(screen.getByTestId('closeAddPeopleToTagModal')); - await waitForElementToBeRemoved(() => - screen.queryByTestId('closeAddPeopleToTagModal'), - ); + await waitFor(() => { + expect( + screen.queryByTestId('addPeopleToTagModal'), + ).not.toBeInTheDocument(); + }); }); test('opens and closes the unassign tag modal', async () => { @@ -191,45 +171,51 @@ describe('Manage Tag Page', () => { test('opens and closes the assignToTags modal', async () => { renderManageTag(link); - await wait(); - + // Wait for the assignToTags button to be present await waitFor(() => { expect(screen.getByTestId('assignToTags')).toBeInTheDocument(); }); + + // Click the assignToTags button to open the modal userEvent.click(screen.getByTestId('assignToTags')); + // Wait for the close button in the modal to be present await waitFor(() => { - return expect( - screen.findByTestId('closeTagActionsModalBtn'), - ).resolves.toBeInTheDocument(); + expect(screen.getByTestId('closeTagActionsModalBtn')).toBeInTheDocument(); }); + + // Click the close button to close the modal userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); - await waitForElementToBeRemoved(() => - screen.queryByTestId('closeTagActionsModalBtn'), - ); + // Wait for the modal to be removed from the document + await waitFor(() => { + expect(screen.queryByTestId('tagActionsModal')).not.toBeInTheDocument(); + }); }); test('opens and closes the removeFromTags modal', async () => { renderManageTag(link); - await wait(); - + // Wait for the removeFromTags button to be present await waitFor(() => { expect(screen.getByTestId('removeFromTags')).toBeInTheDocument(); }); + + // Click the removeFromTags button to open the modal userEvent.click(screen.getByTestId('removeFromTags')); + // Wait for the close button in the modal to be present await waitFor(() => { - return expect( - screen.findByTestId('closeTagActionsModalBtn'), - ).resolves.toBeInTheDocument(); + expect(screen.getByTestId('closeTagActionsModalBtn')).toBeInTheDocument(); }); + + // Click the close button to close the modal userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); - await waitForElementToBeRemoved(() => - screen.queryByTestId('closeTagActionsModalBtn'), - ); + // Wait for the modal to be removed from the document + await waitFor(() => { + expect(screen.queryByTestId('tagActionsModal')).not.toBeInTheDocument(); + }); }); test('opens and closes the edit tag modal', async () => { @@ -238,9 +224,9 @@ describe('Manage Tag Page', () => { await wait(); await waitFor(() => { - expect(screen.getByTestId('editTag')).toBeInTheDocument(); + expect(screen.getByTestId('editUserTag')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('editTag')); + userEvent.click(screen.getByTestId('editUserTag')); await waitFor(() => { return expect( @@ -338,32 +324,113 @@ describe('Manage Tag Page', () => { }); }); - test('paginates between different pages', async () => { + test('searchs for tags where the name matches the provided search input', async () => { renderManageTag(link); await wait(); await waitFor(() => { - expect(screen.getByTestId('nextPagBtn')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('nextPagBtn')); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'assigned user' } }); + // should render the two users from the mock data + // where firstName starts with "assigned" and lastName starts with "user" + await waitFor(() => { + const buttons = screen.getAllByTestId('viewProfileBtn'); + expect(buttons.length).toEqual(2); + }); + }); + + test('fetches the tags by the sort order, i.e. latest or oldest first', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'assigned user' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" await waitFor(() => { expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( - 'member 6', + 'assigned user1', ); }); + // now change the sorting order await waitFor(() => { - expect(screen.getByTestId('previousPageBtn')).toBeInTheDocument(); + expect(screen.getByTestId('sortPeople')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('previousPageBtn')); + userEvent.click(screen.getByTestId('sortPeople')); + await waitFor(() => { + expect(screen.getByTestId('oldest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('oldest')); + + // returns the tags in reverse order await waitFor(() => { expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( - 'member 1', + 'assigned user2', ); }); + + await waitFor(() => { + expect(screen.getByTestId('sortPeople')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortPeople')); + + await waitFor(() => { + expect(screen.getByTestId('latest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('latest')); + + // reverse the order again + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'assigned user1', + ); + }); + }); + + test('Fetches more assigned members with infinite scroll', async () => { + const { getByText } = renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addPeopleToTag)).toBeInTheDocument(); + }); + + const manageTagScrollableDiv = screen.getByTestId('manageTagScrollableDiv'); + + // Get the initial number of tags loaded + const initialAssignedMembersDataLength = + screen.getAllByTestId('viewProfileBtn').length; + + // Set scroll position to the bottom + fireEvent.scroll(manageTagScrollableDiv, { + target: { scrollY: manageTagScrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalAssignedMembersDataLength = + screen.getAllByTestId('viewProfileBtn').length; + expect(finalAssignedMembersDataLength).toBeGreaterThan( + initialAssignedMembersDataLength, + ); + + expect(getByText(translations.addPeopleToTag)).toBeInTheDocument(); + }); }); test('unassigns a tag from a member', async () => { @@ -391,9 +458,9 @@ describe('Manage Tag Page', () => { await wait(); await waitFor(() => { - expect(screen.getByTestId('editTag')).toBeInTheDocument(); + expect(screen.getByTestId('editUserTag')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('editTag')); + userEvent.click(screen.getByTestId('editUserTag')); userEvent.click(screen.getByTestId('editTagSubmitBtn')); diff --git a/src/screens/ManageTag/ManageTag.tsx b/src/screens/ManageTag/ManageTag.tsx index 884b402a7c..e8eb9bb8df 100644 --- a/src/screens/ManageTag/ManageTag.tsx +++ b/src/screens/ManageTag/ManageTag.tsx @@ -1,7 +1,7 @@ import type { FormEvent } from 'react'; import React, { useEffect, useState } from 'react'; -import { useMutation, useQuery, type ApolloError } from '@apollo/client'; -import { Search, WarningAmberRounded } from '@mui/icons-material'; +import { useMutation, useQuery } from '@apollo/client'; +import { WarningAmberRounded } from '@mui/icons-material'; import SortIcon from '@mui/icons-material/Sort'; import Loader from 'components/Loader/Loader'; import IconComponent from 'components/IconComponent/IconComponent'; @@ -9,15 +9,21 @@ import { useNavigate, useParams, Link } from 'react-router-dom'; import { Col, Form } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; import Dropdown from 'react-bootstrap/Dropdown'; -import Modal from 'react-bootstrap/Modal'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import type { InterfaceQueryUserTagsAssignedMembers } from 'utils/interfaces'; import styles from './ManageTag.module.css'; import { DataGrid } from '@mui/x-data-grid'; -import type { TagActionType } from 'utils/organizationTagsUtils'; -import { dataGridStyle } from 'utils/organizationTagsUtils'; +import type { + InterfaceTagAssignedMembersQuery, + SortedByType, + TagActionType, +} from 'utils/organizationTagsUtils'; +import { + TAGS_QUERY_DATA_CHUNK_SIZE, + dataGridStyle, +} from 'utils/organizationTagsUtils'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { Stack } from '@mui/material'; import { @@ -25,18 +31,17 @@ import { UNASSIGN_USER_TAG, UPDATE_USER_TAG, } from 'GraphQl/Mutations/TagMutations'; -import { - USER_TAG_ANCESTORS, - USER_TAGS_ASSIGNED_MEMBERS, -} from 'GraphQl/Queries/userTagQueries'; +import { USER_TAGS_ASSIGNED_MEMBERS } from 'GraphQl/Queries/userTagQueries'; import AddPeopleToTag from 'components/AddPeopleToTag/AddPeopleToTag'; import TagActions from 'components/TagActions/TagActions'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import EditUserTagModal from './EditUserTagModal'; +import RemoveUserTagModal from './RemoveUserTagModal'; +import UnassignUserTagModal from './UnassignUserTagModal'; /** - * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/managetag/:tagId'. - * - * This component does not accept any props and is responsible for displaying - * the content associated with the corresponding route. + * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/manageTag/:tagId'. */ function ManageTag(): JSX.Element { @@ -44,55 +49,50 @@ function ManageTag(): JSX.Element { keyPrefix: 'manageTag', }); const { t: tCommon } = useTranslation('common'); - - const [unassignTagModalIsOpen, setUnassignTagModalIsOpen] = useState(false); - - const [addPeopleToTagModalIsOpen, setAddPeopleToTagModalIsOpen] = - useState(false); - const [assignToTagsModalIsOpen, setAssignToTagsModalIsOpen] = useState(false); - - const [editTagModalIsOpen, setEditTagModalIsOpen] = useState(false); - const [removeTagModalIsOpen, setRemoveTagModalIsOpen] = useState(false); - const { orgId, tagId: currentTagId } = useParams(); const navigate = useNavigate(); - const [after, setAfter] = useState(null); - const [before, setBefore] = useState(null); - const [first, setFirst] = useState(5); - const [last, setLast] = useState(null); + const [unassignUserTagModalIsOpen, setUnassignUserTagModalIsOpen] = + useState(false); + const [addPeopleToTagModalIsOpen, setAddPeopleToTagModalIsOpen] = + useState(false); + const [tagActionsModalIsOpen, setTagActionsModalIsOpen] = useState(false); + const [editUserTagModalIsOpen, setEditUserTagModalIsOpen] = useState(false); + const [removeUserTagModalIsOpen, setRemoveUserTagModalIsOpen] = + useState(false); const [unassignUserId, setUnassignUserId] = useState(null); - + const [assignedMemberSearchInput, setAssignedMemberSearchInput] = + useState(''); + const [assignedMemberSearchFirstName, setAssignedMemberSearchFirstName] = + useState(''); + const [assignedMemberSearchLastName, setAssignedMemberSearchLastName] = + useState(''); + const [assignedMemberSortOrder, setAssignedMemberSortOrder] = + useState('DESCENDING'); // a state to specify whether we're assigning to tags or removing from tags const [tagActionType, setTagActionType] = useState('assignToTags'); const toggleRemoveUserTagModal = (): void => { - setRemoveTagModalIsOpen(!removeTagModalIsOpen); + setRemoveUserTagModalIsOpen(!removeUserTagModalIsOpen); }; - const showAddPeopleToTagModal = (): void => { setAddPeopleToTagModalIsOpen(true); }; - const hideAddPeopleToTagModal = (): void => { setAddPeopleToTagModalIsOpen(false); }; - - const showAssignToTagsModal = (): void => { - setAssignToTagsModalIsOpen(true); + const showTagActionsModal = (): void => { + setTagActionsModalIsOpen(true); }; - - const hideAssignToTagsModal = (): void => { - setAssignToTagsModalIsOpen(false); + const hideTagActionsModal = (): void => { + setTagActionsModalIsOpen(false); }; - - const showEditTagModal = (): void => { - setEditTagModalIsOpen(true); + const showEditUserTagModal = (): void => { + setEditUserTagModalIsOpen(true); }; - - const hideEditTagModal = (): void => { - setEditTagModalIsOpen(false); + const hideEditUserTagModal = (): void => { + setEditUserTagModalIsOpen(false); }; const { @@ -100,47 +100,68 @@ function ManageTag(): JSX.Element { loading: userTagAssignedMembersLoading, error: userTagAssignedMembersError, refetch: userTagAssignedMembersRefetch, - }: { - data?: { - getUserTag: InterfaceQueryUserTagsAssignedMembers; - }; - loading: boolean; - error?: ApolloError; - refetch: () => void; - } = useQuery(USER_TAGS_ASSIGNED_MEMBERS, { + fetchMore: fetchMoreAssignedMembers, + }: InterfaceTagAssignedMembersQuery = useQuery(USER_TAGS_ASSIGNED_MEMBERS, { variables: { id: currentTagId, - after: after, - before: before, - first: first, - last: last, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: assignedMemberSearchFirstName }, + lastName: { starts_with: assignedMemberSearchLastName }, + }, + sortedBy: { id: assignedMemberSortOrder }, }, + fetchPolicy: 'no-cache', }); - const { - data: orgUserTagAncestorsData, - loading: orgUserTagsAncestorsLoading, - refetch: orgUserTagsAncestorsRefetch, - error: orgUserTagsAncestorsError, - }: { - data?: { - getUserTagAncestors: { - _id: string; - name: string; - }[]; - }; - loading: boolean; - error?: ApolloError; - refetch: () => void; - } = useQuery(USER_TAG_ANCESTORS, { - variables: { - id: currentTagId, - }, - }); + const loadMoreAssignedMembers = (): void => { + fetchMoreAssignedMembers({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: + userTagAssignedMembersData?.getAssignedUsers.usersAssignedTo.pageInfo + .endCursor, + }, + updateQuery: ( + prevResult: { getAssignedUsers: InterfaceQueryUserTagsAssignedMembers }, + { + fetchMoreResult, + }: { + fetchMoreResult: { + getAssignedUsers: InterfaceQueryUserTagsAssignedMembers; + }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + getAssignedUsers: { + ...fetchMoreResult.getAssignedUsers, + usersAssignedTo: { + ...fetchMoreResult.getAssignedUsers.usersAssignedTo, + edges: [ + ...prevResult.getAssignedUsers.usersAssignedTo.edges, + ...fetchMoreResult.getAssignedUsers.usersAssignedTo.edges, + ], + }, + }, + }; + }, + }); + }; + + useEffect(() => { + const [firstName, ...lastNameParts] = assignedMemberSearchInput + .trim() + .split(/\s+/); + const lastName = lastNameParts.join(' '); // Joins everything after the first word + setAssignedMemberSearchFirstName(firstName); + setAssignedMemberSearchLastName(lastName); + }, [assignedMemberSearchInput]); const [unassignUserTag] = useMutation(UNASSIGN_USER_TAG); - const handleUnassignTag = async (): Promise => { + const handleUnassignUserTag = async (): Promise => { try { await unassignUserTag({ variables: { @@ -150,7 +171,7 @@ function ManageTag(): JSX.Element { }); userTagAssignedMembersRefetch(); - toggleUnassignTagModal(); + toggleUnassignUserTagModal(); toast.success(t('successfullyUnassigned') as string); } catch (error: unknown) { /* istanbul ignore next */ @@ -163,13 +184,16 @@ function ManageTag(): JSX.Element { const [edit] = useMutation(UPDATE_USER_TAG); const [newTagName, setNewTagName] = useState(''); - const currentTagName = userTagAssignedMembersData?.getUserTag.name ?? ''; + const currentTagName = + userTagAssignedMembersData?.getAssignedUsers.name ?? ''; useEffect(() => { - setNewTagName(userTagAssignedMembersData?.getUserTag.name ?? ''); + setNewTagName(userTagAssignedMembersData?.getAssignedUsers.name ?? ''); }, [userTagAssignedMembersData]); - const editTag = async (e: FormEvent): Promise => { + const handleEditUserTag = async ( + e: FormEvent, + ): Promise => { e.preventDefault(); if (newTagName === currentTagName) { @@ -188,8 +212,7 @@ function ManageTag(): JSX.Element { if (data) { toast.success(t('tagUpdationSuccess')); userTagAssignedMembersRefetch(); - orgUserTagsAncestorsRefetch(); - setEditTagModalIsOpen(false); + setEditUserTagModalIsOpen(false); } } catch (error: unknown) { /* istanbul ignore next */ @@ -219,22 +242,13 @@ function ManageTag(): JSX.Element { } }; - if (userTagAssignedMembersLoading || orgUserTagsAncestorsLoading) { - return ; - } - - if (userTagAssignedMembersError || orgUserTagsAncestorsError) { + if (userTagAssignedMembersError) { return (
- Error occured while loading{' '} - {userTagAssignedMembersError ? 'assigned users' : 'tag ancestors'} -
- {userTagAssignedMembersError - ? userTagAssignedMembersError.message - : orgUserTagsAncestorsError?.message} + Error occured while loading assigned users
@@ -242,43 +256,31 @@ function ManageTag(): JSX.Element { } const userTagAssignedMembers = - userTagAssignedMembersData?.getUserTag.usersAssignedTo.edges.map( + userTagAssignedMembersData?.getAssignedUsers.usersAssignedTo.edges.map( (edge) => edge.node, - ); + ) ?? /* istanbul ignore next */ []; - const orgUserTagAncestors = orgUserTagAncestorsData?.getUserTagAncestors; + // get the ancestorTags array and push the current tag in it + // used for the tag breadcrumbs + const orgUserTagAncestors = [ + ...(userTagAssignedMembersData?.getAssignedUsers.ancestorTags ?? []), + { + _id: currentTagId, + name: currentTagName, + }, + ]; const redirectToSubTags = (tagId: string): void => { navigate(`/orgtags/${orgId}/subTags/${tagId}`); }; - const redirectToManageTag = (tagId: string): void => { - navigate(`/orgtags/${orgId}/managetag/${tagId}`); - }; - - const handleNextPage = (): void => { - setAfter( - userTagAssignedMembersData?.getUserTag.usersAssignedTo.pageInfo.endCursor, - ); - setBefore(null); - setFirst(5); - setLast(null); - }; - const handlePreviousPage = (): void => { - setBefore( - userTagAssignedMembersData?.getUserTag.usersAssignedTo.pageInfo - .startCursor, - ); - setAfter(null); - setFirst(null); - setLast(5); + navigate(`/orgtags/${orgId}/manageTag/${tagId}`); }; - - const toggleUnassignTagModal = (): void => { - if (unassignTagModalIsOpen) { + const toggleUnassignUserTagModal = (): void => { + if (unassignUserTagModalIsOpen) { setUnassignUserId(null); } - setUnassignTagModalIsOpen(!unassignTagModalIsOpen); + setUnassignUserTagModalIsOpen(!unassignUserTagModalIsOpen); }; const columns: GridColDef[] = [ @@ -320,23 +322,23 @@ function ManageTag(): JSX.Element { headerClassName: `${styles.tableHeader}`, renderCell: (params: GridCellParams) => { return ( -
+
-
{t('viewProfile')}
+
+ {t('viewProfile')} +
-
- - -
+
- - -
-
- -
- -
navigate(`/orgtags/${orgId}`)} - className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} - data-testid="allTagsBtn" - > - {'Tags'} - -
- - {orgUserTagAncestors?.map((tag, index) => ( + {userTagAssignedMembersLoading ? ( + + ) : ( + + +
+
+ +
redirectToManageTag(tag._id as string)} - data-testid="redirectToManageTag" + onClick={() => navigate(`/orgtags/${orgId}`)} + className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} + data-testid="allTagsBtn" > - {tag.name} - - {orgUserTagAncestors.length - 1 !== index && ( - /* istanbul ignore next */ - - )} + {'Tags'} +
- ))} -
- row._id} - slots={{ - noRowsOverlay: /* istanbul ignore next */ () => ( - ( +
redirectToManageTag(tag._id as string)} + data-testid="redirectToManageTag" > - {t('noAssignedMembersFound')} - - ), - }} - sx={dataGridStyle} - getRowClassName={() => `${styles.rowBackground}`} - autoHeight - rowHeight={65} - rows={userTagAssignedMembers?.map((assignedMembers, index) => ({ - id: index + 1, - ...assignedMembers, - }))} - columns={columns} - isRowSelectable={() => false} - /> - -
-
- -
-
- + {tag.name} + {orgUserTagAncestors.length - 1 !== index && ( + /* istanbul ignore next */ + + )} +
+ ))}
-
- - - -
-
{'Actions'}
-
-
{ - setTagActionType('assignToTags'); - showAssignToTagsModal(); - }} - className="ms-5 mt-2 mb-2 btn btn-primary btn-sm w-75" - data-testid="assignToTags" + id="manageTagScrollableDiv" + data-testid="manageTagScrollableDiv" + className={styles.manageTagScrollableDiv} > - {t('assignToTags')} + } + scrollableTarget="manageTagScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noAssignedMembersFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagAssignedMembers?.map( + (assignedMembers, index) => ({ + id: index + 1, + ...assignedMembers, + }), + )} + columns={columns} + isRowSelectable={() => false} + /> +
-
{ - setTagActionType('removeFromTags'); - showAssignToTagsModal(); - }} - className="ms-5 mb-3 btn btn-danger btn-sm w-75" - data-testid="removeFromTags" - > - {t('removeFromTags')} + + +
+
{'Actions'}
- -
- -
- {tCommon('edit')} -
-
- {tCommon('remove')} +
+
{ + setTagActionType('assignToTags'); + showTagActionsModal(); + }} + className="my-2 btn btn-primary btn-sm w-75" + data-testid="assignToTags" + > + {t('assignToTags')} +
+
{ + setTagActionType('removeFromTags'); + showTagActionsModal(); + }} + className="mb-1 btn btn-danger btn-sm w-75" + data-testid="removeFromTags" + > + {t('removeFromTags')} +
+
+
+ {tCommon('edit')} +
+
+ {tCommon('remove')} +
-
- - + + + )}
@@ -561,139 +560,41 @@ function ManageTag(): JSX.Element { t={t} tCommon={tCommon} /> - {/* Assign People To Tags Modal */} - - {/* Unassign Tag Modal */} - - - - {t('unassignUserTag')} - - - {t('unassignUserTagMessage')} - - - - - - - {/* Edit Tag Modal */} - - - {t('tagDetails')} - -
- - {t('tagName')} - { - setNewTagName(e.target.value); - }} - /> - - - - - - -
-
- + {/* Unassign User Tag Modal */} + + {/* Edit User Tag Modal */} + {/* Remove User Tag Modal */} - - - - {t('removeUserTag')} - - - {t('removeUserTagMessage')} - - - - - + ); } - export default ManageTag; diff --git a/src/screens/ManageTag/ManageTagMockComponents/MockAddPeopleToTag.tsx b/src/screens/ManageTag/ManageTagMockComponents/MockAddPeopleToTag.tsx new file mode 100644 index 0000000000..7e62c47f86 --- /dev/null +++ b/src/screens/ManageTag/ManageTagMockComponents/MockAddPeopleToTag.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type { InterfaceAddPeopleToTagProps } from '../../../components/AddPeopleToTag/AddPeopleToTag'; + +/** + * Component that mocks the AddPeopleToTag component for the Manage Tag screen. + */ + +const TEST_IDS = { + MODAL: 'addPeopleToTagModal', + CLOSE_BUTTON: 'closeAddPeopleToTagModal', +} as const; +const MockAddPeopleToTag: React.FC = ({ + addPeopleToTagModalIsOpen, + hideAddPeopleToTagModal, +}) => { + return ( + <> + {addPeopleToTagModalIsOpen && ( +
+ + +
+ )} + + ); +}; + +export default MockAddPeopleToTag; diff --git a/src/screens/ManageTag/ManageTagMockComponents/MockTagActions.tsx b/src/screens/ManageTag/ManageTagMockComponents/MockTagActions.tsx new file mode 100644 index 0000000000..3d2d6cc880 --- /dev/null +++ b/src/screens/ManageTag/ManageTagMockComponents/MockTagActions.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { InterfaceTagActionsProps } from '../../../components/TagActions/TagActions'; + +/** + * Component that mocks the TagActions component for the Manage Tag screen. + */ + +const MockTagActions: React.FC = ({ + tagActionsModalIsOpen, + hideTagActionsModal, +}) => { + return ( + <> + {tagActionsModalIsOpen && ( +
+

+ Tag Actions +

+ +
+ )} + + ); +}; + +export default MockTagActions; diff --git a/src/screens/ManageTag/ManageTagMocks.ts b/src/screens/ManageTag/ManageTagMocks.ts index e90e8c58ed..5ce1e62595 100644 --- a/src/screens/ManageTag/ManageTagMocks.ts +++ b/src/screens/ManageTag/ManageTagMocks.ts @@ -3,13 +3,8 @@ import { UNASSIGN_USER_TAG, UPDATE_USER_TAG, } from 'GraphQl/Mutations/TagMutations'; -import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; -import { - USER_TAG_ANCESTORS, - USER_TAGS_ASSIGNED_MEMBERS, - USER_TAGS_MEMBERS_TO_ASSIGN_TO, -} from 'GraphQl/Queries/userTagQueries'; -import { TAGS_QUERY_LIMIT } from 'utils/organizationTagsUtils'; +import { USER_TAGS_ASSIGNED_MEMBERS } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; export const MOCKS = [ { @@ -17,15 +12,17 @@ export const MOCKS = [ query: USER_TAGS_ASSIGNED_MEMBERS, variables: { id: '1', - after: null, - before: null, - first: 5, - last: null, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { data: { - getUserTag: { + getAssignedUsers: { name: 'tag1', usersAssignedTo: { edges: [ @@ -69,15 +66,56 @@ export const MOCKS = [ }, cursor: '5', }, + { + node: { + _id: '6', + firstName: 'member', + lastName: '6', + }, + cursor: '6', + }, + { + node: { + _id: '7', + firstName: 'member', + lastName: '7', + }, + cursor: '7', + }, + { + node: { + _id: '8', + firstName: 'member', + lastName: '8', + }, + cursor: '8', + }, + { + node: { + _id: '9', + firstName: 'member', + lastName: '9', + }, + cursor: '9', + }, + { + node: { + _id: '10', + firstName: 'member', + lastName: '10', + }, + cursor: '10', + }, ], pageInfo: { startCursor: '1', - endCursor: '5', + endCursor: '10', hasNextPage: true, hasPreviousPage: false, }, - totalCount: 6, + totalCount: 12, }, + ancestorTags: [], }, }, }, @@ -87,35 +125,47 @@ export const MOCKS = [ query: USER_TAGS_ASSIGNED_MEMBERS, variables: { id: '1', - after: '5', - before: null, - first: 5, - last: null, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { data: { - getUserTag: { + getAssignedUsers: { name: 'tag1', usersAssignedTo: { edges: [ { node: { - _id: '6', + _id: '11', firstName: 'member', - lastName: '6', + lastName: '11', }, - cursor: '6', + cursor: '11', + }, + { + node: { + _id: '12', + firstName: 'member', + lastName: '12', + }, + cursor: '12', }, ], pageInfo: { - startCursor: '6', - endCursor: '6', + startCursor: '11', + endCursor: '12', hasNextPage: false, hasPreviousPage: true, }, - totalCount: 6, + totalCount: 12, }, + ancestorTags: [], }, }, }, @@ -125,172 +175,99 @@ export const MOCKS = [ query: USER_TAGS_ASSIGNED_MEMBERS, variables: { id: '1', - after: null, - before: '6', - first: null, - last: 5, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: 'assigned' }, + lastName: { starts_with: 'user' }, + }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { data: { - getUserTag: { + getAssignedUsers: { name: 'tag1', usersAssignedTo: { edges: [ { node: { _id: '1', - firstName: 'member', - lastName: '1', + firstName: 'assigned', + lastName: 'user1', }, cursor: '1', }, { node: { _id: '2', - firstName: 'member', - lastName: '2', + firstName: 'assigned', + lastName: 'user2', }, cursor: '2', }, - { - node: { - _id: '3', - firstName: 'member', - lastName: '3', - }, - cursor: '3', - }, - { - node: { - _id: '4', - firstName: 'member', - lastName: '4', - }, - cursor: '4', - }, - { - node: { - _id: '5', - firstName: 'member', - lastName: '5', - }, - cursor: '5', - }, ], pageInfo: { startCursor: '1', - endCursor: '5', - hasNextPage: true, + endCursor: '2', + hasNextPage: false, hasPreviousPage: false, }, - totalCount: 6, + totalCount: 2, }, + ancestorTags: [], }, }, }, }, { request: { - query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + query: USER_TAGS_ASSIGNED_MEMBERS, variables: { id: '1', - first: 7, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: 'assigned' }, + lastName: { starts_with: 'user' }, + }, + sortedBy: { id: 'ASCENDING' }, }, }, result: { data: { - getUserTag: { + getAssignedUsers: { name: 'tag1', - usersToAssignTo: { + usersAssignedTo: { edges: [ - { - node: { - _id: '1', - firstName: 'member', - lastName: '1', - }, - cursor: '1', - }, { node: { _id: '2', - firstName: 'member', - lastName: '2', + firstName: 'assigned', + lastName: 'user2', }, cursor: '2', }, { node: { - _id: '3', - firstName: 'member', - lastName: '3', - }, - cursor: '3', - }, - { - node: { - _id: '4', - firstName: 'member', - lastName: '4', - }, - cursor: '4', - }, - { - node: { - _id: '5', - firstName: 'member', - lastName: '5', - }, - cursor: '5', - }, - { - node: { - _id: '6', - firstName: 'member', - lastName: '6', - }, - cursor: '6', - }, - { - node: { - _id: '7', - firstName: 'member', - lastName: '7', + _id: '1', + firstName: 'assigned', + lastName: 'user1', }, - cursor: '7', + cursor: '1', }, ], pageInfo: { - startCursor: '1', - endCursor: '7', - hasNextPage: true, + startCursor: '2', + endCursor: '1', + hasNextPage: false, hasPreviousPage: false, }, - totalCount: 10, + totalCount: 2, }, + ancestorTags: [], }, }, }, }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: '1', - }, - }, - result: { - data: { - getUserTagAncestors: [ - { - _id: '1', - name: 'tag1', - }, - ], - }, - }, - }, { request: { query: UNASSIGN_USER_TAG, @@ -307,164 +284,6 @@ export const MOCKS = [ }, }, }, - { - request: { - query: ORGANIZATION_USER_TAGS_LIST, - variables: { - id: '123', - first: TAGS_QUERY_LIMIT, - }, - }, - result: { - data: { - organizations: [ - { - userTags: { - edges: [ - { - node: { - _id: '1', - name: 'userTag 1', - usersAssignedTo: { - totalCount: 5, - }, - childTags: { - totalCount: 11, - }, - }, - cursor: '1', - }, - { - node: { - _id: '2', - name: 'userTag 2', - usersAssignedTo: { - totalCount: 5, - }, - childTags: { - totalCount: 0, - }, - }, - cursor: '2', - }, - { - node: { - _id: '3', - name: 'userTag 3', - usersAssignedTo: { - totalCount: 0, - }, - childTags: { - totalCount: 5, - }, - }, - cursor: '3', - }, - { - node: { - _id: '4', - name: 'userTag 4', - usersAssignedTo: { - totalCount: 0, - }, - childTags: { - totalCount: 0, - }, - }, - cursor: '4', - }, - { - node: { - _id: '5', - name: 'userTag 5', - usersAssignedTo: { - totalCount: 5, - }, - childTags: { - totalCount: 5, - }, - }, - cursor: '5', - }, - { - node: { - _id: '6', - name: 'userTag 6', - usersAssignedTo: { - totalCount: 6, - }, - childTags: { - totalCount: 6, - }, - }, - cursor: '6', - }, - { - node: { - _id: '7', - name: 'userTag 7', - usersAssignedTo: { - totalCount: 7, - }, - childTags: { - totalCount: 7, - }, - }, - cursor: '7', - }, - { - node: { - _id: '8', - name: 'userTag 8', - usersAssignedTo: { - totalCount: 8, - }, - childTags: { - totalCount: 8, - }, - }, - cursor: '8', - }, - { - node: { - _id: '9', - name: 'userTag 9', - usersAssignedTo: { - totalCount: 9, - }, - childTags: { - totalCount: 9, - }, - }, - cursor: '9', - }, - { - node: { - _id: '10', - name: 'userTag 10', - usersAssignedTo: { - totalCount: 10, - }, - childTags: { - totalCount: 10, - }, - }, - cursor: '10', - }, - ], - pageInfo: { - startCursor: '1', - endCursor: '10', - hasNextPage: true, - hasPreviousPage: false, - }, - totalCount: 12, - }, - }, - ], - }, - }, - }, { request: { query: UPDATE_USER_TAG, @@ -504,64 +323,12 @@ export const MOCKS_ERROR_ASSIGNED_MEMBERS = [ query: USER_TAGS_ASSIGNED_MEMBERS, variables: { id: '1', - after: null, - before: null, - first: 5, - last: null, - }, - }, - error: new Error('Mock Graphql Error'), - }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: '1', - }, - }, - result: { - data: { - getUserTagAncestors: [], - }, - }, - }, -]; - -export const MOCKS_ERROR_TAG_ANCESTORS = [ - { - request: { - query: USER_TAGS_ASSIGNED_MEMBERS, - variables: { - id: '1', - after: null, - before: null, - first: 5, - last: null, - }, - }, - result: { - data: { - getUserTag: { - name: 'tag1', - usersAssignedTo: { - edges: [], - pageInfo: { - startCursor: '1', - endCursor: '5', - hasNextPage: true, - hasPreviousPage: false, - }, - totalCount: 6, - }, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, }, - }, - }, - }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: '1', + sortedBy: { id: 'DESCENDING' }, }, }, error: new Error('Mock Graphql Error'), diff --git a/src/screens/ManageTag/RemoveUserTagModal.tsx b/src/screens/ManageTag/RemoveUserTagModal.tsx new file mode 100644 index 0000000000..dc000f443c --- /dev/null +++ b/src/screens/ManageTag/RemoveUserTagModal.tsx @@ -0,0 +1,72 @@ +import type { TFunction } from 'i18next'; +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; + +/** + * Remove UserTag Modal component for the Manage Tag screen. + */ + +export interface InterfaceRemoveUserTagModalProps { + removeUserTagModalIsOpen: boolean; + toggleRemoveUserTagModal: () => void; + handleRemoveUserTag: () => Promise; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +const RemoveUserTagModal: React.FC = ({ + removeUserTagModalIsOpen, + toggleRemoveUserTagModal, + handleRemoveUserTag, + t, + tCommon, +}) => { + return ( + <> + + + + {t('removeUserTag')} + + + + {t('removeUserTagMessage')} + + + + + + + + ); +}; + +export default RemoveUserTagModal; diff --git a/src/screens/ManageTag/UnassignUserTagModal.tsx b/src/screens/ManageTag/UnassignUserTagModal.tsx new file mode 100644 index 0000000000..46df2fa4ac --- /dev/null +++ b/src/screens/ManageTag/UnassignUserTagModal.tsx @@ -0,0 +1,80 @@ +import type { TFunction } from 'i18next'; +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; + +/** + * Unassign UserTag Modal component for the Manage Tag screen. + */ + +export interface InterfaceUnassignUserTagModalProps { + unassignUserTagModalIsOpen: boolean; + toggleUnassignUserTagModal: () => void; + handleUnassignUserTag: () => Promise; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +const UnassignUserTagModal: React.FC = ({ + unassignUserTagModalIsOpen, + toggleUnassignUserTagModal, + handleUnassignUserTag, + t, + tCommon, +}) => { + return ( + <> + + + + {t('unassignUserTag')} + + + {t('unassignUserTagMessage')} + + + + + + + ); +}; + +export default UnassignUserTagModal; diff --git a/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx b/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx index 3d45e12a25..fffeebfd7f 100644 --- a/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx @@ -38,11 +38,14 @@ const itemProps: InterfaceItemDeleteModalProps = { actionItemsRefetch: jest.fn(), actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, actionItemCategory: { _id: 'actionItemCategoryId1', @@ -55,12 +58,12 @@ const itemProps: InterfaceItemDeleteModalProps = { completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', diff --git a/src/screens/OrganizationActionItems/ItemModal.test.tsx b/src/screens/OrganizationActionItems/ItemModal.test.tsx index 6901e16291..a58496e6df 100644 --- a/src/screens/OrganizationActionItems/ItemModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemModal.test.tsx @@ -47,6 +47,7 @@ const itemProps: InterfaceItemModalProps[] = [ isOpen: true, hide: jest.fn(), orgId: 'orgId', + eventId: undefined, actionItemsRefetch: jest.fn(), editMode: false, actionItem: null, @@ -55,11 +56,24 @@ const itemProps: InterfaceItemModalProps[] = [ isOpen: true, hide: jest.fn(), orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: false, + actionItem: null, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: undefined, actionItemsRefetch: jest.fn(), editMode: true, actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId1', firstName: 'Harve', lastName: 'Lance', @@ -76,12 +90,12 @@ const itemProps: InterfaceItemModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', @@ -94,11 +108,15 @@ const itemProps: InterfaceItemModalProps[] = [ isOpen: true, hide: jest.fn(), orgId: 'orgId', + eventId: undefined, actionItemsRefetch: jest.fn(), editMode: true, actionItem: { _id: 'actionItemId2', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', @@ -115,7 +133,134 @@ const itemProps: InterfaceItemModalProps[] = [ completionDate: new Date('2044-10-03'), isCompleted: false, event: null, - allotedHours: null, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [], + }, + assigneeGroup: null, + assigneeUser: null, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: { + _id: 'eventId', + title: 'Event 1', + }, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assigneeType: 'EventVolunteerGroup', + assigneeGroup: { + _id: 'groupId1', + name: 'group1', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + assignee: null, + assigneeUser: null, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: { + _id: 'eventId', + title: 'Event 1', + }, + allottedHours: null, assigner: { _id: 'userId2', firstName: 'Wilt', @@ -151,7 +296,7 @@ const renderItemModal = ( }; describe('Testing ItemModal', () => { - it('Create Action Item', async () => { + it('Create Action Item (for Member)', async () => { renderItemModal(link1, itemProps[0]); expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); @@ -181,12 +326,12 @@ describe('Testing ItemModal', () => { }); // Select Allotted Hours (try all options) - const allotedHours = screen.getByLabelText(t.allotedHours); - const allotedHoursOptions = ['', '-1', '9']; + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; - allotedHoursOptions.forEach((option) => { - fireEvent.change(allotedHours, { target: { value: option } }); - expect(allotedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); }); // Add Pre Completion Notes @@ -206,8 +351,132 @@ describe('Testing ItemModal', () => { }); }); - it('Update Action Item (completed)', async () => { + it('Create Action Item (for Volunteer)', async () => { + renderItemModal(link1, itemProps[1]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(individualRadio); + + // Select Individual Volunteer + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('Teresa Bradley'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Create Action Item (for Group)', async () => { renderItemModal(link1, itemProps[1]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group1'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Update Action Item (completed)', async () => { + renderItemModal(link1, itemProps[2]); expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); // Update Category @@ -221,12 +490,12 @@ describe('Testing ItemModal', () => { fireEvent.click(categoryOption); // Update Allotted Hours (try all options) - const allotedHours = screen.getByLabelText(t.allotedHours); - const allotedHoursOptions = ['', '-1', '19']; + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; - allotedHoursOptions.forEach((option) => { - fireEvent.change(allotedHours, { target: { value: option } }); - expect(allotedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); }); // Update Post Completion Notes @@ -240,14 +509,170 @@ describe('Testing ItemModal', () => { fireEvent.click(submitButton); await waitFor(() => { - expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); - expect(itemProps[1].hide).toHaveBeenCalled(); + expect(itemProps[2].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[2].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Volunteer)', async () => { + renderItemModal(link1, itemProps[4]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(individualRadio); + + // Select Individual Volunteer + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('Bruce Graza'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[4].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[4].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Group)', async () => { + renderItemModal(link1, itemProps[5]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group2'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[5].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[5].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Volunteer -> Group)', async () => { + renderItemModal(link1, itemProps[4]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group2'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[4].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[4].hide).toHaveBeenCalled(); expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); }); }); it('Update Action Item (not completed)', async () => { - renderItemModal(link1, itemProps[2]); + renderItemModal(link1, itemProps[3]); expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); // Update Category @@ -271,12 +696,12 @@ describe('Testing ItemModal', () => { fireEvent.click(memberOption); // Update Allotted Hours (try all options) - const allotedHours = screen.getByLabelText(t.allotedHours); - const allotedHoursOptions = ['', '-1', '19']; + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; - allotedHoursOptions.forEach((option) => { - fireEvent.change(allotedHours, { target: { value: option } }); - expect(allotedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); }); // Update Due Date @@ -295,8 +720,8 @@ describe('Testing ItemModal', () => { fireEvent.click(submitButton); await waitFor(() => { - expect(itemProps[2].actionItemsRefetch).toHaveBeenCalled(); - expect(itemProps[2].hide).toHaveBeenCalled(); + expect(itemProps[3].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[3].hide).toHaveBeenCalled(); expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); }); }); @@ -304,27 +729,27 @@ describe('Testing ItemModal', () => { it('Try adding negative Allotted Hours', async () => { renderItemModal(link1, itemProps[0]); expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); - const allotedHours = screen.getByLabelText(t.allotedHours); - fireEvent.change(allotedHours, { target: { value: '-1' } }); + const allottedHours = screen.getByLabelText(t.allottedHours); + fireEvent.change(allottedHours, { target: { value: '-1' } }); await waitFor(() => { - expect(allotedHours).toHaveValue(''); + expect(allottedHours).toHaveValue(''); }); - fireEvent.change(allotedHours, { target: { value: '' } }); + fireEvent.change(allottedHours, { target: { value: '' } }); await waitFor(() => { - expect(allotedHours).toHaveValue(''); + expect(allottedHours).toHaveValue(''); }); - fireEvent.change(allotedHours, { target: { value: '0' } }); + fireEvent.change(allottedHours, { target: { value: '0' } }); await waitFor(() => { - expect(allotedHours).toHaveValue('0'); + expect(allottedHours).toHaveValue('0'); }); - fireEvent.change(allotedHours, { target: { value: '19' } }); + fireEvent.change(allottedHours, { target: { value: '19' } }); await waitFor(() => { - expect(allotedHours).toHaveValue('19'); + expect(allottedHours).toHaveValue('19'); }); }); @@ -341,7 +766,7 @@ describe('Testing ItemModal', () => { }); it('No Fields Updated while Updating', async () => { - renderItemModal(link2, itemProps[1]); + renderItemModal(link2, itemProps[2]); // Click Submit const submitButton = screen.getByTestId('submitBtn'); expect(submitButton).toBeInTheDocument(); @@ -353,7 +778,7 @@ describe('Testing ItemModal', () => { }); it('should fail to Update Action Item', async () => { - renderItemModal(link2, itemProps[1]); + renderItemModal(link2, itemProps[2]); expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); // Update Post Completion Notes diff --git a/src/screens/OrganizationActionItems/ItemModal.tsx b/src/screens/OrganizationActionItems/ItemModal.tsx index d7a38ee0df..a66052d25a 100644 --- a/src/screens/OrganizationActionItems/ItemModal.tsx +++ b/src/screens/OrganizationActionItems/ItemModal.tsx @@ -10,8 +10,10 @@ import type { InterfaceActionItemCategoryInfo, InterfaceActionItemCategoryList, InterfaceActionItemInfo, + InterfaceEventVolunteerInfo, InterfaceMemberInfo, InterfaceMembersList, + InterfaceVolunteerGroupInfo, } from 'utils/interfaces'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; @@ -21,20 +23,26 @@ import { UPDATE_ACTION_ITEM_MUTATION, } from 'GraphQl/Mutations/ActionItemMutations'; import { ACTION_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/ActionItemCategoryQueries'; -import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; import { Autocomplete, FormControl, TextField } from '@mui/material'; +import { + EVENT_VOLUNTEER_GROUP_LIST, + EVENT_VOLUNTEER_LIST, +} from 'GraphQl/Queries/EventVolunteerQueries'; +import { HiUser, HiUserGroup } from 'react-icons/hi2'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; /** * Interface for the form state used in the `ItemModal` component. */ interface InterfaceFormStateType { dueDate: Date; + assigneeType: 'EventVolunteer' | 'EventVolunteerGroup' | 'User'; actionItemCategoryId: string; assigneeId: string; eventId?: string; preCompletionNotes: string; postCompletionNotes: string | null; - allotedHours: number | null; + allottedHours: number | null; isCompleted: boolean; } @@ -45,6 +53,7 @@ export interface InterfaceItemModalProps { isOpen: boolean; hide: () => void; orgId: string; + eventId: string | undefined; actionItemsRefetch: () => void; actionItem: InterfaceActionItemInfo | null; editMode: boolean; @@ -62,10 +71,15 @@ const initializeFormState = ( ): InterfaceFormStateType => ({ dueDate: actionItem?.dueDate || new Date(), actionItemCategoryId: actionItem?.actionItemCategory?._id || '', - assigneeId: actionItem?.assignee._id || '', + assigneeId: + actionItem?.assignee?._id || + actionItem?.assigneeGroup?._id || + actionItem?.assigneeUser?._id || + '', + assigneeType: actionItem?.assigneeType || 'User', preCompletionNotes: actionItem?.preCompletionNotes || '', postCompletionNotes: actionItem?.postCompletionNotes || null, - allotedHours: actionItem?.allotedHours || null, + allottedHours: actionItem?.allottedHours || null, isCompleted: actionItem?.isCompleted || false, }); @@ -79,6 +93,7 @@ const ItemModal: FC = ({ isOpen, hide, orgId, + eventId, actionItem, editMode, actionItemsRefetch, @@ -89,7 +104,15 @@ const ItemModal: FC = ({ const [actionItemCategory, setActionItemCategory] = useState(null); - const [assignee, setAssignee] = useState(null); + const [assignee, setAssignee] = useState( + null, + ); + const [assigneeGroup, setAssigneeGroup] = + useState(null); + + const [assigneeUser, setAssigneeUser] = useState( + null, + ); const [formState, setFormState] = useState( initializeFormState(actionItem), @@ -97,11 +120,12 @@ const ItemModal: FC = ({ const { dueDate, + assigneeType, actionItemCategoryId, assigneeId, preCompletionNotes, postCompletionNotes, - allotedHours, + allottedHours, isCompleted, } = formState; @@ -119,6 +143,41 @@ const ItemModal: FC = ({ }, }); + /** + * Query to fetch event volunteers for the event. + */ + const { + data: volunteersData, + }: { + data?: { + getEventVolunteers: InterfaceEventVolunteerInfo[]; + }; + } = useQuery(EVENT_VOLUNTEER_LIST, { + variables: { + where: { + eventId: eventId, + hasAccepted: true, + }, + }, + }); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: eventId, + }, + }, + }); + /** * Query to fetch members of the organization. */ @@ -130,16 +189,26 @@ const ItemModal: FC = ({ variables: { id: orgId }, }); - const actionItemCategories = useMemo( - () => actionItemCategoriesData?.actionItemCategoriesByOrganization || [], - [actionItemCategoriesData], - ); - const members = useMemo( () => membersData?.organizations[0].members || [], [membersData], ); + const volunteers = useMemo( + () => volunteersData?.getEventVolunteers || [], + [volunteersData], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + const actionItemCategories = useMemo( + () => actionItemCategoriesData?.actionItemCategoriesByOrganization || [], + [actionItemCategoriesData], + ); + /** * Mutation to create & update a new action item. */ @@ -171,13 +240,16 @@ const ItemModal: FC = ({ ): Promise => { e.preventDefault(); try { + const dDate = dayjs(dueDate).format('YYYY-MM-DD'); await createActionItem({ variables: { - assigneeId: assignee?._id, + dDate: dDate, + assigneeId: assigneeId, + assigneeType: assigneeType, actionItemCategoryId: actionItemCategory?._id, preCompletionNotes: preCompletionNotes, - allotedHours: allotedHours, - dueDate: dayjs(dueDate).format('YYYY-MM-DD'), + allottedHours: allottedHours, + ...(eventId && { eventId }), }, }); @@ -211,10 +283,32 @@ const ItemModal: FC = ({ if (actionItemCategoryId !== actionItem?.actionItemCategory?._id) { updatedFields.actionItemCategoryId = actionItemCategoryId; } - if (assigneeId !== actionItem?.assignee._id) { + + if ( + assigneeId !== actionItem?.assignee?._id && + assigneeType === 'EventVolunteer' + ) { updatedFields.assigneeId = assigneeId; } + if ( + assigneeId !== actionItem?.assigneeGroup?._id && + assigneeType === 'EventVolunteerGroup' + ) { + updatedFields.assigneeId = assigneeId; + } + + if ( + assigneeId !== actionItem?.assigneeUser?._id && + assigneeType === 'User' + ) { + updatedFields.assigneeId = assigneeId; + } + + if (assigneeType !== actionItem?.assigneeType) { + updatedFields.assigneeType = assigneeType; + } + if (preCompletionNotes !== actionItem?.preCompletionNotes) { updatedFields.preCompletionNotes = preCompletionNotes; } @@ -223,8 +317,8 @@ const ItemModal: FC = ({ updatedFields.postCompletionNotes = postCompletionNotes; } - if (allotedHours !== actionItem?.allotedHours) { - updatedFields.allotedHours = allotedHours; + if (allottedHours !== actionItem?.allottedHours) { + updatedFields.allottedHours = allottedHours; } if (dueDate !== actionItem?.dueDate) { @@ -240,6 +334,7 @@ const ItemModal: FC = ({ variables: { actionItemId: actionItem?._id, assigneeId: assigneeId, + assigneeType: assigneeType, ...updatedFields, }, }); @@ -261,9 +356,19 @@ const ItemModal: FC = ({ ) || null, ); setAssignee( - members.find((member) => member._id === actionItem?.assignee._id) || null, + volunteers.find( + (volunteer) => volunteer._id === actionItem?.assignee?._id, + ) || null, + ); + setAssigneeGroup( + groups.find((group) => group._id === actionItem?.assigneeGroup?._id) || + null, ); - }, [actionItem, actionItemCategories, members]); + setAssigneeUser( + members.find((member) => member._id === actionItem?.assigneeUser?._id) || + null, + ); + }, [actionItem, actionItemCategories, volunteers, groups, members]); return ( @@ -316,16 +421,16 @@ const ItemModal: FC = ({ /> {isCompleted && ( <> - {/* Input text Component to add alloted Hours for action item */} + {/* Input text Component to add allotted Hours for action item */} handleFormChange( - 'allotedHours', + 'allottedHours', e.target.value === '' || parseInt(e.target.value) < 0 ? null : parseInt(e.target.value), @@ -338,29 +443,132 @@ const ItemModal: FC = ({ {!isCompleted && ( <> - - - option._id === value._id - } - filterSelectedOptions={true} - getOptionLabel={(member: InterfaceMemberInfo): string => - `${member.firstName} ${member.lastName}` - } - onChange={(_, newAssignee): void => { - /* istanbul ignore next */ - handleFormChange('assigneeId', newAssignee?._id ?? ''); - setAssignee(newAssignee); - }} - renderInput={(params) => ( - - )} - /> - + {eventId && ( + <> + {t('assignTo')} +
+ + handleFormChange('assigneeType', 'EventVolunteer') + } + /> + + + + handleFormChange('assigneeType', 'EventVolunteerGroup') + } + checked={assigneeType === 'EventVolunteerGroup'} + /> + +
+ + )} + + {assigneeType === 'EventVolunteer' ? ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={( + volunteer: InterfaceEventVolunteerInfo, + ): string => + `${volunteer.user.firstName} ${volunteer.user.lastName}` + } + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssignee(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + ) : assigneeType === 'EventVolunteerGroup' ? ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={( + group: InterfaceVolunteerGroupInfo, + ): string => `${group.name}`} + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssigneeGroup(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + ) : ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceMemberInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssigneeUser(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + )} {/* Date Calendar Component to select due date of an action item */} @@ -375,16 +583,16 @@ const ItemModal: FC = ({ }} /> - {/* Input text Component to add alloted Hours for action item */} + {/* Input text Component to add allotted Hours for action item */} handleFormChange( - 'allotedHours', + 'allottedHours', e.target.value === '' || parseInt(e.target.value) < 0 ? null : parseInt(e.target.value), diff --git a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx index c1fee119cc..aa28b14d40 100644 --- a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx @@ -39,11 +39,14 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ actionItemsRefetch: jest.fn(), actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { _id: 'userId1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, actionItemCategory: { _id: 'actionItemCategoryId1', @@ -56,12 +59,12 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', @@ -76,11 +79,47 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ actionItemsRefetch: jest.fn(), actionItem: { _id: 'actionItemId1', - assignee: { + assignee: null, + assigneeGroup: { + _id: 'volunteerGroupId1', + name: 'Group 1', + description: 'Description 1', + event: { + _id: 'eventId1', + }, + createdAt: '2024-08-27', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + leader: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + volunteersRequired: 10, + assignments: [], + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + }, + ], + }, + assigneeType: 'EventVolunteerGroup', + assigneeUser: { _id: 'userId1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, actionItemCategory: { _id: 'actionItemCategoryId1', @@ -93,12 +132,59 @@ const itemProps: InterfaceItemUpdateStatusModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: false, event: null, - allotedHours: 24, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + actionItemsRefetch: jest.fn(), + actionItem: { + _id: 'actionItemId1', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + assignments: [], + groups: [], + hoursVolunteered: 0, + }, + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory: { + _id: 'actionItemCategoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, assigner: { _id: 'userId2', firstName: 'Wilt', lastName: 'Shepherd', - image: null, + image: undefined, }, creator: { _id: 'userId2', @@ -160,7 +246,7 @@ describe('Testing ItemUpdateStatusModal', () => { }); it('should fail to Update status of Action Item', async () => { - renderItemUpdateStatusModal(link2, itemProps[0]); + renderItemUpdateStatusModal(link2, itemProps[2]); expect(screen.getByText(t.actionItemStatus)).toBeInTheDocument(); const yesBtn = await screen.findByTestId('yesBtn'); diff --git a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx index 44ac0e63e6..6b709a1929 100644 --- a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx +++ b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx @@ -12,7 +12,7 @@ export interface InterfaceItemUpdateStatusModalProps { isOpen: boolean; hide: () => void; actionItemsRefetch: () => void; - actionItem: InterfaceActionItemInfo | null; + actionItem: InterfaceActionItemInfo; } const ItemUpdateStatusModal: FC = ({ @@ -26,11 +26,17 @@ const ItemUpdateStatusModal: FC = ({ }); const { t: tCommon } = useTranslation('common'); - const [isCompleted, setIsCompleted] = useState( - actionItem?.isCompleted ?? false, - ); + const { + _id, + isCompleted, + assignee, + assigneeGroup, + assigneeUser, + assigneeType, + } = actionItem; + const [postCompletionNotes, setPostCompletionNotes] = useState( - actionItem?.postCompletionNotes ?? '', + actionItem.postCompletionNotes ?? '', ); /** @@ -51,8 +57,14 @@ const ItemUpdateStatusModal: FC = ({ try { await updateActionItem({ variables: { - actionItemId: actionItem?._id, - assigneeId: actionItem?.assignee?._id, + actionItemId: _id, + assigneeId: + assigneeType === 'EventVolunteer' + ? assignee?._id + : assigneeType === 'EventVolunteerGroup' + ? assigneeGroup?._id + : assigneeUser?._id, + assigneeType, postCompletionNotes: isCompleted ? '' : postCompletionNotes, isCompleted: !isCompleted, }, @@ -67,10 +79,7 @@ const ItemUpdateStatusModal: FC = ({ }; useEffect(() => { - if (actionItem) { - setIsCompleted(actionItem?.isCompleted); - setPostCompletionNotes(actionItem?.postCompletionNotes ?? ''); - } + setPostCompletionNotes(actionItem.postCompletionNotes ?? ''); }, [actionItem]); return ( diff --git a/src/screens/OrganizationActionItems/ItemViewModal.test.tsx b/src/screens/OrganizationActionItems/ItemViewModal.test.tsx index 13c208b854..297cfab6a8 100644 --- a/src/screens/OrganizationActionItems/ItemViewModal.test.tsx +++ b/src/screens/OrganizationActionItems/ItemViewModal.test.tsx @@ -3,7 +3,7 @@ import type { ApolloLink } from '@apollo/client'; import { MockedProvider } from '@apollo/react-testing'; import { LocalizationProvider } from '@mui/x-date-pickers'; import type { RenderResult } from '@testing-library/react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; @@ -13,6 +13,11 @@ import i18nForTest from '../../utils/i18nForTest'; import { MOCKS } from './OrganizationActionItem.mocks'; import { StaticMockLink } from 'utils/StaticMockLink'; import ItemViewModal, { type InterfaceViewModalProps } from './ItemViewModal'; +import type { + InterfaceEventVolunteerInfo, + InterfaceUserInfo, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; jest.mock('react-toastify', () => ({ toast: { @@ -28,22 +33,67 @@ const t = JSON.parse( ), ); +const createUser = ( + id: string, + firstName: string, + lastName: string, + image?: string, +): InterfaceUserInfo => ({ + _id: id, + firstName, + lastName, + image, +}); + +const createAssignee = ( + user: ReturnType, + hasAccepted = true, +): InterfaceEventVolunteerInfo => ({ + _id: `${user._id}-assignee`, + user, + assignments: [], + groups: [], + hasAccepted, + hoursVolunteered: 0, +}); + +const createAssigneeGroup = ( + id: string, + name: string, + leader: ReturnType, +): InterfaceVolunteerGroupInfo => ({ + _id: id, + name, + description: `${name} description`, + event: { _id: 'eventId1' }, + volunteers: [], + assignments: [], + volunteersRequired: 10, + leader, + creator: leader, + createdAt: '2024-08-27', +}); + +const userWithImage = createUser('userId', 'Wilt', 'Shepherd', 'wilt-image'); +const userWithoutImage = createUser('userId', 'Wilt', 'Shepherd'); +const assigneeWithImage = createUser('userId1', 'John', 'Doe', 'image-url'); +const assigneeWithoutImage = createUser('userId1', 'John', 'Doe'); +const actionItemCategory = { + _id: 'actionItemCategoryId2', + name: 'Category 2', +}; + const itemProps: InterfaceViewModalProps[] = [ { isOpen: true, hide: jest.fn(), item: { _id: 'actionItemId1', - assignee: { - _id: 'userId1', - firstName: 'John', - lastName: 'Doe', - image: null, - }, - actionItemCategory: { - _id: 'actionItemCategoryId1', - name: 'Category 1', - }, + assignee: createAssignee(assigneeWithoutImage), + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory, preCompletionNotes: 'Notes 1', postCompletionNotes: 'Cmp Notes 1', assignmentDate: new Date('2024-08-27'), @@ -51,18 +101,9 @@ const itemProps: InterfaceViewModalProps[] = [ completionDate: new Date('2044-09-03'), isCompleted: true, event: null, - allotedHours: 24, - assigner: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - image: null, - }, - creator: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - }, + allottedHours: 24, + assigner: userWithoutImage, + creator: userWithoutImage, }, }, { @@ -70,16 +111,11 @@ const itemProps: InterfaceViewModalProps[] = [ hide: jest.fn(), item: { _id: 'actionItemId2', - assignee: { - _id: 'userId1', - firstName: 'Jane', - lastName: 'Doe', - image: 'image-url', - }, - actionItemCategory: { - _id: 'actionItemCategoryId2', - name: 'Category 2', - }, + assignee: createAssignee(assigneeWithImage), + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory, preCompletionNotes: 'Notes 2', postCompletionNotes: null, assignmentDate: new Date('2024-08-27'), @@ -87,18 +123,79 @@ const itemProps: InterfaceViewModalProps[] = [ completionDate: new Date('2044-10-03'), isCompleted: false, event: null, - allotedHours: null, - assigner: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - image: 'wilt-image', - }, - creator: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - }, + allottedHours: null, + assigner: userWithImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: assigneeWithImage, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: createUser('userId1', 'Jane', 'Doe'), + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithoutImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: createAssigneeGroup( + 'groupId1', + 'Group 1', + assigneeWithoutImage, + ), + assigneeType: 'EventVolunteerGroup', + assigneeUser: null, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithoutImage, + creator: userWithoutImage, }, }, ]; @@ -126,22 +223,65 @@ describe('Testing ItemViewModal', () => { it('should render ItemViewModal with pending item & assignee with null image', () => { renderItemViewModal(link1, itemProps[0]); expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); - expect(screen.getByTestId('John_avatar')).toBeInTheDocument(); - expect(screen.getByTestId('Wilt_avatar')).toBeInTheDocument(); + + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('John Doe'); + + expect(screen.getByTestId('assignee_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); expect(screen.getByLabelText(t.postCompletionNotes)).toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toHaveValue('24'); + expect(screen.getByLabelText(t.allottedHours)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toHaveValue('24'); }); - it('should render ItemViewModal with completed item & assignee with null image', () => { + it('should render ItemViewModal with completed item & assignee with image', () => { renderItemViewModal(link1, itemProps[1]); expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); - expect(screen.getByTestId('Jane_image')).toBeInTheDocument(); - expect(screen.getByTestId('Wilt_image')).toBeInTheDocument(); + expect(screen.getByTestId('assignee_image')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_image')).toBeInTheDocument(); expect( screen.queryByLabelText(t.postCompletionNotes), ).not.toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toBeInTheDocument(); - expect(screen.getByLabelText(t.allotedHours)).toHaveValue('-'); + expect(screen.getByLabelText(t.allottedHours)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toHaveValue('-'); + }); + + it('should render ItemViewModal with assigneeUser with image', () => { + renderItemViewModal(link1, itemProps[2]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_image')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_image')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('John Doe'); + }); + + it('should render ItemViewModal with assigneeUser without image', () => { + renderItemViewModal(link1, itemProps[3]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('Jane Doe'); + }); + + it('should render ItemViewModal with assigneeGroup', () => { + renderItemViewModal(link1, itemProps[4]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assigneeGroup_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('Group 1'); }); }); diff --git a/src/screens/OrganizationActionItems/ItemViewModal.tsx b/src/screens/OrganizationActionItems/ItemViewModal.tsx index 84d9a3e7fe..5a78ac8a91 100644 --- a/src/screens/OrganizationActionItems/ItemViewModal.tsx +++ b/src/screens/OrganizationActionItems/ItemViewModal.tsx @@ -38,13 +38,16 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { const { actionItemCategory, assignee, + assigneeGroup, + assigneeUser, + assigneeType, assigner, completionDate, dueDate, isCompleted, postCompletionNotes, preCompletionNotes, - allotedHours, + allottedHours, } = item; return ( @@ -79,29 +82,46 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { label={t('assignee')} variant="outlined" className={styles.noOutline} - value={assignee.firstName + ' ' + assignee.lastName} + data-testid="assignee_input" + value={ + assigneeType === 'EventVolunteer' + ? `${assignee?.user?.firstName} ${assignee?.user?.lastName}` + : assigneeType === 'EventVolunteerGroup' + ? assigneeGroup?.name + : `${assigneeUser?.firstName} ${assigneeUser?.lastName}` + } disabled InputProps={{ startAdornment: ( <> - {assignee.image ? ( + {assignee?.user?.image || assigneeUser?.image ? ( Assignee + ) : assignee || assigneeUser ? ( + ) : ( -
- -
+ )} ), @@ -121,8 +141,8 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { {assigner.image ? ( Assignee ) : ( @@ -131,9 +151,9 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { key={assigner._id + '1'} containerStyle={styles.imageContainer} avatarStyle={styles.TableImage} - dataTestId={`${assigner.firstName}_avatar`} + dataTestId={`assigner_avatar`} name={assigner.firstName + ' ' + assigner.lastName} - alt={assigner.firstName + ' ' + assigner.lastName} + alt={`assigner_avatar`} />
)} @@ -172,10 +192,10 @@ const ItemViewModal: FC = ({ isOpen, hide, item }) => { /> diff --git a/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts b/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts index 0d57023507..d7d661fc9d 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts +++ b/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts @@ -4,146 +4,19 @@ import { DELETE_ACTION_ITEM_MUTATION, UPDATE_ACTION_ITEM_MUTATION, } from 'GraphQl/Mutations/ActionItemMutations'; -import { - ACTION_ITEM_CATEGORY_LIST, - ACTION_ITEM_LIST, - MEMBERS_LIST, -} from 'GraphQl/Queries/Queries'; - -const baseActionItem = { - assigner: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - image: null, - }, - creator: { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - __typename: 'User', - }, -}; - -const actionItem1 = { - _id: 'actionItemId1', - assignee: { - _id: 'userId1', - firstName: 'John', - lastName: 'Doe', - image: null, - }, - actionItemCategory: { - _id: 'actionItemCategoryId1', - name: 'Category 1', - }, - preCompletionNotes: 'Notes 1', - postCompletionNotes: 'Cmp Notes 1', - assignmentDate: '2024-08-27', - dueDate: '2044-08-30', - completionDate: '2044-09-03', - isCompleted: true, - event: null, - allotedHours: 24, - ...baseActionItem, -}; - -const actionItem2 = { - _id: 'actionItemId2', - assignee: { - _id: 'userId1', - firstName: 'Jane', - lastName: 'Doe', - image: 'image-url', - }, - actionItemCategory: { - _id: 'actionItemCategoryId2', - name: 'Category 2', - }, - preCompletionNotes: 'Notes 2', - postCompletionNotes: null, - assignmentDate: '2024-08-27', - dueDate: '2044-09-30', - completionDate: '2044-10-03', - isCompleted: false, - event: null, - allotedHours: null, - ...baseActionItem, -}; - -const memberListQuery = { - request: { - query: MEMBERS_LIST, - variables: { id: 'orgId' }, - }, - result: { - data: { - organizations: [ - { - _id: 'orgId', - members: [ - { - _id: 'userId1', - firstName: 'Harve', - lastName: 'Lance', - email: 'harve@example.com', - image: '', - organizationsBlockedBy: [], - createdAt: '2024-02-14', - }, - { - _id: 'userId2', - firstName: 'Wilt', - lastName: 'Shepherd', - email: 'wilt@example.com', - image: '', - organizationsBlockedBy: [], - createdAt: '2024-02-14', - }, - ], - }, - ], - }, - }, -}; +import { ACTION_ITEM_LIST } from 'GraphQl/Queries/Queries'; -const actionItemCategoryListQuery = { - request: { - query: ACTION_ITEM_CATEGORY_LIST, - variables: { - organizationId: 'orgId', - where: { is_disabled: false }, - }, - }, - result: { - data: { - actionItemCategoriesByOrganization: [ - { - _id: 'categoryId1', - name: 'Category 1', - isDisabled: false, - createdAt: '2024-08-26', - creator: { - _id: 'creatorId1', - firstName: 'Wilt', - lastName: 'Shepherd', - }, - }, - { - _id: 'categoryId2', - name: 'Category 2', - isDisabled: true, - createdAt: '2024-08-25', - creator: { - _id: 'creatorId2', - firstName: 'John', - lastName: 'Doe', - }, - }, - ], - }, - }, -}; +import { + actionItemCategoryListQuery, + groupListQuery, + itemWithGroup, + itemWithUser, + itemWithUserImage, + itemWithVolunteer, + itemWithVolunteerImage, + memberListQuery, + volunteerListQuery, +} from './testObject.mocks'; export const MOCKS = [ { @@ -159,7 +32,13 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1, actionItem2], + actionItemsByOrganization: [ + itemWithVolunteer, + itemWithUser, + itemWithGroup, + itemWithVolunteerImage, + itemWithUserImage, + ], }, }, }, @@ -176,7 +55,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1, actionItem2], + actionItemsByOrganization: [itemWithVolunteer, itemWithUser], }, }, }, @@ -193,7 +72,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1, actionItem2], + actionItemsByOrganization: [itemWithVolunteer, itemWithUser], }, }, }, @@ -210,7 +89,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem2, actionItem1], + actionItemsByOrganization: [itemWithUser, itemWithVolunteer], }, }, }, @@ -228,7 +107,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1], + actionItemsByOrganization: [itemWithVolunteer], }, }, }, @@ -246,7 +125,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem2], + actionItemsByOrganization: [itemWithUser], }, }, }, @@ -263,7 +142,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1], + actionItemsByOrganization: [itemWithVolunteer], }, }, }, @@ -280,7 +159,7 @@ export const MOCKS = [ }, result: { data: { - actionItemsByOrganization: [actionItem1], + actionItemsByOrganization: [itemWithVolunteer], }, }, }, @@ -305,6 +184,7 @@ export const MOCKS = [ variables: { actionItemId: 'actionItemId1', assigneeId: 'userId1', + assigneeType: 'User', postCompletionNotes: '', isCompleted: false, }, @@ -323,9 +203,29 @@ export const MOCKS = [ variables: { actionItemId: 'actionItemId1', assigneeId: 'userId1', + assigneeType: 'User', actionItemCategoryId: 'categoryId2', postCompletionNotes: 'Cmp Notes 2', - allotedHours: 19, + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'volunteerGroupId1', + assigneeType: 'EventVolunteerGroup', + postCompletionNotes: 'Cmp Notes 1', + isCompleted: true, }, }, result: { @@ -342,9 +242,10 @@ export const MOCKS = [ variables: { actionItemId: 'actionItemId2', assigneeId: 'userId1', + assigneeType: 'User', actionItemCategoryId: 'categoryId1', preCompletionNotes: 'Notes 3', - allotedHours: 19, + allottedHours: 19, dueDate: '2044-01-02', }, }, @@ -374,15 +275,116 @@ export const MOCKS = [ }, }, }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'volunteerId2', + assigneeType: 'EventVolunteer', + actionItemCategoryId: 'categoryId1', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'groupId2', + assigneeType: 'EventVolunteerGroup', + actionItemCategoryId: 'categoryId1', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, { request: { query: CREATE_ACTION_ITEM_MUTATION, variables: { assigneeId: 'userId1', + assigneeType: 'User', actionItemCategoryId: 'categoryId1', preCompletionNotes: 'Notes', - allotedHours: 9, - dueDate: '2044-01-02', + allottedHours: 9, + dDate: '2044-01-02', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'userId1', + assigneeType: 'User', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'volunteerId1', + assigneeType: 'EventVolunteer', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + eventId: 'eventId', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'groupId1', + assigneeType: 'EventVolunteerGroup', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + eventId: 'eventId', }, }, result: { @@ -395,6 +397,8 @@ export const MOCKS = [ }, memberListQuery, actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, ]; export const MOCKS_ERROR = [ @@ -425,7 +429,8 @@ export const MOCKS_ERROR = [ query: UPDATE_ACTION_ITEM_MUTATION, variables: { actionItemId: 'actionItemId1', - assigneeId: 'userId1', + assigneeId: 'volunteerId1', + assigneeType: 'EventVolunteer', postCompletionNotes: '', isCompleted: false, }, @@ -436,9 +441,11 @@ export const MOCKS_ERROR = [ request: { query: CREATE_ACTION_ITEM_MUTATION, variables: { + assigneeId: '', + assigneeType: 'User', preCompletionNotes: '', - allotedHours: null, - dueDate: dayjs().format('YYYY-MM-DD'), + allottedHours: null, + dDate: dayjs().format('YYYY-MM-DD'), }, }, error: new Error('Mock Graphql Error'), @@ -449,6 +456,7 @@ export const MOCKS_ERROR = [ variables: { actionItemId: 'actionItemId1', assigneeId: 'userId1', + assigneeType: 'User', postCompletionNotes: 'Cmp Notes 2', }, }, @@ -456,6 +464,8 @@ export const MOCKS_ERROR = [ }, memberListQuery, actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, ]; export const MOCKS_EMPTY = [ @@ -478,4 +488,6 @@ export const MOCKS_EMPTY = [ }, memberListQuery, actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, ]; diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.module.css b/src/screens/OrganizationActionItems/OrganizationActionItems.module.css index 48720ac902..97a20848ec 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.module.css +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.module.css @@ -231,3 +231,61 @@ hr { width: 28px; height: 26px; } + +/* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} /* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 50px; +} diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx index c163ff9546..44e11baa35 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { act } from 'react'; import { MockedProvider } from '@apollo/react-testing'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; @@ -47,6 +47,14 @@ const t = { ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), }; +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + const renderOrganizationActionItems = (link: ApolloLink): RenderResult => { return render( @@ -114,8 +122,8 @@ describe('Testing Organization Action Items Screen', () => { renderOrganizationActionItems(link1); await waitFor(() => { expect(screen.getByTestId('searchBy')).toBeInTheDocument(); - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getAllByText('John Doe')).toHaveLength(2); + expect(screen.getAllByText('Jane Doe')).toHaveLength(2); }); }); @@ -171,8 +179,8 @@ describe('Testing Organization Action Items Screen', () => { fireEvent.click(screen.getByTestId('statusAll')); await waitFor(() => { - expect(screen.getByText('Category 1')).toBeInTheDocument(); - expect(screen.getByText('Category 2')).toBeInTheDocument(); + expect(screen.getAllByText('Category 1')).toHaveLength(3); + expect(screen.getAllByText('Category 2')).toHaveLength(2); }); // Filter by Pending @@ -301,7 +309,8 @@ describe('Testing Organization Action Items Screen', () => { expect(searchInput).toBeInTheDocument(); userEvent.type(searchInput, 'John'); - userEvent.click(screen.getByTestId('searchBtn')); + await debounceWait(); + await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); expect(screen.queryByText('Category 2')).toBeNull(); @@ -325,35 +334,8 @@ describe('Testing Organization Action Items Screen', () => { expect(searchInput).toBeInTheDocument(); userEvent.type(searchInput, 'Category 1'); - userEvent.click(screen.getByTestId('searchBtn')); - await waitFor(() => { - expect(screen.getByText('Category 1')).toBeInTheDocument(); - expect(screen.queryByText('Category 2')).toBeNull(); - }); - }); + await debounceWait(); - it('Search action items by name and clear the input by backspace', async () => { - renderOrganizationActionItems(link1); - - const searchInput = await screen.findByTestId('searchBy'); - expect(searchInput).toBeInTheDocument(); - - // Clear the search input by backspace - userEvent.type(searchInput, 'A{backspace}'); - await waitFor(() => { - expect(screen.getByText('Category 1')).toBeInTheDocument(); - expect(screen.getByText('Category 2')).toBeInTheDocument(); - }); - }); - - it('Search action items by name on press of ENTER', async () => { - renderOrganizationActionItems(link1); - - const searchInput = await screen.findByTestId('searchBy'); - expect(searchInput).toBeInTheDocument(); - - userEvent.type(searchInput, 'John'); - userEvent.type(searchInput, '{enter}'); await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); expect(screen.queryByText('Category 2')).toBeNull(); diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx index ba48f212e0..429338b93c 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx @@ -26,7 +26,7 @@ import { type GridCellParams, type GridColDef, } from '@mui/x-data-grid'; -import { Chip, Stack } from '@mui/material'; +import { Chip, debounce, Stack } from '@mui/material'; import ItemViewModal from './ItemViewModal'; import ItemModal from './ItemModal'; import ItemDeleteModal from './ItemDeleteModal'; @@ -157,6 +157,11 @@ function organizationActionItems(): JSX.Element { [actionItemsData], ); + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + if (actionItemsLoading) { return ; } @@ -167,8 +172,6 @@ function organizationActionItems(): JSX.Element {
{tErrors('errorLoading', { entity: 'Action Items' })} -
- {`${actionItemsError.message}`}
); @@ -185,32 +188,58 @@ function organizationActionItems(): JSX.Element { sortable: false, headerClassName: `${styles.tableHeader}`, renderCell: (params: GridCellParams) => { - const { _id, firstName, lastName, image } = params.row.assignee; + const { _id, firstName, lastName, image } = + params.row.assigneeUser || params.row.assignee?.user || {}; + return ( -
- {image ? ( - Assignee + <> + {params.row.assigneeType !== 'EventVolunteerGroup' ? ( + <> +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ) : ( -
- -
+ <> +
+
+ +
+ {params.row.assigneeGroup?.name as string} +
+ )} - {params.row.assignee.firstName + ' ' + params.row.assignee.lastName} -
+ ); }, }, @@ -255,8 +284,8 @@ function organizationActionItems(): JSX.Element { }, }, { - field: 'allotedHours', - headerName: 'Alloted Hours', + field: 'allottedHours', + headerName: 'Allotted Hours', align: 'center', headerAlign: 'center', sortable: false, @@ -264,7 +293,9 @@ function organizationActionItems(): JSX.Element { flex: 1, renderCell: (params: GridCellParams) => { return ( -
{params.row.allotedHours ?? '-'}
+
+ {params.row.allottedHours ?? '-'} +
); }, }, @@ -353,7 +384,7 @@ function organizationActionItems(): JSX.Element { ]; return ( -
+
{/* Header with search, filter and Create Button */}
@@ -366,20 +397,15 @@ function organizationActionItems(): JSX.Element { required className={styles.inputField} value={searchValue} - onChange={(e) => setSearchValue(e.target.value)} - onKeyUp={(e) => { - if (e.key === 'Enter') { - setSearchTerm(searchValue); - } else if (e.key === 'Backspace' && searchValue === '') { - setSearchTerm(''); - } + onChange={(e) => { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); }} data-testid="searchBy" />
); diff --git a/src/screens/OrganizationActionItems/testObject.mocks.ts b/src/screens/OrganizationActionItems/testObject.mocks.ts new file mode 100644 index 0000000000..d43f574e8c --- /dev/null +++ b/src/screens/OrganizationActionItems/testObject.mocks.ts @@ -0,0 +1,402 @@ +import { + EVENT_VOLUNTEER_GROUP_LIST, + EVENT_VOLUNTEER_LIST, +} from 'GraphQl/Queries/EventVolunteerQueries'; +import { + ACTION_ITEM_CATEGORY_LIST, + MEMBERS_LIST, +} from 'GraphQl/Queries/Queries'; +import type { InterfaceActionItemInfo } from 'utils/interfaces'; + +export const actionItemCategory1 = { + _id: 'actionItemCategoryId1', + name: 'Category 1', +}; + +export const actionItemCategory2 = { + _id: 'actionItemCategoryId2', + name: 'Category 2', +}; + +export const baseActionItem = { + assigner: { + _id: 'userId', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + creator: { + _id: 'userId', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + __typename: 'User', + }, +}; + +export const itemWithVolunteer: InterfaceActionItemInfo = { + _id: 'actionItemId1', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 12, + assignments: [], + groups: [], + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + assigneeUser: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const itemWithVolunteerImage: InterfaceActionItemInfo = { + _id: 'actionItemId1b', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 12, + assignments: [], + groups: [], + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'user-image', + }, + }, + assigneeUser: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const itemWithUser: InterfaceActionItemInfo = { + _id: 'actionItemId2', + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + assignee: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + actionItemCategory: actionItemCategory2, + ...baseActionItem, +}; + +export const itemWithUserImage: InterfaceActionItemInfo = { + _id: 'actionItemId2b', + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Jane', + lastName: 'Doe', + image: 'user-image', + }, + assignee: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + actionItemCategory: actionItemCategory2, + ...baseActionItem, +}; + +export const itemWithGroup: InterfaceActionItemInfo = { + _id: 'actionItemId3', + assigneeType: 'EventVolunteerGroup', + assigneeUser: null, + assignee: null, + assigneeGroup: { + _id: 'volunteerGroupId1', + name: 'Group 1', + description: 'Group 1 Description', + volunteersRequired: 10, + event: { + _id: 'eventId1', + }, + assignments: [], + volunteers: [], + createdAt: '2024-08-27', + creator: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const memberListQuery = { + request: { + query: MEMBERS_LIST, + variables: { id: 'orgId' }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId1', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + email: 'wilt@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, +}; + +export const volunteerListQuery = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { where: { eventId: 'eventId', hasAccepted: true } }, + }, + result: { + data: { + getEventVolunteers: [ + { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + { + _id: 'volunteerId2', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: null, + }, + assignments: [], + groups: [], + }, + ], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { where: { hasAccepted: true } }, + }, + result: { + data: { + getEventVolunteers: [], + }, + }, + }, +]; + +export const groupListQuery = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { where: { eventId: 'eventId' } }, + }, + result: { + data: { + getEventVolunteerGroups: [ + { + _id: 'groupId1', + name: 'group1', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + { + _id: 'groupId2', + name: 'group2', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + ], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { where: { eventId: undefined } }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, +]; + +export const actionItemCategoryListQuery = { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { is_disabled: false }, + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'categoryId2', + name: 'Category 2', + isDisabled: true, + createdAt: '2024-08-25', + creator: { + _id: 'creatorId2', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, +}; diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.module.css b/src/screens/OrganizationDashboard/OrganizationDashboard.module.css index f9423de686..3ffe274196 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboard.module.css +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.module.css @@ -27,3 +27,9 @@ justify-content: center; align-items: center; } + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 35px; +} diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx b/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx index dde6b3120f..9c0a5d7761 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx @@ -1,202 +1,282 @@ +import React from 'react'; import { MockedProvider } from '@apollo/react-testing'; -import { fireEvent, render, screen } from '@testing-library/react'; -import 'jest-location-mock'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; -import { BrowserRouter } from 'react-router-dom'; - -import userEvent from '@testing-library/user-event'; -import { toast } from 'react-toastify'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { store } from 'state/store'; import { StaticMockLink } from 'utils/StaticMockLink'; -import i18nForTest from 'utils/i18nForTest'; -import useLocalStorage from 'utils/useLocalstorage'; +import i18n from 'utils/i18nForTest'; import OrganizationDashboard from './OrganizationDashboard'; -import { EMPTY_MOCKS, ERROR_MOCKS, MOCKS } from './OrganizationDashboardMocks'; -import React, { act } from 'react'; -const { setItem } = useLocalStorage(); -import type { InterfaceQueryOrganizationEventListItem } from 'utils/interfaces'; - -async function wait(ms = 100): Promise { - await act(() => { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); - }); -} -const link1 = new StaticMockLink(MOCKS, true); -const link2 = new StaticMockLink(EMPTY_MOCKS, true); -const link3 = new StaticMockLink(ERROR_MOCKS, true); +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './OrganizationDashboardMocks'; +import { toast } from 'react-toastify'; jest.mock('react-toastify', () => ({ toast: { success: jest.fn(), - warn: jest.fn(), error: jest.fn(), }, })); -const mockNavgate = jest.fn(); -let mockId: string | undefined = undefined; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockNavgate, - useParams: () => ({ orgId: mockId }), -})); -beforeEach(() => { - setItem('FirstName', 'John'); - setItem('LastName', 'Doe'); - setItem( - 'UserImage', - 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.dashboard ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const renderOrganizationDashboard = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
} + /> +
} + /> +
} + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + + +
, ); -}); +}; -afterEach(() => { - jest.clearAllMocks(); - localStorage.clear(); -}); +describe('Testing Organization Dashboard Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); -describe('Organisation Dashboard Page', () => { - test('Should render props and text elements test for the screen', async () => { - await act(async () => { - render( - - - - - - - - - , - ); + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); }); + }); - await wait(); - expect(screen.getByText('Members')).toBeInTheDocument(); - expect(screen.getByText('Admins')).toBeInTheDocument(); - expect(screen.getAllByText('Posts')).toHaveLength(1); - expect(screen.getAllByText('Events')).toHaveLength(1); - expect(screen.getByText('Blocked Users')).toBeInTheDocument(); - expect(screen.getByText('Requests')).toBeInTheDocument(); - expect(screen.getByText('Upcoming Events')).toBeInTheDocument(); - expect(screen.getByText('Latest Posts')).toBeInTheDocument(); - expect(screen.getByText('Membership requests')).toBeInTheDocument(); - - // Checking if posts are rendered + it('should render Organization Dashboard screen', async () => { + renderOrganizationDashboard(link1); + + // Dashboard cards + const membersBtn = await screen.findByText(t.members); + expect(membersBtn).toBeInTheDocument(); + expect(screen.getByText(t.admins)).toBeInTheDocument(); + expect(screen.getByText(t.posts)).toBeInTheDocument(); + expect(screen.getByText(t.events)).toBeInTheDocument(); + expect(screen.getByText(t.blockedUsers)).toBeInTheDocument(); + + // Upcoming events + expect(screen.getByText(t.upcomingEvents)).toBeInTheDocument(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + + // Latest posts + expect(screen.getByText(t.latestPosts)).toBeInTheDocument(); expect(screen.getByText('postone')).toBeInTheDocument(); - // Checking if membership requests are rendered + // Membership requests + expect(screen.getByText(t.membershipRequests)).toBeInTheDocument(); expect(screen.getByText('Jane Doe')).toBeInTheDocument(); - const peopleBtn = screen.getByText('Members'); - const adminBtn = screen.getByText('Admins'); - const postBtn = screen.getAllByText('Posts'); - const eventBtn = screen.getAllByText('Events'); - const blockUserBtn = screen.getByText('Blocked Users'); - const requestBtn = screen.getByText('Requests'); - userEvent.click(peopleBtn); - userEvent.click(adminBtn); - userEvent.click(postBtn[0]); - userEvent.click(eventBtn[0]); - userEvent.click(postBtn[0]); - userEvent.click(eventBtn[0]); - userEvent.click(blockUserBtn); - userEvent.click(requestBtn); - }); - - test('Testing buttons and checking empty events, posts and membership requests', async () => { - await act(async () => { - render( - - - - - - - - - , - ); + // Volunteer rankings + expect(screen.getByText(t.volunteerRankings)).toBeInTheDocument(); + expect(screen.getByText('Teresa Bradley')).toBeInTheDocument(); + }); + + it('Click People Card', async () => { + renderOrganizationDashboard(link1); + const membersBtn = await screen.findByText(t.members); + expect(membersBtn).toBeInTheDocument(); + + userEvent.click(membersBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpeople')).toBeInTheDocument(); }); + }); + + it('Click Admin Card', async () => { + renderOrganizationDashboard(link1); + const adminsBtn = await screen.findByText(t.admins); + expect(adminsBtn).toBeInTheDocument(); - await wait(); - const viewEventsBtn = screen.getByTestId('viewAllEvents'); - const viewPostsBtn = screen.getByTestId('viewAllPosts'); - const viewMSBtn = screen.getByTestId('viewAllMembershipRequests'); - - userEvent.click(viewEventsBtn); - userEvent.click(viewPostsBtn); - fireEvent.click(viewMSBtn); - expect(toast.success).toBeCalledWith('Coming soon!'); - - expect( - screen.getByText(/No membership requests present/i), - ).toBeInTheDocument(); - expect(screen.getByText(/No upcoming events/i)).toBeInTheDocument(); - expect(screen.getByText(/No Posts Present/i)).toBeInTheDocument(); - }); - - test('Testing error scenario', async () => { - await act(async () => { - render( - - - - - - - - - , - ); + userEvent.click(adminsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpeople')).toBeInTheDocument(); }); + }); + + it('Click Post Card', async () => { + renderOrganizationDashboard(link1); + const postsBtn = await screen.findByText(t.posts); + expect(postsBtn).toBeInTheDocument(); - await wait(); - expect(mockNavgate).toHaveBeenCalledWith('/orglist'); - }); - test('upcomingEvents cardItem component should render when length>0', async () => { - mockId = '123'; - await act(async () => { - render( - - - - - - - - - , - ); + userEvent.click(postsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpost')).toBeInTheDocument(); }); - screen.getByTestId('cardItem'); - }); - - test('event data should get updated using useState function', async () => { - mockId = '123'; - const mockSetState = jest.spyOn(React, 'useState'); - jest.doMock('react', () => ({ - ...jest.requireActual('react'), - useState: (initial: InterfaceQueryOrganizationEventListItem[]) => [ - initial, - mockSetState, - ], - })); - await act(async () => { - render( - - - - - - - - - , - ); + }); + + it('Click Events Card', async () => { + renderOrganizationDashboard(link1); + const eventsBtn = await screen.findByText(t.events); + expect(eventsBtn).toBeInTheDocument(); + + userEvent.click(eventsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgevents')).toBeInTheDocument(); + }); + }); + + it('Click Blocked Users Card', async () => { + renderOrganizationDashboard(link1); + const blockedUsersBtn = await screen.findByText(t.blockedUsers); + expect(blockedUsersBtn).toBeInTheDocument(); + + userEvent.click(blockedUsersBtn); + await waitFor(() => { + expect(screen.getByTestId('blockuser')).toBeInTheDocument(); + }); + }); + + it('Click Requests Card', async () => { + renderOrganizationDashboard(link1); + const requestsBtn = await screen.findByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + + userEvent.click(requestsBtn); + await waitFor(() => { + expect(screen.getByTestId('requests')).toBeInTheDocument(); + }); + }); + + it('Click View All Events', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[0]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[0]); + await waitFor(() => { + expect(screen.getByTestId('orgevents')).toBeInTheDocument(); + }); + }); + + it('Click View All Posts', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[1]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[1]); + await waitFor(() => { + expect(screen.getByTestId('orgpost')).toBeInTheDocument(); + }); + }); + + it('Click View All Requests', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[2]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[2]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); + + it('Click View All Leaderboard', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[3]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[3]); + await waitFor(() => { + expect(screen.getByTestId('leaderboard')).toBeInTheDocument(); + }); + }); + + it('should render Organization Dashboard screen with empty data', async () => { + renderOrganizationDashboard(link3); + + await waitFor(() => { + expect(screen.getByText(t.noUpcomingEvents)).toBeInTheDocument(); + expect(screen.getByText(t.noPostsPresent)).toBeInTheDocument(); + expect(screen.getByText(t.noMembershipRequests)).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('should redirectt to / if error occurs', async () => { + renderOrganizationDashboard(link2); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); }); - expect(mockSetState).toHaveBeenCalled(); }); }); diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.tsx b/src/screens/OrganizationDashboard/OrganizationDashboard.tsx index 6927e4f874..2aaf52c2db 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboard.tsx +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@apollo/client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Button, Card } from 'react-bootstrap'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; @@ -20,14 +20,19 @@ import CardItem from 'components/OrganizationDashCards/CardItem'; import CardItemLoading from 'components/OrganizationDashCards/CardItemLoading'; import DashBoardCard from 'components/OrganizationDashCards/DashboardCard'; import DashboardCardLoading from 'components/OrganizationDashCards/DashboardCardLoading'; -import { useNavigate, useParams } from 'react-router-dom'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import gold from 'assets/images/gold.png'; +import silver from 'assets/images/silver.png'; +import bronze from 'assets/images/bronze.png'; import { toast } from 'react-toastify'; import type { InterfaceQueryOrganizationEventListItem, InterfaceQueryOrganizationPostListItem, InterfaceQueryOrganizationsListObject, + InterfaceVolunteerRank, } from 'utils/interfaces'; import styles from './OrganizationDashboard.module.css'; +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; /** * Component for displaying the organization dashboard. @@ -39,12 +44,19 @@ import styles from './OrganizationDashboard.module.css'; function organizationDashboard(): JSX.Element { const { t } = useTranslation('translation', { keyPrefix: 'dashboard' }); const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); document.title = t('title'); - const { orgId: currentUrl } = useParams(); - const peopleLink = `/orgpeople/${currentUrl}`; - const postsLink = `/orgpost/${currentUrl}`; - const eventsLink = `/orgevents/${currentUrl}`; - const blockUserLink = `/blockuser/${currentUrl}`; + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + const leaderboardLink = `/leaderboard/${orgId}`; + const peopleLink = `/orgpeople/${orgId}`; + const postsLink = `/orgpost/${orgId}`; + const eventsLink = `/orgevents/${orgId}`; + const blockUserLink = `/blockuser/${orgId}`; const requestLink = '/requests'; const navigate = useNavigate(); @@ -66,9 +78,38 @@ function organizationDashboard(): JSX.Element { loading: boolean; error?: ApolloError; } = useQuery(ORGANIZATIONS_LIST, { - variables: { id: currentUrl }, + variables: { id: orgId }, + }); + + /** + * Query to fetch vvolunteer rankings. + */ + const { + data: rankingsData, + loading: rankingsLoading, + error: errorRankings, + }: { + data?: { + getVolunteerRanks: InterfaceVolunteerRank[]; + }; + loading: boolean; + error?: ApolloError; + } = useQuery(VOLUNTEER_RANKING, { + variables: { + orgId, + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, }); + const rankings = useMemo( + () => rankingsData?.getVolunteerRanks || [], + [rankingsData], + ); + /** * Query to fetch posts for the organization. */ @@ -83,7 +124,7 @@ function organizationDashboard(): JSX.Element { loading: boolean; error?: ApolloError; } = useQuery(ORGANIZATION_POST_LIST, { - variables: { id: currentUrl, first: 10 }, + variables: { id: orgId, first: 10 }, }); /** @@ -95,7 +136,7 @@ function organizationDashboard(): JSX.Element { error: errorEvent, } = useQuery(ORGANIZATION_EVENT_CONNECTION_LIST, { variables: { - organization_id: currentUrl, + organization_id: orgId, }, }); @@ -122,11 +163,11 @@ function organizationDashboard(): JSX.Element { * UseEffect to handle errors and navigate if necessary. */ useEffect(() => { - if (errorOrg || errorPost || errorEvent) { - console.log('error', errorPost?.message); - navigate('/orglist'); + if (errorOrg || errorPost || errorEvent || errorRankings) { + toast.error(tErrors('errorLoading', { entity: '' })); + navigate('/'); } - }, [errorOrg, errorPost, errorEvent]); + }, [errorOrg, errorPost, errorEvent, errorRankings]); return ( <> @@ -136,7 +177,12 @@ function organizationDashboard(): JSX.Element { {[...Array(6)].map((_, index) => { return ( - + ); @@ -253,7 +299,7 @@ function organizationDashboard(): JSX.Element { {loadingEvent ? ( [...Array(4)].map((_, index) => { - return ; + return ; }) ) : upcomingEvents.length == 0 ? (
@@ -295,7 +341,7 @@ function organizationDashboard(): JSX.Element { {loadingPost ? ( [...Array(4)].map((_, index) => { - return ; + return ; }) ) : postData?.organizations[0].posts.totalCount == 0 ? ( /* eslint-disable */ @@ -325,44 +371,109 @@ function organizationDashboard(): JSX.Element { - -
-
{t('membershipRequests')}
- -
- - {loadingOrgData ? ( - [...Array(4)].map((_, index) => { - return ; - }) - ) : data?.organizations[0].membershipRequests.length == 0 ? ( -
-
{t('noMembershipRequests')}
+ + +
+
+ {t('membershipRequests')}
- ) : ( - data?.organizations[0]?.membershipRequests - .slice(0, 8) - .map((request) => { + +
+ + {loadingOrgData ? ( + [...Array(4)].map((_, index) => { + return ; + }) + ) : data?.organizations[0].membershipRequests.length == 0 ? ( +
+
{t('noMembershipRequests')}
+
+ ) : ( + data?.organizations[0]?.membershipRequests + .slice(0, 8) + .map((request) => { + return ( + + ); + }) + )} +
+
+
+ + +
+
{t('volunteerRankings')}
+ +
+ + {rankingsLoading ? ( + [...Array(3)].map((_, index) => { + return ; + }) + ) : rankings.length == 0 ? ( +
+
{t('noVolunteers')}
+
+ ) : ( + rankings.map(({ rank, user, hoursVolunteered }, index) => { return ( - +
+
+
+ {rank <= 3 ? ( + gold + ) : ( + rank + )} +
+
{`${user.firstName} ${user.lastName}`}
+
- {hoursVolunteered} hours
+
+ {index < 2 &&
} +
); }) - )} -
-
+ )} + + +
diff --git a/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts b/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts index 26e5441f92..e005ed2e7d 100644 --- a/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts +++ b/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts @@ -1,3 +1,4 @@ +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; import { ORGANIZATIONS_LIST, ORGANIZATION_EVENT_CONNECTION_LIST, @@ -8,12 +9,13 @@ export const MOCKS = [ { request: { query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, }, result: { data: { organizations: [ { - _id: 123, + _id: 'orgId', image: '', name: 'Dummy Organization', description: 'This is a Dummy Organization', @@ -53,7 +55,7 @@ export const MOCKS = [ ], membershipRequests: [ { - _id: '456', + _id: 'requestId1', user: { firstName: 'Jane', lastName: 'Doe', @@ -77,7 +79,7 @@ export const MOCKS = [ { request: { query: ORGANIZATION_POST_LIST, - variables: { first: 10 }, + variables: { id: 'orgId', first: 10 }, }, result: { data: { @@ -87,7 +89,7 @@ export const MOCKS = [ edges: [ { node: { - _id: '6411e53835d7ba2344a78e21', + _id: 'postId1', title: 'postone', text: 'This is the first post', imageUrl: null, @@ -105,11 +107,11 @@ export const MOCKS = [ pinned: true, likedBy: [], }, - cursor: '6411e53835d7ba2344a78e21', + cursor: 'postId1', }, { node: { - _id: '6411e54835d7ba2344a78e29', + _id: 'postId2', title: 'posttwo', text: 'Tis is the post two', imageUrl: null, @@ -127,11 +129,11 @@ export const MOCKS = [ likedBy: [], comments: [], }, - cursor: '6411e54835d7ba2344a78e29', + cursor: 'postId2', }, { node: { - _id: '6411e54835d7ba2344a78e30', + _id: 'postId3', title: 'posttwo', text: 'Tis is the post two', imageUrl: null, @@ -149,11 +151,11 @@ export const MOCKS = [ likedBy: [], comments: [], }, - cursor: '6411e54835d7ba2344a78e30', + cursor: 'postId3', }, { node: { - _id: '6411e54835d7ba2344a78e31', + _id: 'postId4', title: 'posttwo', text: 'Tis is the post two', imageUrl: null, @@ -171,12 +173,12 @@ export const MOCKS = [ likedBy: [], comments: [], }, - cursor: '6411e54835d7ba2344a78e31', + cursor: 'postId4', }, ], pageInfo: { - startCursor: '6411e53835d7ba2344a78e21', - endCursor: '6411e54835d7ba2344a78e31', + startCursor: 'postId1', + endCursor: 'postId4', hasNextPage: false, hasPreviousPage: false, }, @@ -191,15 +193,15 @@ export const MOCKS = [ request: { query: ORGANIZATION_EVENT_CONNECTION_LIST, variables: { - organization_id: '123', + organization_id: 'orgId', }, }, result: { data: { eventsByOrganizationConnection: [ { - _id: '1', - title: 'Sample Event', + _id: 'eventId1', + title: 'Event 1', description: 'Sample Description', startDate: '2025-10-29T00:00:00.000Z', endDate: '2023-10-29T23:59:59.000Z', @@ -214,8 +216,8 @@ export const MOCKS = [ isRegisterable: true, }, { - _id: '2', - title: 'Sample Event', + _id: 'eventId2', + title: 'Event 2', description: 'Sample Description', startDate: '2022-10-29T00:00:00.000Z', endDate: '2023-10-29T23:59:59.000Z', @@ -233,12 +235,76 @@ export const MOCKS = [ }, }, }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + result: { + data: { + getVolunteerRanks: [ + { + rank: 1, + hoursVolunteered: 5, + user: { + _id: 'userId1', + lastName: 'Bradley', + firstName: 'Teresa', + image: null, + email: 'testuser4@example.com', + }, + }, + { + rank: 2, + hoursVolunteered: 4, + user: { + _id: 'userId2', + lastName: 'Garza', + firstName: 'Bruce', + image: null, + email: 'testuser5@example.com', + }, + }, + { + rank: 3, + hoursVolunteered: 3, + user: { + _id: 'userId3', + lastName: 'John', + firstName: 'Doe', + image: null, + email: 'testuser6@example.com', + }, + }, + { + rank: 4, + hoursVolunteered: 2, + user: { + _id: 'userId4', + lastName: 'Jane', + firstName: 'Doe', + image: null, + email: 'testuser7@example.com', + }, + }, + ], + }, + }, + }, ]; export const EMPTY_MOCKS = [ { request: { query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, }, result: { data: { @@ -299,7 +365,7 @@ export const EMPTY_MOCKS = [ { request: { query: ORGANIZATION_POST_LIST, - variables: { first: 10 }, + variables: { id: 'orgId', first: 10 }, }, result: { data: { @@ -323,6 +389,9 @@ export const EMPTY_MOCKS = [ { request: { query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, }, result: { data: { @@ -330,25 +399,62 @@ export const EMPTY_MOCKS = [ }, }, }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: '123', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + result: { + data: { + getVolunteerRanks: [], + }, + }, + }, ]; export const ERROR_MOCKS = [ { request: { query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, }, error: new Error('Mock Graphql ORGANIZATIONS_LIST Error'), }, { request: { query: ORGANIZATION_POST_LIST, + variables: { id: 'orgId', first: 10 }, }, error: new Error('Mock Graphql ORGANIZATION_POST_LIST Error'), }, { request: { query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, }, error: new Error('Mock Graphql ORGANIZATION_EVENT_LIST Error'), }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: '123', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + error: new Error('Mock Graphql VOLUNTEER_RANKING Error'), + }, ]; diff --git a/src/screens/OrganizationTags/OrganizationTags.module.css b/src/screens/OrganizationTags/OrganizationTags.module.css index 7251a79d0d..aa2e2abb40 100644 --- a/src/screens/OrganizationTags/OrganizationTags.module.css +++ b/src/screens/OrganizationTags/OrganizationTags.module.css @@ -139,3 +139,12 @@ color: var(--bs-gray); cursor: pointer; } + +.orgUserTagsScrollableDiv { + scrollbar-width: auto; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; + position: sticky; +} diff --git a/src/screens/OrganizationTags/OrganizationTags.test.tsx b/src/screens/OrganizationTags/OrganizationTags.test.tsx index 923a26d982..0d426d20ac 100644 --- a/src/screens/OrganizationTags/OrganizationTags.test.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.test.tsx @@ -4,6 +4,7 @@ import type { RenderResult } from '@testing-library/react'; import { act, cleanup, + fireEvent, render, screen, waitFor, @@ -59,11 +60,11 @@ const renderOrganizationTags = (link: ApolloLink): RenderResult => { } />
} />
} /> @@ -87,7 +88,7 @@ describe('Organisation Tags Page', () => { cleanup(); }); - test('Component loads correctly', async () => { + test('component loads correctly', async () => { const { getByText } = renderOrganizationTags(link); await wait(); @@ -103,7 +104,7 @@ describe('Organisation Tags Page', () => { await wait(); await waitFor(() => { - expect(queryByText(translations.create)).not.toBeInTheDocument(); + expect(queryByText(translations.createTag)).not.toBeInTheDocument(); }); }); @@ -129,118 +130,170 @@ describe('Organisation Tags Page', () => { ); }); - test('opens and closes the remove tag modal', async () => { + test('navigates to sub tags screen after clicking on a tag', async () => { renderOrganizationTags(link); await wait(); await waitFor(() => { - expect(screen.getAllByTestId('removeUserTagBtn')[0]).toBeInTheDocument(); + expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); }); - userEvent.click(screen.getAllByTestId('removeUserTagBtn')[0]); + userEvent.click(screen.getAllByTestId('tagName')[0]); await waitFor(() => { - return expect( - screen.findByTestId('removeUserTagModalCloseBtn'), - ).resolves.toBeInTheDocument(); + expect(screen.getByTestId('subTagsScreen')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('removeUserTagModalCloseBtn')); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('removeUserTagModalCloseBtn'), - ); }); - test('navigates to sub tags screen after clicking on a tag', async () => { + test('navigates to manage tag page after clicking manage tag option', async () => { renderOrganizationTags(link); await wait(); await waitFor(() => { - expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); + expect(screen.getAllByTestId('manageTagBtn')[0]).toBeInTheDocument(); }); - userEvent.click(screen.getAllByTestId('tagName')[0]); + userEvent.click(screen.getAllByTestId('manageTagBtn')[0]); await waitFor(() => { - expect(screen.getByTestId('subTagsScreen')).toBeInTheDocument(); + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); }); }); - test('navigates to manage tag page after clicking manage tag option', async () => { + test('searchs for tags where the name matches the provided search input', async () => { renderOrganizationTags(link); await wait(); await waitFor(() => { - expect(screen.getAllByTestId('manageTagBtn')[0]).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); }); - userEvent.click(screen.getAllByTestId('manageTagBtn')[0]); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchUserTag' } }); + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" await waitFor(() => { - expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + const buttons = screen.getAllByTestId('manageTagBtn'); + expect(buttons.length).toEqual(2); }); }); - test('paginates between different pages', async () => { + test('fetches the tags by the sort order, i.e. latest or oldest first', async () => { renderOrganizationTags(link); await wait(); await waitFor(() => { - expect(screen.getByTestId('nextPagBtn')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchUserTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchUserTag 1', + ); + }); + + // now change the sorting order + await waitFor(() => { + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortTags')); + + await waitFor(() => { + expect(screen.getByTestId('oldest')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('nextPagBtn')); + userEvent.click(screen.getByTestId('oldest')); + // returns the tags in reverse order await waitFor(() => { - expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent('6'); + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchUserTag 2', + ); }); await waitFor(() => { - expect(screen.getByTestId('previousPageBtn')).toBeInTheDocument(); + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('previousPageBtn')); + userEvent.click(screen.getByTestId('sortTags')); await waitFor(() => { - expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent('1'); + expect(screen.getByTestId('latest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('latest')); + + // reverse the order again + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchUserTag 1', + ); }); }); - test('creates a new user tag', async () => { - renderOrganizationTags(link); + test('fetches more tags with infinite scroll', async () => { + const { getByText } = renderOrganizationTags(link); await wait(); await waitFor(() => { - expect(screen.getByTestId('createTagBtn')).toBeInTheDocument(); + expect(getByText(translations.createTag)).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('createTagBtn')); - userEvent.type( - screen.getByPlaceholderText(translations.tagNamePlaceholder), - '7', + const orgUserTagsScrollableDiv = screen.getByTestId( + 'orgUserTagsScrollableDiv', ); - userEvent.click(screen.getByTestId('createTagSubmitBtn')); + // Get the initial number of tags loaded + const initialTagsDataLength = screen.getAllByTestId('manageTagBtn').length; + + // Set scroll position to the bottom + fireEvent.scroll(orgUserTagsScrollableDiv, { + target: { scrollY: orgUserTagsScrollableDiv.scrollHeight }, + }); await waitFor(() => { - expect(toast.success).toBeCalledWith(translations.tagCreationSuccess); + const finalTagsDataLength = screen.getAllByTestId('manageTagBtn').length; + expect(finalTagsDataLength).toBeGreaterThan(initialTagsDataLength); + + expect(getByText(translations.createTag)).toBeInTheDocument(); }); }); - test('removes a user tag', async () => { + test('creates a new user tag', async () => { renderOrganizationTags(link); await wait(); await waitFor(() => { - expect(screen.getAllByTestId('removeUserTagBtn')[0]).toBeInTheDocument(); + expect(screen.getByTestId('createTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createTagBtn')); + + userEvent.click(screen.getByTestId('createTagSubmitBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.enterTagName); }); - userEvent.click(screen.getAllByTestId('removeUserTagBtn')[0]); - userEvent.click(screen.getByTestId('removeUserTagSubmitBtn')); + userEvent.type( + screen.getByPlaceholderText(translations.tagNamePlaceholder), + 'userTag 12', + ); + + userEvent.click(screen.getByTestId('createTagSubmitBtn')); await waitFor(() => { - expect(toast.success).toBeCalledWith(translations.tagRemovalSuccess); + expect(toast.success).toHaveBeenCalledWith( + translations.tagCreationSuccess, + ); }); }); }); diff --git a/src/screens/OrganizationTags/OrganizationTags.tsx b/src/screens/OrganizationTags/OrganizationTags.tsx index e4bf0604e9..b59da67fd0 100644 --- a/src/screens/OrganizationTags/OrganizationTags.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.tsx @@ -1,11 +1,11 @@ -import { useMutation, useQuery, type ApolloError } from '@apollo/client'; -import { Search, WarningAmberRounded } from '@mui/icons-material'; +import { useMutation, useQuery } from '@apollo/client'; +import { WarningAmberRounded } from '@mui/icons-material'; import SortIcon from '@mui/icons-material/Sort'; import Loader from 'components/Loader/Loader'; import IconComponent from 'components/IconComponent/IconComponent'; import { useNavigate, useParams, Link } from 'react-router-dom'; import type { ChangeEvent } from 'react'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Form } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; import Dropdown from 'react-bootstrap/Dropdown'; @@ -13,17 +13,26 @@ import Modal from 'react-bootstrap/Modal'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import type { InterfaceQueryOrganizationUserTags } from 'utils/interfaces'; +import type { + InterfaceQueryOrganizationUserTags, + InterfaceTagData, +} from 'utils/interfaces'; import styles from './OrganizationTags.module.css'; import { DataGrid } from '@mui/x-data-grid'; -import { dataGridStyle } from 'utils/organizationTagsUtils'; +import type { + InterfaceOrganizationTagsQuery, + SortedByType, +} from 'utils/organizationTagsUtils'; +import { + dataGridStyle, + TAGS_QUERY_DATA_CHUNK_SIZE, +} from 'utils/organizationTagsUtils'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { Stack } from '@mui/material'; import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; -import { - CREATE_USER_TAG, - REMOVE_USER_TAG, -} from 'GraphQl/Mutations/TagMutations'; +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; /** * Component that renders the Organization Tags screen when the app navigates to '/orgtags/:orgId'. @@ -40,19 +49,14 @@ function OrganizationTags(): JSX.Element { const [createTagModalIsOpen, setCreateTagModalIsOpen] = useState(false); + const [tagSearchName, setTagSearchName] = useState(''); + const [tagSortOrder, setTagSortOrder] = useState('DESCENDING'); + const { orgId } = useParams(); const navigate = useNavigate(); - const [after, setAfter] = useState(null); - const [before, setBefore] = useState(null); - const [first, setFirst] = useState(5); - const [last, setLast] = useState(null); - const [tagSerialNumber, setTagSerialNumber] = useState(0); const [tagName, setTagName] = useState(''); - const [removeUserTagId, setRemoveUserTagId] = useState(null); - const [removeTagModalIsOpen, setRemoveTagModalIsOpen] = useState(false); - const showCreateTagModal = (): void => { setTagName(''); setCreateTagModalIsOpen(true); @@ -67,29 +71,71 @@ function OrganizationTags(): JSX.Element { loading: orgUserTagsLoading, error: orgUserTagsError, refetch: orgUserTagsRefetch, - }: { - data?: { - organizations: InterfaceQueryOrganizationUserTags[]; - }; - loading: boolean; - error?: ApolloError; - refetch: () => void; - } = useQuery(ORGANIZATION_USER_TAGS_LIST, { + fetchMore: orgUserTagsFetchMore, + }: InterfaceOrganizationTagsQuery = useQuery(ORGANIZATION_USER_TAGS_LIST, { variables: { id: orgId, - after: after, - before: before, - first: first, - last: last, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: tagSearchName } }, + sortedBy: { id: tagSortOrder }, }, }); + const loadMoreUserTags = (): void => { + orgUserTagsFetchMore({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: + orgUserTagsData?.organizations?.[0]?.userTags?.pageInfo?.endCursor ?? + /* istanbul ignore next */ + null, + }, + updateQuery: ( + prevResult: { organizations: InterfaceQueryOrganizationUserTags[] }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + organizations: [ + { + ...prevResult.organizations[0], + userTags: { + ...prevResult.organizations[0].userTags, + edges: [ + ...prevResult.organizations[0].userTags.edges, + ...fetchMoreResult.organizations[0].userTags.edges, + ], + pageInfo: fetchMoreResult.organizations[0].userTags.pageInfo, + }, + }, + ], + }; + }, + }); + }; + + useEffect(() => { + orgUserTagsRefetch(); + }, []); + const [create, { loading: createUserTagLoading }] = useMutation(CREATE_USER_TAG); const createTag = async (e: ChangeEvent): Promise => { e.preventDefault(); + if (!tagName.trim()) { + toast.error(t('enterTagName')); + return; + } + try { const { data } = await create({ variables: { @@ -99,7 +145,7 @@ function OrganizationTags(): JSX.Element { }); if (data) { - toast.success(t('tagCreationSuccess') as string); + toast.success(t('tagCreationSuccess')); orgUserTagsRefetch(); setTagName(''); setCreateTagModalIsOpen(false); @@ -112,30 +158,6 @@ function OrganizationTags(): JSX.Element { } }; - const [removeUserTag] = useMutation(REMOVE_USER_TAG); - const handleRemoveUserTag = async (): Promise => { - try { - await removeUserTag({ - variables: { - id: removeUserTagId, - }, - }); - - orgUserTagsRefetch(); - toggleRemoveUserTagModal(); - toast.success(t('tagRemovalSuccess') as string); - } catch (error: unknown) { - /* istanbul ignore next */ - if (error instanceof Error) { - toast.error(error.message); - } - } - }; - - if (createUserTagLoading || orgUserTagsLoading) { - return ; - } - if (orgUserTagsError) { return (
@@ -151,38 +173,18 @@ function OrganizationTags(): JSX.Element { ); } - const handleNextPage = (): void => { - setAfter(orgUserTagsData?.organizations[0].userTags.pageInfo.endCursor); - setBefore(null); - setFirst(5); - setLast(null); - setTagSerialNumber(tagSerialNumber + 1); - }; - const handlePreviousPage = (): void => { - setBefore(orgUserTagsData?.organizations[0].userTags.pageInfo.startCursor); - setAfter(null); - setFirst(null); - setLast(5); - setTagSerialNumber(tagSerialNumber - 1); - }; - const userTagsList = orgUserTagsData?.organizations[0].userTags.edges.map( (edge) => edge.node, ); const redirectToManageTag = (tagId: string): void => { - navigate(`/orgtags/${orgId}/managetag/${tagId}`); + navigate(`/orgtags/${orgId}/manageTag/${tagId}`); }; const redirectToSubTags = (tagId: string): void => { navigate(`/orgtags/${orgId}/subTags/${tagId}`); }; - const toggleRemoveUserTagModal = (): void => { - if (removeTagModalIsOpen) setRemoveUserTagId(null); - setRemoveTagModalIsOpen(!removeTagModalIsOpen); - }; - const columns: GridColDef[] = [ { field: 'id', @@ -193,7 +195,7 @@ function OrganizationTags(): JSX.Element { headerClassName: `${styles.tableHeader}`, sortable: false, renderCell: (params: GridCellParams) => { - return
{tagSerialNumber * 5 + params.row.id}
; + return
{params.row.id}
; }, }, { @@ -203,16 +205,29 @@ function OrganizationTags(): JSX.Element { minWidth: 100, sortable: false, headerClassName: `${styles.tableHeader}`, - renderCell: (params: GridCellParams) => { + renderCell: (params: GridCellParams) => { return ( -
redirectToSubTags(params.row._id)} - > - {params.row.name} - - +
+ {params.row.parentTag && + params.row.ancestorTags?.map((tag) => ( +
+ {tag.name} + +
+ ))} + +
redirectToSubTags(params.row._id)} + > + {params.row.name} + +
); }, @@ -230,7 +245,7 @@ function OrganizationTags(): JSX.Element { return ( {params.row.childTags.totalCount} @@ -250,7 +265,7 @@ function OrganizationTags(): JSX.Element { return ( {params.row.usersAssignedTo.totalCount} @@ -268,28 +283,14 @@ function OrganizationTags(): JSX.Element { headerClassName: `${styles.tableHeader}`, renderCell: (params: GridCellParams) => { return ( -
- - - -
+ ); }, }, @@ -301,21 +302,16 @@ function OrganizationTags(): JSX.Element {
+ setTagSearchName(e.target.value.trim())} autoComplete="off" - required /> -
- {tCommon('sort')} + {tagSortOrder === 'DESCENDING' + ? tCommon('Latest') + : tCommon('Oldest')} - + setTagSortOrder('DESCENDING')} + > {tCommon('Latest')} - + setTagSortOrder('ASCENDING')} + > {tCommon('Oldest')} @@ -351,72 +355,66 @@ function OrganizationTags(): JSX.Element {
-
-
-
- + {orgUserTagsLoading || createUserTagLoading ? ( + + ) : ( +
+
+
+ +
+ +
+ {'Tags'} +
-
- {'Tags'} +
+ } + scrollableTarget="orgUserTagsScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noTagsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagsList?.map((userTag, index) => ({ + id: index + 1, + ...userTag, + }))} + columns={columns} + isRowSelectable={() => false} + /> +
- row._id} - slots={{ - noRowsOverlay: /* istanbul ignore next */ () => ( - - {t('noTagsFound')} - - ), - }} - sx={dataGridStyle} - getRowClassName={() => `${styles.rowBackground}`} - autoHeight - rowHeight={65} - rows={userTagsList?.map((fund, index) => ({ - id: index + 1, - ...fund, - }))} - columns={columns} - isRowSelectable={() => false} - /> -
-
- -
-
- -
-
- -
+ )}
@@ -471,43 +469,6 @@ function OrganizationTags(): JSX.Element { - - {/* Remove User Tag Modal */} - - - - {t('removeUserTag')} - - - {t('removeUserTagMessage')} - - - - - ); } diff --git a/src/screens/OrganizationTags/OrganizationTagsMocks.ts b/src/screens/OrganizationTags/OrganizationTagsMocks.ts index 3700c66c46..0fe48ca97f 100644 --- a/src/screens/OrganizationTags/OrganizationTagsMocks.ts +++ b/src/screens/OrganizationTags/OrganizationTagsMocks.ts @@ -1,8 +1,6 @@ -import { - CREATE_USER_TAG, - REMOVE_USER_TAG, -} from 'GraphQl/Mutations/TagMutations'; +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; export const MOCKS = [ { @@ -10,10 +8,9 @@ export const MOCKS = [ query: ORGANIZATION_USER_TAGS_LIST, variables: { id: '123', - after: null, - before: null, - first: 5, - last: null, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { @@ -26,12 +23,14 @@ export const MOCKS = [ node: { _id: '1', name: 'userTag 1', + parentTag: null, usersAssignedTo: { totalCount: 5, }, childTags: { - totalCount: 5, + totalCount: 11, }, + ancestorTags: [], }, cursor: '1', }, @@ -39,12 +38,14 @@ export const MOCKS = [ node: { _id: '2', name: 'userTag 2', + parentTag: null, usersAssignedTo: { totalCount: 5, }, childTags: { totalCount: 0, }, + ancestorTags: [], }, cursor: '2', }, @@ -52,12 +53,14 @@ export const MOCKS = [ node: { _id: '3', name: 'userTag 3', + parentTag: null, usersAssignedTo: { totalCount: 0, }, childTags: { totalCount: 5, }, + ancestorTags: [], }, cursor: '3', }, @@ -65,12 +68,14 @@ export const MOCKS = [ node: { _id: '4', name: 'userTag 4', + parentTag: null, usersAssignedTo: { totalCount: 0, }, childTags: { totalCount: 0, }, + ancestorTags: [], }, cursor: '4', }, @@ -78,23 +83,100 @@ export const MOCKS = [ node: { _id: '5', name: 'userTag 5', + parentTag: null, usersAssignedTo: { totalCount: 5, }, childTags: { totalCount: 5, }, + ancestorTags: [], }, cursor: '5', }, + { + node: { + _id: '6', + name: 'userTag 6', + parentTag: null, + usersAssignedTo: { + totalCount: 6, + }, + childTags: { + totalCount: 6, + }, + ancestorTags: [], + }, + cursor: '6', + }, + { + node: { + _id: '7', + name: 'userTag 7', + parentTag: null, + usersAssignedTo: { + totalCount: 7, + }, + childTags: { + totalCount: 7, + }, + ancestorTags: [], + }, + cursor: '7', + }, + { + node: { + _id: '8', + name: 'userTag 8', + parentTag: null, + usersAssignedTo: { + totalCount: 8, + }, + childTags: { + totalCount: 8, + }, + ancestorTags: [], + }, + cursor: '8', + }, + { + node: { + _id: '9', + name: 'userTag 9', + parentTag: null, + usersAssignedTo: { + totalCount: 9, + }, + childTags: { + totalCount: 9, + }, + ancestorTags: [], + }, + cursor: '9', + }, + { + node: { + _id: '10', + name: 'userTag 10', + parentTag: null, + usersAssignedTo: { + totalCount: 10, + }, + childTags: { + totalCount: 10, + }, + ancestorTags: [], + }, + cursor: '10', + }, ], pageInfo: { startCursor: '1', - endCursor: '5', + endCursor: '10', hasNextPage: true, hasPreviousPage: false, }, - totalCount: 6, + totalCount: 12, }, }, ], @@ -106,10 +188,10 @@ export const MOCKS = [ query: ORGANIZATION_USER_TAGS_LIST, variables: { id: '123', - after: '5', - before: null, - first: 5, - last: null, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { @@ -120,25 +202,42 @@ export const MOCKS = [ edges: [ { node: { - _id: '6', - name: 'userTag 6', + _id: '11', + name: 'userTag 11', + parentTag: null, usersAssignedTo: { - totalCount: 0, + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '11', + }, + { + node: { + _id: '12', + name: 'userTag 12', + parentTag: null, + usersAssignedTo: { + totalCount: 5, }, childTags: { totalCount: 0, }, + ancestorTags: [], }, - cursor: '6', + cursor: '12', }, ], pageInfo: { - startCursor: '6', - endCursor: '6', + startCursor: '11', + endCursor: '12', hasNextPage: false, hasPreviousPage: true, }, - totalCount: 6, + totalCount: 12, }, }, ], @@ -150,10 +249,9 @@ export const MOCKS = [ query: ORGANIZATION_USER_TAGS_LIST, variables: { id: '123', - after: null, - before: '6', - first: null, - last: 5, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchUserTag' } }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { @@ -164,77 +262,130 @@ export const MOCKS = [ edges: [ { node: { - _id: '1', - name: 'userTag 1', + _id: 'searchUserTag1', + name: 'searchUserTag 1', + parentTag: { + _id: '1', + }, usersAssignedTo: { totalCount: 5, }, childTags: { totalCount: 5, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '1', + cursor: 'searchUserTag1', }, { node: { - _id: '2', - name: 'userTag 2', + _id: 'searchUserTag2', + name: 'searchUserTag 2', + parentTag: { + _id: '1', + }, usersAssignedTo: { totalCount: 5, }, childTags: { totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '2', + cursor: 'searchUserTag2', }, + ], + pageInfo: { + startCursor: 'searchUserTag1', + endCursor: 'searchUserTag2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchUserTag' } }, + sortedBy: { id: 'ASCENDING' }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ { node: { - _id: '3', - name: 'userTag 3', + _id: 'searchUserTag2', + name: 'searchUserTag 2', + parentTag: { + _id: '1', + }, usersAssignedTo: { - totalCount: 0, + totalCount: 5, }, childTags: { totalCount: 5, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '3', + cursor: 'searchUserTag2', }, { node: { - _id: '4', - name: 'userTag 4', - usersAssignedTo: { - totalCount: 0, - }, - childTags: { - totalCount: 0, + _id: 'searchUserTag1', + name: 'searchUserTag 1', + parentTag: { + _id: '1', }, - }, - cursor: '4', - }, - { - node: { - _id: '5', - name: 'userTag 5', usersAssignedTo: { totalCount: 5, }, childTags: { - totalCount: 5, + totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '5', + cursor: 'searchUserTag1', }, ], pageInfo: { - startCursor: '1', - endCursor: '5', - hasNextPage: true, + startCursor: 'searchUserTag2', + endCursor: 'searchUserTag1', + hasNextPage: false, hasPreviousPage: false, }, - totalCount: 6, + totalCount: 2, }, }, ], @@ -245,29 +396,14 @@ export const MOCKS = [ request: { query: CREATE_USER_TAG, variables: { - name: '7', + name: 'userTag 12', organizationId: '123', }, }, result: { data: { createUserTag: { - _id: '7', - }, - }, - }, - }, - { - request: { - query: REMOVE_USER_TAG, - variables: { - id: '1', - }, - }, - result: { - data: { - removeUserTag: { - _id: '1', + _id: '12', }, }, }, @@ -280,10 +416,9 @@ export const MOCKS_ERROR = [ query: ORGANIZATION_USER_TAGS_LIST, variables: { id: '123', - after: null, - before: null, - first: 5, - last: null, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, error: new Error('Mock Graphql Error'), diff --git a/src/screens/SubTags/SubTags.module.css b/src/screens/SubTags/SubTags.module.css index 2fed58ec52..0a210bdfa4 100644 --- a/src/screens/SubTags/SubTags.module.css +++ b/src/screens/SubTags/SubTags.module.css @@ -135,3 +135,11 @@ font-weight: 600; text-decoration: underline; } + +.subTagsScrollableDiv { + scrollbar-width: auto; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; +} diff --git a/src/screens/SubTags/SubTags.test.tsx b/src/screens/SubTags/SubTags.test.tsx index 1780027639..145d31109d 100644 --- a/src/screens/SubTags/SubTags.test.tsx +++ b/src/screens/SubTags/SubTags.test.tsx @@ -4,6 +4,7 @@ import type { RenderResult } from '@testing-library/react'; import { act, cleanup, + fireEvent, render, screen, waitFor, @@ -19,11 +20,7 @@ import { store } from 'state/store'; import { StaticMockLink } from 'utils/StaticMockLink'; import i18n from 'utils/i18nForTest'; import SubTags from './SubTags'; -import { - MOCKS, - MOCKS_ERROR_SUB_TAGS, - MOCKS_ERROR_TAG_ANCESTORS, -} from './SubTagsMocks'; +import { MOCKS, MOCKS_ERROR_SUB_TAGS } from './SubTagsMocks'; import { InMemoryCache, type ApolloLink } from '@apollo/client'; const translations = { @@ -38,7 +35,6 @@ const translations = { const link = new StaticMockLink(MOCKS, true); const link2 = new StaticMockLink(MOCKS_ERROR_SUB_TAGS, true); -const link3 = new StaticMockLink(MOCKS_ERROR_TAG_ANCESTORS, true); async function wait(ms = 500): Promise { await act(() => { @@ -60,9 +56,21 @@ const cache = new InMemoryCache({ Query: { fields: { getUserTag: { - keyArgs: false, merge(existing = {}, incoming) { - return incoming; + const merged = { + ...existing, + ...incoming, + childTags: { + ...existing.childTags, + ...incoming.childTags, + edges: [ + ...(existing.childTags?.edges || []), + ...(incoming.childTags?.edges || []), + ], + }, + }; + + return merged; }, }, }, @@ -72,8 +80,8 @@ const cache = new InMemoryCache({ const renderSubTags = (link: ApolloLink): RenderResult => { return render( - - + + @@ -82,11 +90,11 @@ const renderSubTags = (link: ApolloLink): RenderResult => { element={
} />
} /> } /> @@ -131,16 +139,6 @@ describe('Organisation Tags Page', () => { }); }); - test('renders error component on unsuccessful userTag ancestors query', async () => { - const { queryByText } = renderSubTags(link3); - - await wait(); - - await waitFor(() => { - expect(queryByText(translations.addChildTag)).not.toBeInTheDocument(); - }); - }); - test('opens and closes the create tag modal', async () => { renderSubTags(link); @@ -163,28 +161,6 @@ describe('Organisation Tags Page', () => { ); }); - test('opens and closes the remove tag modal', async () => { - renderSubTags(link); - - await wait(); - - await waitFor(() => { - expect(screen.getAllByTestId('removeUserTagBtn')[0]).toBeInTheDocument(); - }); - userEvent.click(screen.getAllByTestId('removeUserTagBtn')[0]); - - await waitFor(() => { - return expect( - screen.findByTestId('removeUserTagModalCloseBtn'), - ).resolves.toBeInTheDocument(); - }); - userEvent.click(screen.getByTestId('removeUserTagModalCloseBtn')); - - await waitForElementToBeRemoved(() => - screen.queryByTestId('removeUserTagModalCloseBtn'), - ); - }); - test('navigates to manage tag screen after clicking manage tag option', async () => { renderSubTags(link); @@ -260,66 +236,134 @@ describe('Organisation Tags Page', () => { }); }); - test('paginates between different pages', async () => { + test('searchs for tags where the name matches the provided search input', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchSubTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + const buttons = screen.getAllByTestId('manageTagBtn'); + expect(buttons.length).toEqual(2); + }); + }); + + test('fetches the tags by the sort order, i.e. latest or oldest first', async () => { renderSubTags(link); await wait(); await waitFor(() => { - expect(screen.getByTestId('nextPagBtn')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchSubTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchSubTag 1', + ); + }); + + // now change the sorting order + await waitFor(() => { + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortTags')); + + await waitFor(() => { + expect(screen.getByTestId('oldest')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('nextPagBtn')); + userEvent.click(screen.getByTestId('oldest')); + // returns the tags in reverse order await waitFor(() => { - expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent('subTag 6'); + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchSubTag 2', + ); }); await waitFor(() => { - expect(screen.getByTestId('previousPageBtn')).toBeInTheDocument(); + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('previousPageBtn')); + userEvent.click(screen.getByTestId('sortTags')); await waitFor(() => { - expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent('subTag 1'); + expect(screen.getByTestId('latest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('latest')); + + // reverse the order again + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchSubTag 1', + ); }); }); - test('adds a new sub tag to the current tag', async () => { - renderSubTags(link); + test('Fetches more sub tags with infinite scroll', async () => { + const { getByText } = renderSubTags(link); await wait(); await waitFor(() => { - expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + expect(getByText(translations.addChildTag)).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('addSubTagBtn')); - userEvent.type( - screen.getByPlaceholderText(translations.tagNamePlaceholder), - 'subTag 7', - ); + const subTagsScrollableDiv = screen.getByTestId('subTagsScrollableDiv'); - userEvent.click(screen.getByTestId('addSubTagSubmitBtn')); + // Get the initial number of tags loaded + const initialSubTagsDataLength = + screen.getAllByTestId('manageTagBtn').length; + + // Set scroll position to the bottom + fireEvent.scroll(subTagsScrollableDiv, { + target: { scrollY: subTagsScrollableDiv.scrollHeight }, + }); await waitFor(() => { - expect(toast.success).toBeCalledWith(translations.tagCreationSuccess); + const finalSubTagsDataLength = + screen.getAllByTestId('manageTagBtn').length; + expect(finalSubTagsDataLength).toBeGreaterThan(initialSubTagsDataLength); + + expect(getByText(translations.addChildTag)).toBeInTheDocument(); }); }); - test('removes a sub tag', async () => { + test('adds a new sub tag to the current tag', async () => { renderSubTags(link); await wait(); await waitFor(() => { - expect(screen.getAllByTestId('removeUserTagBtn')[0]).toBeInTheDocument(); + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); }); - userEvent.click(screen.getAllByTestId('removeUserTagBtn')[0]); + userEvent.click(screen.getByTestId('addSubTagBtn')); + + userEvent.type( + screen.getByPlaceholderText(translations.tagNamePlaceholder), + 'subTag 12', + ); - userEvent.click(screen.getByTestId('removeUserTagSubmitBtn')); + userEvent.click(screen.getByTestId('addSubTagSubmitBtn')); await waitFor(() => { - expect(toast.success).toBeCalledWith(translations.tagRemovalSuccess); + expect(toast.success).toHaveBeenCalledWith( + translations.tagCreationSuccess, + ); }); }); }); diff --git a/src/screens/SubTags/SubTags.tsx b/src/screens/SubTags/SubTags.tsx index b381c9f816..063ce9f028 100644 --- a/src/screens/SubTags/SubTags.tsx +++ b/src/screens/SubTags/SubTags.tsx @@ -1,5 +1,5 @@ -import { useMutation, useQuery, type ApolloError } from '@apollo/client'; -import { Search, WarningAmberRounded } from '@mui/icons-material'; +import { useMutation, useQuery } from '@apollo/client'; +import { WarningAmberRounded } from '@mui/icons-material'; import SortIcon from '@mui/icons-material/Sort'; import Loader from 'components/Loader/Loader'; import IconComponent from 'components/IconComponent/IconComponent'; @@ -16,17 +16,20 @@ import { toast } from 'react-toastify'; import type { InterfaceQueryUserTagChildTags } from 'utils/interfaces'; import styles from './SubTags.module.css'; import { DataGrid } from '@mui/x-data-grid'; -import { dataGridStyle } from 'utils/organizationTagsUtils'; +import type { + InterfaceOrganizationSubTagsQuery, + SortedByType, +} from 'utils/organizationTagsUtils'; +import { + dataGridStyle, + TAGS_QUERY_DATA_CHUNK_SIZE, +} from 'utils/organizationTagsUtils'; import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { Stack } from '@mui/material'; -import { - CREATE_USER_TAG, - REMOVE_USER_TAG, -} from 'GraphQl/Mutations/TagMutations'; -import { - USER_TAG_ANCESTORS, - USER_TAG_SUB_TAGS, -} from 'GraphQl/Queries/userTagQueries'; +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; /** * Component that renders the SubTags screen when the app navigates to '/orgtags/:orgId/subtags/:tagId'. @@ -47,16 +50,10 @@ function SubTags(): JSX.Element { const navigate = useNavigate(); - const [after, setAfter] = useState(null); - const [before, setBefore] = useState(null); - const [first, setFirst] = useState(5); - const [last, setLast] = useState(null); - const [tagName, setTagName] = useState(''); - const [removeUserTagId, setRemoveUserTagId] = useState(null); - const [removeUserTagModalIsOpen, setRemoveUserTagModalIsOpen] = - useState(false); + const [tagSearchName, setTagSearchName] = useState(''); + const [tagSortOrder, setTagSortOrder] = useState('DESCENDING'); const showAddSubTagModal = (): void => { setAddSubTagModalIsOpen(true); @@ -69,45 +66,50 @@ function SubTags(): JSX.Element { const { data: subTagsData, - loading: subTagsLoading, error: subTagsError, + loading: subTagsLoading, refetch: subTagsRefetch, - }: { - data?: { - getUserTag: InterfaceQueryUserTagChildTags; - }; - loading: boolean; - error?: ApolloError; - refetch: () => void; - } = useQuery(USER_TAG_SUB_TAGS, { + fetchMore: fetchMoreSubTags, + }: InterfaceOrganizationSubTagsQuery = useQuery(USER_TAG_SUB_TAGS, { variables: { id: parentTagId, - after: after, - before: before, - first: first, - last: last, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: tagSearchName } }, + sortedBy: { id: tagSortOrder }, }, }); - const { - data: orgUserTagAncestorsData, - loading: orgUserTagsAncestorsLoading, - error: orgUserTagsAncestorsError, - }: { - data?: { - getUserTagAncestors: { - _id: string; - name: string; - }[]; - }; - loading: boolean; - error?: ApolloError; - refetch: () => void; - } = useQuery(USER_TAG_ANCESTORS, { - variables: { - id: parentTagId, - }, - }); + const loadMoreSubTags = (): void => { + fetchMoreSubTags({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: subTagsData?.getChildTags.childTags.pageInfo.endCursor, + }, + updateQuery: ( + prevResult: { getChildTags: InterfaceQueryUserTagChildTags }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { getChildTags: InterfaceQueryUserTagChildTags }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + getChildTags: { + ...fetchMoreResult.getChildTags, + childTags: { + ...fetchMoreResult.getChildTags.childTags, + edges: [ + ...prevResult.getChildTags.childTags.edges, + ...fetchMoreResult.getChildTags.childTags.edges, + ], + }, + }, + }; + }, + }); + }; const [create, { loading: createUserTagLoading }] = useMutation(CREATE_USER_TAG); @@ -139,81 +141,41 @@ function SubTags(): JSX.Element { } }; - const [removeUserTag] = useMutation(REMOVE_USER_TAG); - const handleRemoveUserTag = async (): Promise => { - try { - await removeUserTag({ - variables: { - id: removeUserTagId, - }, - }); - - subTagsRefetch(); - toggleRemoveUserTagModal(); - toast.success(t('tagRemovalSuccess') as string); - } catch (error: unknown) { - /* istanbul ignore next */ - if (error instanceof Error) { - toast.error(error.message); - } - } - }; - - if (createUserTagLoading || subTagsLoading || orgUserTagsAncestorsLoading) { - return ; - } - - const handleNextPage = (): void => { - setAfter(subTagsData?.getUserTag.childTags.pageInfo.endCursor); - setBefore(null); - setFirst(5); - setLast(null); - }; - - const handlePreviousPage = (): void => { - setBefore(subTagsData?.getUserTag.childTags.pageInfo.startCursor); - setAfter(null); - setFirst(null); - setLast(5); - }; - - if (subTagsError || orgUserTagsAncestorsError) { + if (subTagsError) { return (
- Error occured while loading{' '} - {subTagsError ? 'sub tags' : 'tag ancestors'} -
- {subTagsError - ? subTagsError.message - : orgUserTagsAncestorsError?.message} + Error occured while loading sub tags
); } - const userTagsList = subTagsData?.getUserTag.childTags.edges.map( - (edge) => edge.node, - ); + const subTagsList = + subTagsData?.getChildTags.childTags.edges.map((edge) => edge.node) ?? + /* istanbul ignore next */ []; - const orgUserTagAncestors = orgUserTagAncestorsData?.getUserTagAncestors; + const parentTagName = subTagsData?.getChildTags.name; + + // get the ancestorTags array and push the current tag in it + // used for the tag breadcrumbs + const orgUserTagAncestors = [ + ...(subTagsData?.getChildTags.ancestorTags ?? []), + { + _id: parentTagId, + name: parentTagName, + }, + ]; const redirectToManageTag = (tagId: string): void => { navigate(`/orgtags/${orgId}/manageTag/${tagId}`); }; const redirectToSubTags = (tagId: string): void => { - navigate(`/orgtags/${orgId}/subtags/${tagId}`); - }; - - const toggleRemoveUserTagModal = (): void => { - if (removeUserTagModalIsOpen) { - setRemoveUserTagId(null); - } - setRemoveUserTagModalIsOpen(!removeUserTagModalIsOpen); + navigate(`/orgtags/${orgId}/subTags/${tagId}`); }; const columns: GridColDef[] = [ @@ -263,7 +225,7 @@ function SubTags(): JSX.Element { return ( {params.row.childTags.totalCount} @@ -283,7 +245,7 @@ function SubTags(): JSX.Element { return ( {params.row.usersAssignedTo.totalCount} @@ -301,28 +263,14 @@ function SubTags(): JSX.Element { headerClassName: `${styles.tableHeader}`, renderCell: (params: GridCellParams) => { return ( -
- - - -
+ ); }, }, @@ -334,21 +282,16 @@ function SubTags(): JSX.Element {
+ setTagSearchName(e.target.value.trim())} data-testid="searchByName" autoComplete="off" - required /> -
- {tCommon('sort')} + {tagSortOrder === 'DESCENDING' + ? tCommon('Latest') + : tCommon('Oldest')} - + setTagSortOrder('DESCENDING')} + > {tCommon('Latest')} - + setTagSortOrder('ASCENDING')} + > {tCommon('Oldest')} @@ -379,104 +330,100 @@ function SubTags(): JSX.Element { data-testid="manageCurrentTagBtn" className="mx-4" > - {`${t('manageTag')} ${subTagsData?.getUserTag.name}`} - - -
+
-
-
-
- -
- -
navigate(`/orgtags/${orgId}`)} - className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} - data-testid="allTagsBtn" - > - {'Tags'} - -
+ {subTagsLoading || createUserTagLoading ? ( + + ) : ( +
+
+
+ +
- {orgUserTagAncestors?.map((tag, index) => (
redirectToSubTags(tag._id as string)} - data-testid="redirectToSubTags" + onClick={() => navigate(`/orgtags/${orgId}`)} + className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} + data-testid="allTagsBtn" > - {tag.name} - - {orgUserTagAncestors.length - 1 !== index && ( - - )} + {'Tags'} +
- ))} -
- row._id} - slots={{ - noRowsOverlay: /* istanbul ignore next */ () => ( - ( +
redirectToSubTags(tag._id as string)} + data-testid="redirectToSubTags" > - {t('noTagsFound')} - - ), - }} - sx={dataGridStyle} - getRowClassName={() => `${styles.rowBackground}`} - autoHeight - rowHeight={65} - rows={userTagsList?.map((fund, index) => ({ - id: index + 1, - ...fund, - }))} - columns={columns} - isRowSelectable={() => false} - /> -
-
+ {tag.name} -
-
- -
-
- -
+ {orgUserTagAncestors.length - 1 !== index && ( + + )} +
+ ))} +
+
+ } + scrollableTarget="subTagsScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noTagsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={subTagsList?.map((subTag, index) => ({ + id: index + 1, + ...subTag, + }))} + columns={columns} + isRowSelectable={() => false} + /> + +
+
+ )}
@@ -527,44 +474,6 @@ function SubTags(): JSX.Element { - - {/* Remove User Tag Modal */} - - - - {t('removeUserTag')} - - - {t('removeUserTagMessage')} - - - - - ); } diff --git a/src/screens/SubTags/SubTagsMocks.ts b/src/screens/SubTags/SubTagsMocks.ts index 757f3f42ad..5165ea3a53 100644 --- a/src/screens/SubTags/SubTagsMocks.ts +++ b/src/screens/SubTags/SubTagsMocks.ts @@ -1,33 +1,27 @@ -import { - CREATE_USER_TAG, - REMOVE_USER_TAG, -} from 'GraphQl/Mutations/TagMutations'; -import { - USER_TAG_ANCESTORS, - USER_TAG_SUB_TAGS, -} from 'GraphQl/Queries/userTagQueries'; +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; export const MOCKS = [ { request: { query: USER_TAG_SUB_TAGS, variables: { - id: 'tag1', - after: null, - before: null, - first: 5, - last: null, + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { data: { - getUserTag: { - name: 'tag1', + getChildTags: { + name: 'userTag 1', childTags: { edges: [ { node: { - _id: '1', + _id: 'subTag1', name: 'subTag 1', usersAssignedTo: { totalCount: 5, @@ -35,12 +29,18 @@ export const MOCKS = [ childTags: { totalCount: 5, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '1', + cursor: 'subTag1', }, { node: { - _id: '2', + _id: 'subTag2', name: 'subTag 2', usersAssignedTo: { totalCount: 5, @@ -48,12 +48,18 @@ export const MOCKS = [ childTags: { totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '2', + cursor: 'subTag2', }, { node: { - _id: '3', + _id: 'subTag3', name: 'subTag 3', usersAssignedTo: { totalCount: 0, @@ -61,12 +67,18 @@ export const MOCKS = [ childTags: { totalCount: 5, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '3', + cursor: 'subTag3', }, { node: { - _id: '4', + _id: 'subTag4', name: 'subTag 4', usersAssignedTo: { totalCount: 0, @@ -74,12 +86,18 @@ export const MOCKS = [ childTags: { totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '4', + cursor: 'subTag4', }, { node: { - _id: '5', + _id: 'subTag5', name: 'subTag 5', usersAssignedTo: { totalCount: 5, @@ -87,18 +105,120 @@ export const MOCKS = [ childTags: { totalCount: 5, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag5', + }, + { + node: { + _id: 'subTag6', + name: 'subTag 6', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag6', + }, + { + node: { + _id: 'subTag7', + name: 'subTag 7', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag7', + }, + { + node: { + _id: 'subTag8', + name: 'subTag 8', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag8', + }, + { + node: { + _id: 'subTag9', + name: 'subTag 9', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag9', + }, + { + node: { + _id: 'subTag10', + name: 'subTag 10', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '5', + cursor: 'subTag10', }, ], pageInfo: { startCursor: '1', - endCursor: '5', + endCursor: '10', hasNextPage: true, hasPreviousPage: false, }, - totalCount: 6, + totalCount: 11, }, + ancestorTags: [], }, }, }, @@ -107,41 +227,48 @@ export const MOCKS = [ request: { query: USER_TAG_SUB_TAGS, variables: { - id: 'tag1', - after: '5', - before: null, - first: 5, - last: null, + id: '1', + after: '10', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { data: { - getUserTag: { - name: 'tag1', + getChildTags: { + name: 'userTag 1', childTags: { edges: [ { node: { - _id: '6', - name: 'subTag 6', + _id: 'subTag11', + name: 'subTag 11', usersAssignedTo: { totalCount: 0, }, childTags: { totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '6', + cursor: 'subTag11', }, ], pageInfo: { - startCursor: '6', - endCursor: '6', + startCursor: '11', + endCursor: '11', hasNextPage: false, hasPreviousPage: true, }, - totalCount: 6, + totalCount: 11, }, + ancestorTags: [], }, }, }, @@ -150,93 +277,124 @@ export const MOCKS = [ request: { query: USER_TAG_SUB_TAGS, variables: { - id: 'tag1', - after: null, - before: '6', - first: null, - last: 5, + id: 'subTag1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, result: { data: { - getUserTag: { - name: 'tag1', + getChildTags: { + name: 'subTag 1', childTags: { edges: [ { node: { - _id: '1', - name: 'subTag 1', + _id: 'subTag1.1', + name: 'subTag 1.1', usersAssignedTo: { totalCount: 5, }, childTags: { totalCount: 5, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + { + _id: 'subTag1', + name: 'subTag 1', + }, + ], }, - cursor: '1', - }, - { - node: { - _id: '2', - name: 'subTag 2', - usersAssignedTo: { - totalCount: 5, - }, - childTags: { - totalCount: 0, - }, - }, - cursor: '2', + cursor: 'subTag1.1', }, + ], + pageInfo: { + startCursor: 'subTag1.1', + endCursor: 'subTag1.1', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 1, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchSubTag' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ { node: { - _id: '3', - name: 'subTag 3', + _id: 'searchSubTag1', + name: 'searchSubTag 1', usersAssignedTo: { totalCount: 0, }, childTags: { - totalCount: 5, + totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '3', + cursor: 'searchSubTag1', }, { node: { - _id: '4', - name: 'subTag 4', + _id: 'searchSubTag2', + name: 'searchSubTag 2', usersAssignedTo: { totalCount: 0, }, childTags: { totalCount: 0, }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], }, - cursor: '4', - }, - { - node: { - _id: '5', - name: 'subTag 5', - usersAssignedTo: { - totalCount: 5, - }, - childTags: { - totalCount: 5, - }, - }, - cursor: '5', + cursor: 'searchSubTag2', }, ], pageInfo: { - startCursor: '1', - endCursor: '5', - hasNextPage: true, + startCursor: 'searchSubTag1', + endCursor: 'searchSubTag2', + hasNextPage: false, hasPreviousPage: false, }, - totalCount: 6, + totalCount: 2, }, + ancestorTags: [], }, }, }, @@ -246,98 +404,82 @@ export const MOCKS = [ query: USER_TAG_SUB_TAGS, variables: { id: '1', - after: null, - before: null, - first: 5, - last: null, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchSubTag' } }, + sortedBy: { id: 'ASCENDING' }, }, }, result: { data: { - getUserTag: { - name: 'subTag 1', + getChildTags: { + name: 'userTag 1', childTags: { - edges: [], + edges: [ + { + node: { + _id: 'searchSubTag2', + name: 'searchSubTag 2', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchSubTag2', + }, + { + node: { + _id: 'searchSubTag1', + name: 'searchSubTag 1', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchSubTag1', + }, + ], pageInfo: { - startCursor: null, - endCursor: null, + startCursor: 'searchSubTag2', + endCursor: 'searchSubTag1', hasNextPage: false, hasPreviousPage: false, }, - totalCount: 0, + totalCount: 2, }, + ancestorTags: [], }, }, }, }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: 'tag1', - }, - }, - result: { - data: { - getUserTagAncestors: [ - { - _id: '1', - name: 'tag1', - }, - ], - }, - }, - }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: '1', - }, - }, - result: { - data: { - getUserTagAncestors: [ - { - _id: 'tag1', - name: 'tag 1', - }, - { - _id: '1', - name: 'subTag 1', - }, - ], - }, - }, - }, { request: { query: CREATE_USER_TAG, variables: { - name: 'subTag 7', + name: 'subTag 12', organizationId: '123', - parentTagId: 'tag1', + parentTagId: '1', }, }, result: { data: { createUserTag: { - _id: '7', - }, - }, - }, - }, - { - request: { - query: REMOVE_USER_TAG, - variables: { - id: '1', - }, - }, - result: { - data: { - removeUserTag: { - _id: '1', + _id: 'subTag12', }, }, }, @@ -349,65 +491,10 @@ export const MOCKS_ERROR_SUB_TAGS = [ request: { query: USER_TAG_SUB_TAGS, variables: { - id: 'tag1', - after: null, - before: null, - first: 5, - last: null, - }, - }, - error: new Error('Mock Graphql Error'), - }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: 'tag1', - }, - }, - result: { - data: { - getUserTagAncestors: [], - }, - }, - }, -]; - -export const MOCKS_ERROR_TAG_ANCESTORS = [ - { - request: { - query: USER_TAG_SUB_TAGS, - variables: { - id: 'tag1', - after: null, - before: null, - first: 5, - last: null, - }, - }, - result: { - data: { - getUserTag: { - name: 'tag1', - childTags: { - edges: [], - pageInfo: { - startCursor: '1', - endCursor: '5', - hasNextPage: true, - hasPreviousPage: false, - }, - totalCount: 6, - }, - }, - }, - }, - }, - { - request: { - query: USER_TAG_ANCESTORS, - variables: { - id: 'tag1', + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, }, }, error: new Error('Mock Graphql Error'), diff --git a/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx b/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx index 3f299c5c48..b9fd7a7d4d 100644 --- a/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx +++ b/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx @@ -53,7 +53,7 @@ const pledgeProps: InterfacePledgeModal[] = [ _id: '1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, ], }, @@ -77,7 +77,7 @@ const pledgeProps: InterfacePledgeModal[] = [ _id: '1', firstName: 'John', lastName: 'Doe', - image: null, + image: undefined, }, ], }, diff --git a/src/screens/UserPortal/Campaigns/PledgeModal.tsx b/src/screens/UserPortal/Campaigns/PledgeModal.tsx index d9076f52c8..44cc82401b 100644 --- a/src/screens/UserPortal/Campaigns/PledgeModal.tsx +++ b/src/screens/UserPortal/Campaigns/PledgeModal.tsx @@ -6,7 +6,7 @@ import { currencyOptions, currencySymbols } from 'utils/currency'; import type { InterfaceCreatePledge, InterfacePledgeInfo, - InterfacePledger, + InterfaceUserInfo, } from 'utils/interfaces'; import styles from './Campaigns.module.css'; import React, { useCallback, useEffect, useState } from 'react'; @@ -77,7 +77,7 @@ const PledgeModal: React.FC = ({ }); // State to manage the list of pledgers (users who are part of the pledge) - const [pledgers, setPledgers] = useState([]); + const [pledgers, setPledgers] = useState([]); // Mutation to update an existing pledge const [updatePledge] = useMutation(UPDATE_PLEDGE); @@ -252,7 +252,7 @@ const PledgeModal: React.FC = ({ readOnly={mode === 'edit' ? true : false} isOptionEqualToValue={(option, value) => option._id === value._id} filterSelectedOptions={true} - getOptionLabel={(member: InterfacePledger): string => + getOptionLabel={(member: InterfaceUserInfo): string => `${member.firstName} ${member.lastName}` } onChange={ diff --git a/src/screens/UserPortal/Chat/Chat.test.tsx b/src/screens/UserPortal/Chat/Chat.test.tsx index d5e8049687..a5c74b1e35 100644 --- a/src/screens/UserPortal/Chat/Chat.test.tsx +++ b/src/screens/UserPortal/Chat/Chat.test.tsx @@ -698,7 +698,7 @@ const MESSAGE_SENT_TO_CHAT_MOCK = [ }, result: { data: { - messageSentToGroupChat: { + messageSentToChat: { _id: '668ec1f1df364e03ac47a151', createdAt: '2024-07-10T17:16:33.248Z', messageContent: 'Test ', @@ -724,7 +724,7 @@ const MESSAGE_SENT_TO_CHAT_MOCK = [ }, result: { data: { - messageSentToGroupChat: { + messageSentToChat: { _id: '668ec1f13603ac4697a151', createdAt: '2024-07-10T17:16:33.248Z', messageContent: 'Test ', diff --git a/src/screens/UserPortal/Pledges/Pledges.tsx b/src/screens/UserPortal/Pledges/Pledges.tsx index 59802be507..33e8bf63c2 100644 --- a/src/screens/UserPortal/Pledges/Pledges.tsx +++ b/src/screens/UserPortal/Pledges/Pledges.tsx @@ -4,7 +4,7 @@ import styles from './Pledges.module.css'; import { useTranslation } from 'react-i18next'; import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; import useLocalStorage from 'utils/useLocalstorage'; -import type { InterfacePledgeInfo, InterfacePledger } from 'utils/interfaces'; +import type { InterfacePledgeInfo, InterfaceUserInfo } from 'utils/interfaces'; import { Unstable_Popup as BasePopup } from '@mui/base/Unstable_Popup'; import { type ApolloQueryResult, useQuery } from '@apollo/client'; import { USER_PLEDGES } from 'GraphQl/Queries/fundQueries'; @@ -81,7 +81,7 @@ const Pledges = (): JSX.Element => { } const [anchor, setAnchor] = useState(null); - const [extraUsers, setExtraUsers] = useState([]); + const [extraUsers, setExtraUsers] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [pledges, setPledges] = useState([]); const [pledge, setPledge] = useState(null); @@ -154,7 +154,7 @@ const Pledges = (): JSX.Element => { const handleClick = ( event: React.MouseEvent, - users: InterfacePledger[], + users: InterfaceUserInfo[], ): void => { setExtraUsers(users); setAnchor(anchor ? null : event.currentTarget); @@ -197,7 +197,7 @@ const Pledges = (): JSX.Element => {
{params.row.users .slice(0, 2) - .map((user: InterfacePledger, index: number) => ( + .map((user: InterfaceUserInfo, index: number) => (
{user.image ? ( { disablePortal className={`${styles.popup} ${extraUsers.length > 4 ? styles.popupExtra : ''}`} > - {extraUsers.map((user: InterfacePledger, index: number) => ( + {extraUsers.map((user: InterfaceUserInfo, index: number) => (
=> { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderActions = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Actions Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> +
} + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Actions screen', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Check Sorting Functionality', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by dueDate_DESC + fireEvent.click(sortBtn); + const dueDateDESC = await screen.findByTestId('dueDate_DESC'); + expect(dueDateDESC).toBeInTheDocument(); + fireEvent.click(dueDateDESC); + + let assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Group 1'); + + // Sort by dueDate_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const dueDateASC = await screen.findByTestId('dueDate_ASC'); + expect(dueDateASC).toBeInTheDocument(); + fireEvent.click(dueDateASC); + + assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Search by Assignee name', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByAssignee = await screen.findByTestId('assignee'); + expect(searchByAssignee).toBeInTheDocument(); + userEvent.click(searchByAssignee); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Category name', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByCategory = await screen.findByTestId('category'); + expect(searchByCategory).toBeInTheDocument(); + userEvent.click(searchByCategory); + + // Search by name on press of ENTER + userEvent.type(searchInput, '1'); + await debounceWait(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('should render screen with No Actions', async () => { + renderActions(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noActionItems)).toBeInTheDocument(); + }); + }); + + it('Error while fetching Actions data', async () => { + renderActions(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ItemUpdateStatusModal', async () => { + renderActions(link1); + + const checkbox = await screen.findAllByTestId('statusCheckbox'); + userEvent.click(checkbox[0]); + + expect(await screen.findByText(t.actionItemStatus)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close ItemViewModal', async () => { + renderActions(link1); + + const viewItemBtn = await screen.findAllByTestId('viewItemBtn'); + userEvent.click(viewItemBtn[0]); + + expect(await screen.findByText(t.actionItemDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx new file mode 100644 index 0000000000..36b1f29b83 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import dayjs from 'dayjs'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceActionItemInfo } from 'utils/interfaces'; +import styles from 'screens/OrganizationActionItems/OrganizationActionItems.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import ItemViewModal from 'screens/OrganizationActionItems/ItemViewModal'; +import Avatar from 'components/Avatar/Avatar'; +import ItemUpdateStatusModal from 'screens/OrganizationActionItems/ItemUpdateStatusModal'; +import { ACTION_ITEMS_BY_USER } from 'GraphQl/Queries/ActionItemQueries'; +import useLocalStorage from 'utils/useLocalstorage'; + +enum ModalState { + VIEW = 'view', + STATUS = 'status', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing and displaying action items within an organization. + * + * This component allows users to view, filter, sort, and create action items. It also handles fetching and displaying related data such as action item categories and members. + * + * @returns The rendered component. + */ +function actions(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + if (!orgId || !userId) { + return ; + } + + const [actionItem, setActionItem] = useState( + null, + ); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'dueDate_ASC' | 'dueDate_DESC' | null>( + null, + ); + const [searchBy, setSearchBy] = useState<'assignee' | 'category'>('assignee'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.VIEW]: false, + [ModalState.STATUS]: false, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (actionItem: InterfaceActionItemInfo | null, modal: ModalState): void => { + setActionItem(actionItem); + openModal(modal); + }, + [openModal], + ); + + /** + * Query to fetch action items for the organization based on filters and sorting. + */ + const { + data: actionItemsData, + loading: actionItemsLoading, + error: actionItemsError, + refetch: actionItemsRefetch, + }: { + data?: { + actionItemsByUser: InterfaceActionItemInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(ACTION_ITEMS_BY_USER, { + variables: { + userId, + orderBy: sortBy, + where: { + orgId, + assigneeName: searchBy === 'assignee' ? searchTerm : undefined, + categoryName: searchBy === 'category' ? searchTerm : undefined, + }, + }, + }); + + const actionItems = useMemo( + () => actionItemsData?.actionItemsByUser || [], + [actionItemsData], + ); + + if (actionItemsLoading) { + return ; + } + + if (actionItemsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Action Items' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'assignee', + headerName: 'Assignee', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = + params.row.assignee?.user || {}; + + return ( + <> + {params.row.assigneeType === 'EventVolunteer' ? ( + <> +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ + ) : ( + <> +
+
+ +
+ {params.row.assigneeGroup?.name as string} +
+ + )} + + ); + }, + }, + { + field: 'itemCategory', + headerName: 'Item Category', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.actionItemCategory?.name} +
+ ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.isCompleted ? 'Completed' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.isCompleted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'allottedHours', + headerName: 'Allotted Hours', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.allottedHours ?? '-'} +
+ ); + }, + }, + { + field: 'dueDate', + headerName: 'Due Date', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
+ {dayjs(params.row.dueDate).format('DD/MM/YYYY')} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + ); + }, + }, + { + field: 'completed', + headerName: 'Completed', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ handleModalClick(params.row, ModalState.STATUS)} + /> +
+ ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('assignee')} + data-testid="assignee" + > + {t('assignee')} + + setSearchBy('category')} + data-testid="category" + > + {t('category')} + + + + + + + {tCommon('sort')} + + + setSortBy('dueDate_DESC')} + data-testid="dueDate_DESC" + > + {t('latestDueDate')} + + setSortBy('dueDate_ASC')} + data-testid="dueDate_ASC" + > + {t('earliestDueDate')} + + + +
+
+
+ + {/* Table with Action Items */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noActionItems')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={actionItems} + columns={columns} + isRowSelectable={() => false} + /> + + {/* View Modal */} + {actionItem && ( + <> + closeModal(ModalState.VIEW)} + item={actionItem} + /> + + closeModal(ModalState.STATUS)} + actionItemsRefetch={actionItemsRefetch} + /> + + )} +
+ ); +} + +export default actions; diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx new file mode 100644 index 0000000000..1d83d9a872 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx @@ -0,0 +1,308 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, UPDATE_ERROR_MOCKS } from './Groups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceGroupModal } from './GroupModal'; +import GroupModal from './GroupModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupModal = ( + link: ApolloLink, + props: InterfaceGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing GroupModal', () => { + it('GroupModal -> Requests -> Accept', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const acceptBtn = screen.getAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + userEvent.click(acceptBtn[0]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestAccepted); + }); + }); + + it('GroupModal -> Requests -> Reject', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const rejectBtn = screen.getAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + userEvent.click(rejectBtn[0]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestRejected); + }); + }); + + it('GroupModal -> Click Requests -> Click Details', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const detailsBtn = await screen.findByText(t.details); + expect(detailsBtn).toBeInTheDocument(); + userEvent.click(detailsBtn); + }); + + it('GroupModal -> Details -> Update', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupUpdated); + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Details -> Update -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Requests -> Accept -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const acceptBtn = screen.getAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + userEvent.click(acceptBtn[0]); + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('Try adding different values for volunteersRequired', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '-1' } }); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + userEvent.clear(vrInput); + userEvent.type(vrInput, '1{backspace}'); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '0' } }); + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '19' } }); + await waitFor(() => { + expect(vrInput).toHaveValue('19'); + }); + }); + + it('GroupModal -> Details -> No values updated', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx new file mode 100644 index 0000000000..4ae162cd70 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx @@ -0,0 +1,415 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { + InterfaceCreateVolunteerGroup, + InterfaceVolunteerGroupInfo, + InterfaceVolunteerMembership, +} from 'utils/interfaces'; +import styles from 'screens/EventVolunteers/EventVolunteers.module.css'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { + FormControl, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import { + UPDATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { PiUserListBold } from 'react-icons/pi'; +import { TbListDetails } from 'react-icons/tb'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import Avatar from 'components/Avatar/Avatar'; +import { FaXmark } from 'react-icons/fa6'; + +export interface InterfaceGroupModal { + isOpen: boolean; + hide: () => void; + eventId: string; + group: InterfaceVolunteerGroupInfo; + refetchGroups: () => void; +} + +/** + * A modal dialog for creating or editing a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer group. + * @param orgId - The ID of the organization associated with volunteer group. + * @param group - The volunteer group object to be edited. + * @param refetchGroups - Function to refetch the volunteer groups after creation or update. + * @returns The rendered modal component. + * + * The `VolunteerGroupModal` component displays a form within a modal dialog for creating or editing a Volunteer Group. + * It includes fields for entering the group name, description, volunteersRequired, and selecting volunteers/leaders. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - An input field for entering the group name. + * - A textarea for entering the group description. + * - An input field for entering the number of volunteers required. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updateVoluneerGroup` mutation to update an existing group, or + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const GroupModal: React.FC = ({ + isOpen, + hide, + eventId, + group, + refetchGroups, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [modalType, setModalType] = useState<'details' | 'requests'>('details'); + const [formState, setFormState] = useState({ + name: group.name, + description: group.description ?? '', + leader: group.leader, + volunteerUsers: group.volunteers.map((volunteer) => volunteer.user), + volunteersRequired: group.volunteersRequired ?? null, + }); + + const [updateVolunteerGroup] = useMutation(UPDATE_VOLUNTEER_GROUP); + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'requestAccepted' : 'requestRejected', + ) as string, + ); + refetchRequests(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Query to fetch volunteer Membership requests for the event. + */ + const { + data: requestsData, + refetch: refetchRequests, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + eventId, + groupId: group._id, + status: 'requested', + }, + }, + }); + + const requests = useMemo(() => { + if (!requestsData) return []; + return requestsData.getVolunteerMembership; + }, [requestsData]); + + useEffect(() => { + setFormState({ + name: group.name, + description: group.description ?? '', + leader: group.leader, + volunteerUsers: group.volunteers.map((volunteer) => volunteer.user), + volunteersRequired: group.volunteersRequired ?? null, + }); + }, [group]); + + const { name, description, volunteersRequired } = formState; + + const updateGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const updatedFields: { + [key: string]: number | string | undefined | null; + } = {}; + + if (name !== group?.name) { + updatedFields.name = name; + } + if (description !== group?.description) { + updatedFields.description = description; + } + if (volunteersRequired !== group?.volunteersRequired) { + updatedFields.volunteersRequired = volunteersRequired; + } + try { + await updateVolunteerGroup({ + variables: { + id: group?._id, + data: { ...updatedFields, eventId }, + }, + }); + toast.success(t('volunteerGroupUpdated')); + refetchGroups(); + hide(); + } catch (error: unknown) { + console.log(error); + toast.error((error as Error).message); + } + }, + [formState, group], + ); + + return ( + + +

{t('manageGroup')}

+ +
+ +
+ setModalType('details')} + /> + + + setModalType('requests')} + checked={modalType === 'requests'} + /> + +
+ + {modalType === 'details' ? ( +
+ {/* Input field to enter the group name */} + + + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {/* Input field to enter the group description */} + + + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + volunteersRequired: parseInt(e.target.value), + }); + } else if (e.target.value === '') { + setFormState({ + ...formState, + volunteersRequired: null, + }); + } + }} + /> + + + + {/* Button to submit the pledge form */} + +
+ ) : ( +
+ {requests.length === 0 ? ( + + {t('noRequests')} + + ) : ( + + + + + Name + Actions + + + + {requests.map((request, index) => { + const { _id, firstName, lastName, image } = + request.volunteer.user; + return ( + + + {image ? ( + volunteer + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ +
+ + +
+
+
+ ); + })} +
+
+
+ )} +
+ )} +
+
+ ); +}; +export default GroupModal; diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts b/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts new file mode 100644 index 0000000000..204326ee8d --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts @@ -0,0 +1,468 @@ +import { + UPDATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { + EVENT_VOLUNTEER_GROUP_LIST, + USER_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Queries/EventVolunteerQueries'; + +const group1 = { + _id: 'groupId1', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId1', + }, +}; + +const group2 = { + _id: 'groupId2', + name: 'Group 2', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:25:13.044Z', + creator: { + _id: 'creatorId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId2', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId2', + }, +}; + +const group3 = { + _id: 'groupId3', + name: 'Group 3', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'creatorId3', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Bruce', + lastName: 'Garza', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId3', + }, +}; + +const membership1 = { + _id: 'membershipId1', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: { + _id: 'groupId', + name: 'Group 1', + }, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId2', + user: { + _id: 'userId2', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + group: { + _id: 'groupId', + name: 'Group 2', + }, +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_DESC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_ASC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group2, group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '1', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: '', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: 'Bruce', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group3], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + groupId: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + group: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + error: new Error('Mock Graphql EVENT_VOLUNTEER_GROUP_LIST Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + groupId: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_GROUP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx new file mode 100644 index 0000000000..bc0a4993b9 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx @@ -0,0 +1,217 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Groups from './Groups'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Groups.mocks'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderGroups = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Groups Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> +
} + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Groups screen', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by members_DESC + fireEvent.click(sortBtn); + const volunteersDESC = await screen.findByTestId('volunteers_DESC'); + expect(volunteersDESC).toBeInTheDocument(); + fireEvent.click(volunteersDESC); + + let groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + + // Sort by members_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const volunteersASC = await screen.findByTestId('volunteers_ASC'); + expect(volunteersASC).toBeInTheDocument(); + fireEvent.click(volunteersASC); + + groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 2'); + }); + + it('Search by Groups', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByGroup = await screen.findByTestId('group'); + expect(searchByGroup).toBeInTheDocument(); + userEvent.click(searchByGroup); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Leader', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLeader = await screen.findByTestId('leader'); + expect(searchByLeader).toBeInTheDocument(); + userEvent.click(searchByLeader); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'Bruce'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('should render screen with No Groups', async () => { + renderGroups(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteerGroups)).toBeInTheDocument(); + }); + }); + + it('Error while fetching groups data', async () => { + renderGroups(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ViewModal', async () => { + renderGroups(link1); + + const viewGroupBtn = await screen.findAllByTestId('viewGroupBtn'); + userEvent.click(viewGroupBtn[0]); + + expect(await screen.findByText(t.groupDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('volunteerViewModalCloseBtn')); + }); + + it('Open and close GroupModal', async () => { + renderGroups(link1); + + const editGroupBtn = await screen.findAllByTestId('editGroupBtn'); + userEvent.click(editGroupBtn[0]); + + expect(await screen.findByText(t.manageGroup)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx new file mode 100644 index 0000000000..3941f461d5 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx @@ -0,0 +1,415 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from 'screens/EventVolunteers/EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import VolunteerGroupViewModal from 'screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal'; +import useLocalStorage from 'utils/useLocalstorage'; +import GroupModal from './GroupModal'; + +enum ModalState { + EDIT = 'edit', + VIEW = 'view', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing volunteer groups for an event. + * This component allows users to view, filter, sort, and create action items. It also provides a modal for creating and editing action items. + * @returns The rendered component. + */ +function groups(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId || !userId) { + return ; + } + + const [group, setGroup] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'volunteers_ASC' | 'volunteers_DESC' | null + >(null); + const [searchBy, setSearchBy] = useState<'leader' | 'group'>('group'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.EDIT]: false, + [ModalState.VIEW]: false, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + loading: groupsLoading, + error: groupsError, + refetch: refetchGroups, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: undefined, + userId, + orgId, + leaderName: searchBy === 'leader' ? searchTerm : null, + name_contains: searchBy === 'group' ? searchTerm : null, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (group: InterfaceVolunteerGroupInfo | null, modal: ModalState): void => { + setGroup(group); + openModal(modal); + }, + [openModal], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + if (groupsLoading) { + return ; + } + + if (groupsError) { + return ( +
+ +
+ {tErrors('errorLoading', { entity: 'Volunteer Groups' })} +
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'group', + headerName: 'Group', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.name} +
+ ); + }, + }, + { + field: 'leader', + headerName: 'Leader', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.leader; + return ( +
+ {image ? ( + Assignee + ) : ( +
+ +
+ )} + {firstName + ' ' + lastName} +
+ ); + }, + }, + { + field: 'actions', + headerName: 'Actions Completed', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.assignments.length} +
+ ); + }, + }, + { + field: 'volunteers', + headerName: 'No. of Volunteers', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.volunteers.length} +
+ ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + {params.row.leader._id === userId && ( + + )} + + ); + }, + }, + ]; + + return ( +
+ {/* Header with search, filter and Create Button */} +
+
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('leader')} + data-testid="leader" + > + {t('leader')} + + setSearchBy('group')} + data-testid="group" + > + {t('group')} + + + + + + + {tCommon('sort')} + + + setSortBy('volunteers_DESC')} + data-testid="volunteers_DESC" + > + {t('mostVolunteers')} + + setSortBy('volunteers_ASC')} + data-testid="volunteers_ASC" + > + {t('leastVolunteers')} + + + +
+
+
+ + {/* Table with Volunteer Groups */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteerGroups')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={groups.map((group, index) => ({ + id: index + 1, + ...group, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + {group && ( + <> + closeModal(ModalState.EDIT)} + refetchGroups={refetchGroups} + group={group} + eventId={group.event._id} + /> + closeModal(ModalState.VIEW)} + group={group} + /> + + )} +
+ ); +} + +export default groups; diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts b/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts new file mode 100644 index 0000000000..c400d96939 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts @@ -0,0 +1,263 @@ +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; + +const membership1 = { + _id: 'membershipId1', + status: 'invited', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: null, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'invited', + createdAt: '2024-10-30T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 2', + startDate: '2044-11-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + group: { + _id: 'groupId1', + name: 'Group 1', + }, +}; + +export const MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership2, membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + orderBy: 'createdAt_ASC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: 'group', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: 'individual', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + eventTitle: '1', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + error: new Error('Mock Graphql USER_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx new file mode 100644 index 0000000000..2c0cafc6a9 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx @@ -0,0 +1,303 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Invitations from './Invitations'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + UPDATE_ERROR_MOCKS, +} from './Invitations.mocks'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.userVolunteer ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderInvitations = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Invvitations Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> +
} + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Invitations screen', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by createdAt_DESC + fireEvent.click(sortBtn); + const createdAtDESC = await screen.findByTestId('createdAt_DESC'); + expect(createdAtDESC).toBeInTheDocument(); + fireEvent.click(createdAtDESC); + + let inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to join volunteer group', + ); + + // Sort by createdAt_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const createdAtASC = await screen.findByTestId('createdAt_ASC'); + expect(createdAtASC).toBeInTheDocument(); + fireEvent.click(createdAtASC); + + inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + + it('Filter Invitations (all)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterAll = await screen.findByTestId('filterAll'); + expect(filterAll).toBeInTheDocument(); + + fireEvent.click(filterAll); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(2); + }); + + it('Filter Invitations (group)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterGroup = await screen.findByTestId('filterGroup'); + expect(filterGroup).toBeInTheDocument(); + + fireEvent.click(filterGroup); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to join volunteer group', + ); + }); + + it('Filter Invitations (individual)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterIndividual = await screen.findByTestId('filterIndividual'); + expect(filterIndividual).toBeInTheDocument(); + + fireEvent.click(filterIndividual); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + + it('Search Invitations', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, '1'); + await debounceWait(); + + await waitFor(() => { + const inviteSubject = screen.getAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + }); + + it('should render screen with No Invitations', async () => { + renderInvitations(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noInvitations)).toBeInTheDocument(); + }); + }); + + it('Error while fetching invitations data', async () => { + renderInvitations(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Accept Invite', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.invitationAccepted); + }); + }); + + it('Reject Invite', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const rejectBtn = await screen.findAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + + // Reject Request + userEvent.click(rejectBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.invitationRejected); + }); + }); + + it('Error in Update Invite Mutation', async () => { + renderInvitations(link4); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx new file mode 100644 index 0000000000..a79b64251d --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx @@ -0,0 +1,297 @@ +import React, { useMemo, useState } from 'react'; +import { Dropdown, Form, Button } from 'react-bootstrap'; +import styles from '../VolunteerManagement.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import { TbCalendarEvent } from 'react-icons/tb'; +import { FaUserGroup } from 'react-icons/fa6'; +import { debounce, Stack } from '@mui/material'; + +import useLocalStorage from 'utils/useLocalstorage'; +import { useMutation, useQuery } from '@apollo/client'; +import type { InterfaceVolunteerMembership } from 'utils/interfaces'; +import { FaRegClock } from 'react-icons/fa'; +import Loader from 'components/Loader/Loader'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; + +enum ItemFilter { + Group = 'group', + Individual = 'individual', +} + +/** + * The `Invitations` component displays list of invites for the user to volunteer. + * It allows the user to search, sort, and accept/reject invites. + * + * @returns The rendered component displaying the upcoming events. + */ +const Invitations = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Retrieves stored user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Extracts organization ID from the URL parameters + const { orgId } = useParams(); + if (!orgId || !userId) { + // Redirects to the homepage if orgId or userId is missing + return ; + } + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const [searchTerm, setSearchTerm] = useState(''); + const [searchValue, setSearchValue] = useState(''); + const [filter, setFilter] = useState(null); + const [sortBy, setSortBy] = useState< + 'createdAt_ASC' | 'createdAt_DESC' | null + >(null); + + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'invitationAccepted' : 'invitationRejected', + ) as string, + ); + refetchInvitations(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + const { + data: invitationData, + loading: invitationLoading, + error: invitationError, + refetch: refetchInvitations, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + userId: userId, + status: 'invited', + filter: filter, + eventTitle: searchTerm ? searchTerm : undefined, + }, + orderBy: sortBy ? sortBy : undefined, + }, + }); + + const invitations = useMemo(() => { + if (!invitationData) return []; + return invitationData.getVolunteerMembership; + }, [invitationData]); + + // loads the invitations when the component mounts + if (invitationLoading) return ; + if (invitationError) { + // Displays an error message if there is an issue loading the invvitations + return ( +
+
+ +
+ {tErrors('errorLoading', { entity: 'Volunteership Invitations' })} +
+
+
+ ); + } + + // Renders the invitations list and UI elements for searching, sorting, and accepting/rejecting invites + return ( + <> +
+ {/* Search input field and button */} +
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ {/* Dropdown menu for sorting invitations */} + + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {t('receivedLatest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {t('receivedEarliest')} + + + + + + + + {t('filter')} + + + setFilter(null)} + data-testid="filterAll" + > + {tCommon('all')} + + setFilter(ItemFilter.Group)} + data-testid="filterGroup" + > + {t('groupInvite')} + + setFilter(ItemFilter.Individual)} + data-testid="filterIndividual" + > + {t('individualInvite')} + + + +
+
+
+ {invitations.length < 1 ? ( + + {/* Displayed if no invitations are found */} + {t('noInvitations')} + + ) : ( + invitations.map((invite: InterfaceVolunteerMembership) => ( +
+
+
+ {invite.group ? ( + <>{t('groupInvitationSubject')} + ) : ( + <>{t('eventInvitationSubject')} + )} +
+
+ {invite.group && ( + <> +
+ + Group:{' '} + {invite.group.name} +
+ | + + )} +
+ + Event:{' '} + {invite.event.title} +
+ | +
+ + Received:{' '} + {new Date(invite.createdAt).toLocaleString()} +
+
+
+
+ + +
+
+ )) + )} + + ); +}; + +export default Invitations; diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts new file mode 100644 index 0000000000..ae00d52dbe --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts @@ -0,0 +1,281 @@ +import { CREATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_EVENTS_VOLUNTEER } from 'GraphQl/Queries/PlugInQueries'; + +const event1 = { + _id: 'eventId1', + title: 'Event 1', + startDate: '2044-10-30', + endDate: '2044-10-30', + location: 'Mumbai', + startTime: null, + endTime: null, + allDay: true, + recurring: true, + volunteerGroups: [ + { + _id: 'groupId1', + name: 'Group 1', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'volunteerId1', + }, + { + _id: 'volunteerId2', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + }, + }, + { + _id: 'volunteerId2', + user: { + _id: 'userId2', + }, + }, + ], +}; + +const event2 = { + _id: 'eventId2', + title: 'Event 2', + startDate: '2044-10-31', + endDate: '2044-10-31', + location: 'Pune', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + volunteerGroups: [ + { + _id: 'groupId2', + name: 'Group 2', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'volunteerId3', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId3', + }, + }, + ], +}; + +const event3 = { + _id: 'eventId3', + title: 'Event 3', + startDate: '2044-10-31', + endDate: '2022-10-31', + location: 'Delhi', + startTime: null, + endTime: null, + description: 'desc', + allDay: true, + recurring: true, + volunteerGroups: [ + { + _id: 'groupId3', + name: 'Group 3', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'userId', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId', + user: { + _id: 'userId', + }, + }, + ], +}; + +export const MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1, event2, event3], + }, + }, + }, + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '1', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1], + }, + }, + }, + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: 'M', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: null, + status: 'requested', + userId: 'userId', + }, + }, + }, + result: { + data: { + createVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: 'groupId1', + status: 'requested', + userId: 'userId', + }, + }, + }, + result: { + data: { + createVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + error: new Error('Mock Graphql USER_EVENTS_VOLUNTEER Error'), + }, +]; + +export const CREATE_ERROR_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1, event2], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: null, + status: 'requested', + userId: 'userId', + }, + }, + }, + error: new Error('Mock Graphql CREATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx new file mode 100644 index 0000000000..43e0b15cdb --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx @@ -0,0 +1,224 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import UpcomingEvents from './UpcomingEvents'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + CREATE_ERROR_MOCKS, +} from './UpcomingEvents.mocks'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(CREATE_ERROR_MOCKS); + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.userVolunteer ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderUpcomingEvents = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
} + /> + + + + + + , + ); +}; + +describe('Testing Upcoming Events Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> +
} + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Upcoming Events screen', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Search by event title', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByTitle = await screen.findByTestId('title'); + expect(searchByTitle).toBeInTheDocument(); + userEvent.click(searchByTitle); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + }); + + it('Search by event location on click of search button', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLocation = await screen.findByTestId('location'); + expect(searchByLocation).toBeInTheDocument(); + userEvent.click(searchByLocation); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'M'); + await debounceWait(); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + }); + + it('should render screen with No Events', async () => { + renderUpcomingEvents(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noEvents)).toBeInTheDocument(); + }); + }); + + it('Error while fetching Events data', async () => { + renderUpcomingEvents(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Click on Individual volunteer button', async () => { + renderUpcomingEvents(link1); + + const volunteerBtn = await screen.findAllByTestId('volunteerBtn'); + userEvent.click(volunteerBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerSuccess); + }); + }); + + it('Join Volunteer Group', async () => { + renderUpcomingEvents(link1); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + userEvent.click(eventTitle[0]); + + const joinGroupBtn = await screen.findAllByTestId('joinBtn'); + expect(joinGroupBtn).toHaveLength(3); + userEvent.click(joinGroupBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerSuccess); + }); + }); + + it('Error on Create Volunteer Membership', async () => { + renderUpcomingEvents(link4); + + const volunteerBtn = await screen.findAllByTestId('volunteerBtn'); + userEvent.click(volunteerBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx new file mode 100644 index 0000000000..bd61ca97e0 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx @@ -0,0 +1,377 @@ +import React, { useMemo, useState } from 'react'; +import { Dropdown, Form, Button } from 'react-bootstrap'; +import styles from '../VolunteerManagement.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { IoLocationOutline } from 'react-icons/io5'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Accordion, + AccordionDetails, + AccordionSummary, + Chip, + Stack, + debounce, +} from '@mui/material'; +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { GridExpandMoreIcon } from '@mui/x-data-grid'; +import useLocalStorage from 'utils/useLocalstorage'; +import { useMutation, useQuery } from '@apollo/client'; +import type { InterfaceUserEvents } from 'utils/interfaces'; +import { IoIosHand } from 'react-icons/io'; +import Loader from 'components/Loader/Loader'; +import { USER_EVENTS_VOLUNTEER } from 'GraphQl/Queries/PlugInQueries'; +import { CREATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; +import { FaCheck } from 'react-icons/fa'; + +/** + * The `UpcomingEvents` component displays list of upcoming events for the user to volunteer. + * It allows the user to search, sort, and volunteer for events/volunteer groups. + * + * @returns The rendered component displaying the upcoming events. + */ +const UpcomingEvents = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Retrieves stored user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Extracts organization ID from the URL parameters + const { orgId } = useParams(); + if (!orgId || !userId) { + // Redirects to the homepage if orgId or userId is missing + return ; + } + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [searchBy, setSearchBy] = useState<'title' | 'location'>('title'); + + const [createVolunteerMembership] = useMutation(CREATE_VOLUNTEER_MEMBERSHIP); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const handleVolunteer = async ( + eventId: string, + group: string | null, + status: string, + ): Promise => { + try { + await createVolunteerMembership({ + variables: { + data: { + event: eventId, + group, + status, + userId, + }, + }, + }); + toast.success(t('volunteerSuccess')); + refetchEvents(); + } catch (error) { + toast.error((error as Error).message); + } + }; + + // Fetches upcomin events based on the organization ID, search term, and sorting order + const { + data: eventsData, + loading: eventsLoading, + error: eventsError, + refetch: refetchEvents, + }: { + data?: { + eventsByOrganizationConnection: InterfaceUserEvents[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_EVENTS_VOLUNTEER, { + variables: { + organization_id: orgId, + title_contains: searchBy === 'title' ? searchTerm : '', + location_contains: searchBy === 'location' ? searchTerm : '', + upcomingOnly: true, + first: null, + skip: null, + }, + }); + + // Extracts the list of upcoming events from the fetched data + const events = useMemo(() => { + if (eventsData) { + return eventsData.eventsByOrganizationConnection; + } + return []; + }, [eventsData]); + + // Renders a loader while events are being fetched + if (eventsLoading) return ; + if (eventsError) { + // Displays an error message if there is an issue loading the events + return ( +
+
+ +
+ {tErrors('errorLoading', { entity: 'Events' })} +
+
+
+ ); + } + + // Renders the upcoming events list and UI elements for searching, sorting, and adding pledges + return ( + <> +
+ {/* Search input field and button */} +
+ { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
+
+
+ + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('title')} + data-testid="title" + > + {t('name')} + + setSearchBy('location')} + data-testid="location" + > + {tCommon('location')} + + + +
+
+
+ {events.length < 1 ? ( + + {/* Displayed if no events are found */} + {t('noEvents')} + + ) : ( + events.map((event: InterfaceUserEvents, index: number) => { + const { + title, + description, + startDate, + endDate, + location, + volunteerGroups, + recurring, + _id, + volunteers, + } = event; + const isVolunteered = volunteers.some( + (volunteer) => volunteer.user._id === userId, + ); + return ( + + }> +
+
+
+

{title}

+ {recurring && ( + } + label={t('recurring')} + variant="outlined" + color="primary" + className={`${styles.chip} ${styles.active}`} + /> + )} +
+ +
+ + {' '} + + location: {location} + + Start Date: {startDate as unknown as string} + End Date: {endDate as unknown as string} +
+
+
+ +
+
+
+ + { + /*istanbul ignore next*/ + description && ( +
+ Description: + {description} +
+ ) + } + {volunteerGroups && volunteerGroups.length > 0 && ( + + + Volunteer Groups: + + + + + + + Sr. No. + + Group Name + + + No. of Members + + + Options + + + + + {volunteerGroups.map((group, index) => { + const { _id: gId, name, volunteers } = group; + const hasJoined = volunteers.some( + (volunteer) => volunteer._id === userId, + ); + return ( + + + {index + 1} + + + {name} + + + {volunteers.length} + + + + + + ); + })} + +
+
+
+ )} +
+
+ ); + }) + )} + + ); +}; + +export default UpcomingEvents; diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css b/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css new file mode 100644 index 0000000000..d3b2bbaa54 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css @@ -0,0 +1,138 @@ +/* Upcoming Events Styles */ +.btnsContainer { + display: flex; + margin: 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); + background-color: white; +} + +.btnsContainer .input button { + width: 52px; +} + +.accordionSummary { + width: 100% !important; + padding-right: 0.75rem; + display: flex; + justify-content: space-between !important; + align-items: center; +} + +.accordionSummary button { + height: 2.25rem; + padding-top: 0.35rem; +} + +.accordionSummary button:hover { + background-color: #31bb6a50 !important; + color: #31bb6b !important; +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.titleContainer h3 { + font-size: 1.25rem; + font-weight: 750; + color: #5e5e5e; + margin-top: 0.2rem; +} + +.subContainer span { + font-size: 0.9rem; + margin-left: 0.5rem; + font-weight: lighter; + color: #707070; +} + +.chipIcon { + height: 0.9rem !important; +} + +.chip { + height: 1.5rem !important; + margin: 0.15rem 0 0 1.25rem; +} + +.active { + background-color: #31bb6a50 !important; +} + +.pending { + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} + +.progress { + display: flex; + width: 45rem; +} + +.progressBar { + margin: 0rem 0.75rem; + width: 100%; + font-size: 0.9rem; + height: 1.25rem; +} + +/* Pledge Modal */ + +.pledgeModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.noOutline input { + outline: none; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx b/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx new file mode 100644 index 0000000000..65d2d082a7 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import i18n from 'utils/i18nForTest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import VolunteerManagement from './VolunteerManagement'; +import userEvent from '@testing-library/user-event'; +import { MOCKS } from './UpcomingEvents/UpcomingEvents.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import useLocalStorage from 'utils/useLocalstorage'; +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); + +const renderVolunteerManagement = (): RenderResult => { + return render( + + + + + + } + /> + } /> + } + /> + + + + + , + ); +}; + +describe('Volunteer Management', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } + /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + test('Render Volunteer Management Screen', async () => { + renderVolunteerManagement(); + + const upcomingEventsTab = await screen.findByTestId('upcomingEventsTab'); + expect(upcomingEventsTab).toBeInTheDocument(); + expect(screen.getByTestId('invitationsBtn')).toBeInTheDocument(); + expect(screen.getByTestId('actionsBtn')).toBeInTheDocument(); + expect(screen.getByTestId('groupsBtn')).toBeInTheDocument(); + }); + + test('Testing back button navigation', async () => { + renderVolunteerManagement(); + + const backButton = await screen.findByTestId('backBtn'); + userEvent.click(backButton); + await waitFor(() => { + const orgHome = screen.getByTestId('orgHome'); + expect(orgHome).toBeInTheDocument(); + }); + }); + + test('Testing volunteer management tab switching', async () => { + renderVolunteerManagement(); + + const invitationsBtn = screen.getByTestId('invitationsBtn'); + userEvent.click(invitationsBtn); + + const invitationsTab = screen.getByTestId('invitationsTab'); + expect(invitationsTab).toBeInTheDocument(); + + const actionsBtn = screen.getByTestId('actionsBtn'); + userEvent.click(actionsBtn); + + const actionsTab = screen.getByTestId('actionsTab'); + expect(actionsTab).toBeInTheDocument(); + + const groupsBtn = screen.getByTestId('groupsBtn'); + userEvent.click(groupsBtn); + + const groupsTab = screen.getByTestId('groupsTab'); + expect(groupsTab).toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx b/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx new file mode 100644 index 0000000000..87be9d7adc --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { FaChevronLeft, FaTasks } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown } from 'react-bootstrap'; +import { TbCalendarEvent } from 'react-icons/tb'; +import { FaRegEnvelopeOpen, FaUserGroup } from 'react-icons/fa6'; +import UpcomingEvents from './UpcomingEvents/UpcomingEvents'; +import Invitations from './Invitations/Invitations'; +import Actions from './Actions/Actions'; +import Groups from './Groups/Groups'; + +/** + * List of tabs for the volunteer dashboard. + * + * Each tab is associated with an icon and value. + */ +const volunteerDashboardTabs: { + value: TabOptions; + icon: JSX.Element; +}[] = [ + { + value: 'upcomingEvents', + icon: , + }, + { + value: 'invitations', + icon: , + }, + { + value: 'actions', + icon: , + }, + { + value: 'groups', + icon: , + }, +]; + +/** + * Tab options for the volunteer management component. + */ +type TabOptions = 'upcomingEvents' | 'invitations' | 'actions' | 'groups'; + +/** + * `VolunteerManagement` component handles the display and navigation of different event management sections. + * + * It provides a tabbed interface for: + * - Viewing upcoming events to volunteer + * - Managing volunteer requests + * - Managing volunteer invitations + * - Managing volunteer groups + * + * @returns JSX.Element - The `VolunteerManagement` component. + */ +const VolunteerManagement = (): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + + // Extract organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + // Hook for navigation + const navigate = useNavigate(); + + // State hook for managing the currently selected tab + const [tab, setTab] = useState('upcomingEvents'); + + /** + * Renders a button for each tab with the appropriate icon and label. + * + * @param value - The tab value + * @param icon - The icon to display for the tab + * @returns JSX.Element - The rendered button component + */ + const renderButton = ({ + value, + icon, + }: { + value: TabOptions; + icon: React.ReactNode; + }): JSX.Element => { + const selected = tab === value; + const variant = selected ? 'success' : 'light'; + const translatedText = t(value); + + const className = selected + ? 'px-4 d-flex align-items-center rounded-3 shadow-sm' + : 'text-secondary bg-white px-4 d-flex align-items-center rounded-3 shadow-sm'; + const props = { + variant, + className, + style: { height: '2.5rem' }, + onClick: () => setTab(value), + 'data-testid': `${value}Btn`, + }; + + return ( + + ); + }; + + const handleBack = (): void => { + navigate(`/user/organization/${orgId}`); + }; + + return ( +
+ + +
+ + {volunteerDashboardTabs.map(renderButton)} +
+ + + + {t(tab)} + + + {/* Render dropdown items for each settings category */} + {volunteerDashboardTabs.map(({ value, icon }, index) => ( + setTab(value) + } + className={`d-flex gap-2 ${tab === value && 'text-secondary'}`} + > + {icon} {t(value)} + + ))} + + + + + +
+
+
+ + {/* Render content based on the selected settings category */} + {(() => { + switch (tab) { + case 'upcomingEvents': + return ( +
+ +
+ ); + case 'invitations': + return ( +
+ +
+ ); + case 'actions': + return ( +
+ +
+ ); + case 'groups': + return ( +
+ +
+ ); + } + })()} +
+ ); +}; + +export default VolunteerManagement; diff --git a/src/state/reducers/userRoutersReducer.test.ts b/src/state/reducers/userRoutersReducer.test.ts index 98c4a78464..e2987dcd38 100644 --- a/src/state/reducers/userRoutersReducer.test.ts +++ b/src/state/reducers/userRoutersReducer.test.ts @@ -14,6 +14,7 @@ describe('Testing Routes reducer', () => { { name: 'Posts', url: 'user/organization/undefined' }, { name: 'People', url: 'user/people/undefined' }, { name: 'Events', url: 'user/events/undefined' }, + { name: 'Volunteer', url: 'user/volunteer/undefined' }, { name: 'Donate', url: 'user/donate/undefined' }, { name: 'Campaigns', url: 'user/campaigns/undefined' }, { name: 'My Pledges', url: 'user/pledges/undefined' }, @@ -31,6 +32,11 @@ describe('Testing Routes reducer', () => { }, { name: 'People', comp_id: 'people', component: 'People' }, { name: 'Events', comp_id: 'events', component: 'Events' }, + { + name: 'Volunteer', + comp_id: 'volunteer', + component: 'VolunteerManagement', + }, { name: 'Donate', comp_id: 'donate', component: 'Donate' }, { name: 'Campaigns', @@ -54,6 +60,7 @@ describe('Testing Routes reducer', () => { { name: 'Posts', url: 'user/organization/orgId' }, { name: 'People', url: 'user/people/orgId' }, { name: 'Events', url: 'user/events/orgId' }, + { name: 'Volunteer', url: 'user/volunteer/orgId' }, { name: 'Donate', url: 'user/donate/orgId' }, { name: 'Campaigns', url: 'user/campaigns/orgId' }, { name: 'My Pledges', url: 'user/pledges/orgId' }, @@ -71,6 +78,11 @@ describe('Testing Routes reducer', () => { }, { name: 'People', comp_id: 'people', component: 'People' }, { name: 'Events', comp_id: 'events', component: 'Events' }, + { + name: 'Volunteer', + comp_id: 'volunteer', + component: 'VolunteerManagement', + }, { name: 'Donate', comp_id: 'donate', component: 'Donate' }, { name: 'Campaigns', diff --git a/src/state/reducers/userRoutesReducer.ts b/src/state/reducers/userRoutesReducer.ts index 44bc91fabb..e1bf5de0dc 100644 --- a/src/state/reducers/userRoutesReducer.ts +++ b/src/state/reducers/userRoutesReducer.ts @@ -55,6 +55,7 @@ const components: ComponentType[] = [ }, { name: 'People', comp_id: 'people', component: 'People' }, { name: 'Events', comp_id: 'events', component: 'Events' }, + { name: 'Volunteer', comp_id: 'volunteer', component: 'VolunteerManagement' }, { name: 'Donate', comp_id: 'donate', component: 'Donate' }, { name: 'Campaigns', diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 3d3af2ac64..23abae8f61 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -7,6 +7,27 @@ export interface InterfaceUserType { }; } +export interface InterfaceUserInfo { + _id: string; + firstName: string; + lastName: string; + image?: string | null; +} + +// Base interface for common event properties +export interface InterfaceBaseEvent { + _id: string; + title: string; + description: string; + startDate: string; + endDate: string; + location: string; + startTime: string; + endTime: string; + allDay: boolean; + recurring: boolean; +} + export interface InterfaceActionItemCategoryInfo { _id: string; name: string; @@ -21,18 +42,11 @@ export interface InterfaceActionItemCategoryList { export interface InterfaceActionItemInfo { _id: string; - assignee: { - _id: string; - firstName: string; - lastName: string; - image: string | null; - }; - assigner: { - _id: string; - firstName: string; - lastName: string; - image: string | null; - }; + assigneeType: 'EventVolunteer' | 'EventVolunteerGroup' | 'User'; + assignee: InterfaceEventVolunteerInfo | null; + assigneeGroup: InterfaceVolunteerGroupInfo | null; + assigneeUser: InterfaceUserInfo | null; + assigner: InterfaceUserInfo; actionItemCategory: { _id: string; name: string; @@ -41,18 +55,14 @@ export interface InterfaceActionItemInfo { postCompletionNotes: string | null; assignmentDate: Date; dueDate: Date; - completionDate: Date; + completionDate: Date | null; isCompleted: boolean; event: { _id: string; title: string; } | null; - creator: { - _id: string; - firstName: string; - lastName: string; - }; - allotedHours: number | null; + creator: InterfaceUserInfo; + allottedHours: number | null; } export interface InterfaceActionItemList { @@ -212,12 +222,17 @@ export interface InterfaceQueryOrganizationPostListItem { export interface InterfaceTagData { _id: string; name: string; + parentTag: { _id: string }; usersAssignedTo: { totalCount: number; }; childTags: { totalCount: number; }; + ancestorTags: { + _id: string; + name: string; + }[]; } interface InterfaceTagNodeData { @@ -258,16 +273,19 @@ export interface InterfaceQueryOrganizationUserTags { export interface InterfaceQueryUserTagChildTags { name: string; childTags: InterfaceTagNodeData; + ancestorTags: { + _id: string; + name: string; + }[]; } export interface InterfaceQueryUserTagsAssignedMembers { name: string; usersAssignedTo: InterfaceTagMembersData; -} - -export interface InterfaceQueryUserTagsMembersToAssignTo { - name: string; - usersToAssignTo: InterfaceTagMembersData; + ancestorTags: { + _id: string; + name: string; + }[]; } export interface InterfaceQueryUserTagsMembersToAssignTo { @@ -357,19 +375,10 @@ export interface InterfacePledgeInfo { currency: string; endDate: string; startDate: string; - users: InterfacePledger[]; + users: InterfaceUserInfo[]; } -export interface InterfaceQueryOrganizationEventListItem { - _id: string; - title: string; - description: string; - startDate: string; - endDate: string; - location: string; - startTime: string; - endTime: string; - allDay: boolean; - recurring: boolean; +export interface InterfaceQueryOrganizationEventListItem + extends InterfaceBaseEvent { isPublic: boolean; isRegisterable: boolean; } @@ -497,7 +506,7 @@ export interface InterfacePostCard { } export interface InterfaceCreatePledge { - pledgeUsers: InterfacePledger[]; + pledgeUsers: InterfaceUserInfo[]; pledgeAmount: number; pledgeCurrency: string; pledgeStartDate: Date; @@ -519,13 +528,6 @@ export interface InterfaceQueryMembershipRequestsListItem { }[]; } -export interface InterfacePledger { - _id: string; - firstName: string; - lastName: string; - image: string | null; -} - export interface InterfaceAgendaItemCategoryInfo { _id: string; name: string; @@ -585,3 +587,101 @@ export interface InterfaceCustomFieldData { type: string; name: string; } + +export interface InterfaceEventVolunteerInfo { + _id: string; + hasAccepted: boolean; + hoursVolunteered: number | null; + user: InterfaceUserInfo; + assignments: { + _id: string; + }[]; + groups: { + _id: string; + name: string; + volunteers: { + _id: string; + }[]; + }[]; +} + +export interface InterfaceVolunteerGroupInfo { + _id: string; + name: string; + description: string | null; + event: { + _id: string; + }; + volunteersRequired: number | null; + createdAt: string; + creator: InterfaceUserInfo; + leader: InterfaceUserInfo; + volunteers: { + _id: string; + user: InterfaceUserInfo; + }[]; + assignments: { + _id: string; + actionItemCategory: { + _id: string; + name: string; + }; + allottedHours: number; + isCompleted: boolean; + }[]; +} + +export interface InterfaceCreateVolunteerGroup { + name: string; + description: string | null; + leader: InterfaceUserInfo | null; + volunteersRequired: number | null; + volunteerUsers: InterfaceUserInfo[]; +} + +export interface InterfaceUserEvents extends InterfaceBaseEvent { + volunteerGroups: { + _id: string; + name: string; + volunteersRequired: number; + description: string; + volunteers: { _id: string }[]; + }[]; + volunteers: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +export interface InterfaceVolunteerMembership { + _id: string; + status: string; + createdAt: string; + event: { + _id: string; + title: string; + startDate: string; + }; + volunteer: { + _id: string; + user: InterfaceUserInfo; + }; + group: { + _id: string; + name: string; + }; +} + +export interface InterfaceVolunteerRank { + rank: number; + hoursVolunteered: number; + user: { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string | null; + }; +} diff --git a/src/utils/organizationTagsUtils.ts b/src/utils/organizationTagsUtils.ts index fc987f37b5..99faca3bf2 100644 --- a/src/utils/organizationTagsUtils.ts +++ b/src/utils/organizationTagsUtils.ts @@ -1,5 +1,13 @@ // This file will contain the utililities for organization tags +import type { ApolloError } from '@apollo/client'; +import type { + InterfaceQueryOrganizationUserTags, + InterfaceQueryUserTagChildTags, + InterfaceQueryUserTagsAssignedMembers, + InterfaceQueryUserTagsMembersToAssignTo, +} from './interfaces'; + // This is the style object for mui's data grid used to list the data (tags and member data) export const dataGridStyle = { '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { @@ -20,9 +28,90 @@ export const dataGridStyle = { '& .MuiDataGrid-main': { borderRadius: '0.1rem', }, + '& .MuiDataGrid-topContainer': { + position: 'fixed', + top: 259, + zIndex: 1, + }, + '& .MuiDataGrid-virtualScrollerContent': { + marginTop: 6.5, + }, }; -export const ADD_PEOPLE_TO_TAGS_QUERY_LIMIT = 7; -export const TAGS_QUERY_LIMIT = 10; +// the data chunk size for tag related queries +export const TAGS_QUERY_DATA_CHUNK_SIZE = 10; +// the tag action type export type TagActionType = 'assignToTags' | 'removeFromTags'; + +// the sortedByType +export type SortedByType = 'ASCENDING' | 'DESCENDING'; + +// Interfaces for tag queries: +// 1. Base interface for Apollo query results +interface InterfaceBaseQueryResult { + loading: boolean; + error?: ApolloError; + refetch?: () => void; +} + +// 2. Generic pagination options +interface InterfacePaginationVariables { + after?: string | null; + first?: number | null; +} + +// 3. Generic fetch more options +interface InterfaceBaseFetchMoreOptions { + variables: InterfacePaginationVariables; + updateQuery?: (prev: T, options: { fetchMoreResult: T }) => T; +} + +// 4. Query interfaces +export interface InterfaceOrganizationTagsQuery + extends InterfaceBaseQueryResult { + data?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + organizations: InterfaceQueryOrganizationUserTags[]; + }>, + ) => void; +} + +export interface InterfaceOrganizationSubTagsQuery + extends InterfaceBaseQueryResult { + data?: { + getChildTags: InterfaceQueryUserTagChildTags; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + getChildTags: InterfaceQueryUserTagChildTags; + }>, + ) => void; +} + +export interface InterfaceTagAssignedMembersQuery + extends InterfaceBaseQueryResult { + data?: { + getAssignedUsers: InterfaceQueryUserTagsAssignedMembers; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + getAssignedUsers: InterfaceQueryUserTagsAssignedMembers; + }>, + ) => void; +} + +export interface InterfaceTagUsersToAssignToQuery + extends InterfaceBaseQueryResult { + data?: { + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }>, + ) => void; +}