From 7ce0153a763ef9db8b66934a4a3bc9a990edf51b Mon Sep 17 00:00:00 2001 From: Peter Harrison <16875803+palisadoes@users.noreply.github.com> Date: Thu, 9 Jan 2025 19:23:04 -0800 Subject: [PATCH] Merge from `develop-postgres` prior to code freeze (#3234) * 20250109190418 Deleted all files in the develop branch in anticipation of merging develop-postgres into develop cleanly * 20250109190422 Merge develop-postgres into develop --- .husky/pre-commit | 2 - jest.config.js | 4 +- package.json | 2 +- public/locales/en/translation.json | 1 + public/locales/fr/translation.json | 1 + public/locales/hi/translation.json | 1 + public/locales/sp/translation.json | 1 + public/locales/zh/translation.json | 1 + setup.ts | 203 ++--- src/assets/css/app.css | 21 + .../EventCalendar/EventCalendar.module.css | 0 .../EventCalendar/EventHeader.spec.tsx | 2 +- src/components/EventCalendar/EventHeader.tsx | 77 +- .../EventListCard/EventListCard.tsx | 5 +- .../EventAttendance/EventAttendance.spec.tsx | 15 +- .../EventAttendance/EventAttendance.tsx | 106 +-- .../EventStats/Statistics/AverageRating.tsx | 14 +- src/components/LeftDrawer/LeftDrawer.tsx | 8 +- src/components/OrgListCard/OrgListCard.tsx | 21 +- src/components/OrgListCard/TruncatedText.tsx | 80 ++ src/components/OrgListCard/useDebounce.tsx | 42 + .../OrgPeopleListCard.spec.tsx | 48 +- .../OrgPeopleListCard/OrgPeopleListCard.tsx | 5 +- .../OrgActionItemCategories.spec.tsx | 12 +- .../OrgActionItemCategories.tsx | 107 ++- .../RequestsTableItem.spec.tsx | 2 - .../RequestsTableItem/RequestsTableItem.tsx | 4 - .../UsersTableItem/UsersTableItem.tsx | 1 + src/components/Venues/VenueModal.spec.tsx | 85 ++ src/components/Venues/VenueModal.tsx | 4 - src/screens/BlockUser/BlockUser.spec.tsx | 12 +- src/screens/BlockUser/BlockUser.tsx | 95 +-- .../EventManagement/EventManagement.spec.tsx | 253 ++++++ .../EventManagement/EventManagement.test.tsx | 143 ---- .../EventManagement/EventManagement.tsx | 16 +- .../EventVolunteers/Requests/Requests.tsx | 41 +- .../VolunteerGroups/VolunteerGroups.tsx | 78 +- .../Volunteers/Volunteers.spec.tsx | 12 +- .../EventVolunteers/Volunteers/Volunteers.tsx | 100 +-- src/screens/ForgotPassword/ForgotPassword.tsx | 2 +- ...e.test.tsx => FundCampaignPledge.spec.tsx} | 52 +- .../FundCampaignPledge/FundCampaignPledge.tsx | 64 +- ...al.test.tsx => PledgeDeleteModal.spec.tsx} | 11 +- src/screens/Leaderboard/Leaderboard.spec.tsx | 8 +- src/screens/Leaderboard/Leaderboard.tsx | 97 +-- src/screens/ManageTag/ManageTag.spec.tsx | 10 +- src/screens/ManageTag/ManageTag.tsx | 44 +- .../{OrgList.test.tsx => OrgList.spec.tsx} | 20 +- src/screens/OrgList/OrgList.tsx | 67 +- src/screens/OrgList/OrgListMocks.ts | 1 - src/screens/OrgPost/OrgPost.test.tsx | 2 +- src/screens/OrgPost/OrgPost.tsx | 87 +-- src/screens/OrgSettings/OrgSettings.spec.tsx | 92 +-- src/screens/OrgSettings/OrgSettings.tsx | 30 +- .../OrganizationActionItems.spec.tsx | 12 +- .../OrganizationActionItems.tsx | 152 ++-- ...nModal.test.tsx => CampaignModal.spec.tsx} | 26 +- .../OrganizationFundCampagins.tsx | 61 +- ....tsx => OrganizationFundCampaign.spec.tsx} | 59 +- .../OrganizationFunds/OrganizationFunds.tsx | 49 +- src/screens/OrganizationPeople/AddMember.tsx | 58 +- .../OrganizationPeople/OrganizationPeople.tsx | 152 ++-- .../OrganizationTags/OrganizationTags.tsx | 50 +- .../OrganizationVenues/OrganizationVenues.tsx | 107 +-- src/screens/SubTags/SubTags.spec.tsx | 8 +- src/screens/SubTags/SubTags.tsx | 44 +- .../UserPortal/Campaigns/Campaigns.tsx | 63 +- src/screens/UserPortal/Pledges/Pledges.tsx | 106 +-- .../Posts/{Posts.test.tsx => Posts.spec.tsx} | 67 +- .../UserPortal/Settings/Settings.spec.tsx | 153 ++++ src/screens/UserPortal/Settings/Settings.tsx | 3 - .../UserPortal/UserScreen/UserScreen.spec.tsx | 19 + .../UserPortal/UserScreen/UserScreen.tsx | 1 + .../UserPortal/Volunteer/Actions/Actions.tsx | 76 +- .../UserPortal/Volunteer/Groups/Groups.tsx | 80 +- .../Invitations/Invitations.spec.tsx | 6 +- .../Volunteer/Invitations/Invitations.tsx | 93 +-- .../UpcomingEvents/UpcomingEvents.tsx | 44 +- src/screens/Users/Users.tsx | 97 +-- .../askAndSetDockerOption.spec.ts | 61 ++ .../askAndSetDockerOption.ts | 35 + .../askAndUpdatePort/askAndUpdatePort.ts | 25 + .../askAndUpdatePort/askForUpdatePort.spec.ts | 55 ++ src/setup/askForDocker/askForDocker.spec.ts | 69 ++ src/setup/askForDocker/askForDocker.ts | 99 +++ src/setup/updateEnvFile/updateEnvFile.spec.ts | 88 +++ src/setup/updateEnvFile/updateEnvFile.ts | 21 + ...tcha.test.ts => validateRecaptcha.spec.ts} | 1 + src/style/app.module.css | 126 ++- src/subComponents/SortingButton.tsx | 100 +++ src/utils/StaticMockLink.spec.ts | 725 ++++++++++++++++++ 91 files changed, 3186 insertions(+), 2017 deletions(-) create mode 100644 src/components/EventCalendar/EventCalendar.module.css create mode 100644 src/components/OrgListCard/TruncatedText.tsx create mode 100644 src/components/OrgListCard/useDebounce.tsx create mode 100644 src/screens/EventManagement/EventManagement.spec.tsx delete mode 100644 src/screens/EventManagement/EventManagement.test.tsx rename src/screens/FundCampaignPledge/{FundCampaignPledge.test.tsx => FundCampaignPledge.spec.tsx} (92%) rename src/screens/FundCampaignPledge/{PledgeDeleteModal.test.tsx => PledgeDeleteModal.spec.tsx} (95%) rename src/screens/OrgList/{OrgList.test.tsx => OrgList.spec.tsx} (98%) rename src/screens/OrganizationFundCampaign/{CampaignModal.test.tsx => CampaignModal.spec.tsx} (95%) rename src/screens/OrganizationFundCampaign/{OrganizationFundCampaign.test.tsx => OrganizationFundCampaign.spec.tsx} (88%) rename src/screens/UserPortal/Posts/{Posts.test.tsx => Posts.spec.tsx} (89%) create mode 100644 src/setup/askAndSetDockerOption/askAndSetDockerOption.spec.ts create mode 100644 src/setup/askAndSetDockerOption/askAndSetDockerOption.ts create mode 100644 src/setup/askAndUpdatePort/askAndUpdatePort.ts create mode 100644 src/setup/askAndUpdatePort/askForUpdatePort.spec.ts create mode 100644 src/setup/askForDocker/askForDocker.spec.ts create mode 100644 src/setup/askForDocker/askForDocker.ts create mode 100644 src/setup/updateEnvFile/updateEnvFile.spec.ts create mode 100644 src/setup/updateEnvFile/updateEnvFile.ts rename src/setup/validateRecaptcha/{validateRecaptcha.test.ts => validateRecaptcha.spec.ts} (95%) create mode 100644 src/subComponents/SortingButton.tsx create mode 100644 src/utils/StaticMockLink.spec.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 77ecddae25..8a0ce26aa2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,3 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" npm run format:fix # npm run lint:fix diff --git a/jest.config.js b/jest.config.js index 75e0cc5b4d..dffec5db18 100644 --- a/jest.config.js +++ b/jest.config.js @@ -72,8 +72,8 @@ export default { ], coverageThreshold: { global: { - lines: 20, - statements: 20, + lines: 1, + statements: 1, }, }, testPathIgnorePatterns: [ diff --git a/package.json b/package.json index cd52ff6453..7fd2f550eb 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "format:check": "prettier --check \"**/*.{ts,tsx,json,scss,css}\"", "check-tsdoc": "node .github/workflows/check-tsdoc.js", "typecheck": "tsc --project tsconfig.json --noEmit", - "prepare": "husky install", + "prepare": "husky", "jest-preview": "jest-preview", "update:toc": "node scripts/githooks/update-toc.js", "lint-staged": "lint-staged --concurrent false", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index a6cb90d3d1..fc445a708e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1262,6 +1262,7 @@ "endOfResults": "endOfResults" }, "userChat": { + "title": "Chats", "add": "Add", "chat": "Chat", "search": "Search", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 416d00c4b4..0ac5728872 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -1262,6 +1262,7 @@ "endOfResults": "Fin des résultats" }, "userChat": { + "title": "Discussions", "add": "Ajouter", "chat": "Chat", "contacts": "Contacts", diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json index 6abb8abefc..d791c0d9e8 100644 --- a/public/locales/hi/translation.json +++ b/public/locales/hi/translation.json @@ -1262,6 +1262,7 @@ "endOfResults": "परिणाम समाप्त" }, "userChat": { + "title": "चैट्स", "add": "जोड़ें", "chat": "बात करना", "contacts": "संपर्क", diff --git a/public/locales/sp/translation.json b/public/locales/sp/translation.json index 79d7436c39..814da7334b 100644 --- a/public/locales/sp/translation.json +++ b/public/locales/sp/translation.json @@ -1265,6 +1265,7 @@ "createAdvertisement": "Crear publicidad" }, "userChat": { + "title": "Chats", "add": "Agregar", "chat": "Charlar", "search": "Buscar", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 32a4f953be..2a8a2753a8 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -1262,6 +1262,7 @@ "endOfResults": "结果结束" }, "userChat": { + "title": "聊天", "add": "添加", "chat": "聊天", "contacts": "联系方式", diff --git a/setup.ts b/setup.ts index 2a6c437fa3..2c39924be8 100644 --- a/setup.ts +++ b/setup.ts @@ -1,165 +1,48 @@ import dotenv from 'dotenv'; import fs from 'fs'; import inquirer from 'inquirer'; -import { checkConnection } from './src/setup/checkConnection/checkConnection'; -import { askForTalawaApiUrl } from './src/setup/askForTalawaApiUrl/askForTalawaApiUrl'; import { checkEnvFile } from './src/setup/checkEnvFile/checkEnvFile'; import { validateRecaptcha } from './src/setup/validateRecaptcha/validateRecaptcha'; -import { askForCustomPort } from './src/setup/askForCustomPort/askForCustomPort'; - -export async function main(): Promise<void> { - console.log('Welcome to the Talawa Admin setup! 🚀'); - - if (!fs.existsSync('.env')) { - fs.openSync('.env', 'w'); - const config = dotenv.parse(fs.readFileSync('.env.example')); - for (const key in config) { - fs.appendFileSync('.env', `${key}=${config[key]}\n`); - } - } else { - checkEnvFile(); - } - - let shouldSetCustomPort: boolean; - - if (process.env.PORT) { - console.log( - `\nCustom port for development server already exists with the value:\n${process.env.PORT}`, - ); - shouldSetCustomPort = true; - } else { - const { shouldSetCustomPortResponse } = await inquirer.prompt({ +import askAndSetDockerOption from './src/setup/askAndSetDockerOption/askAndSetDockerOption'; +import updateEnvFile from './src/setup/updateEnvFile/updateEnvFile'; +import askAndUpdatePort from './src/setup/askAndUpdatePort/askAndUpdatePort'; +import { askAndUpdateTalawaApiUrl } from './src/setup/askForDocker/askForDocker'; + +// Ask and set up reCAPTCHA +const askAndSetRecaptcha = async (): Promise<void> => { + try { + const { shouldUseRecaptcha } = await inquirer.prompt({ type: 'confirm', - name: 'shouldSetCustomPortResponse', - message: 'Would you like to set up a custom port?', + name: 'shouldUseRecaptcha', + message: 'Would you like to set up reCAPTCHA?', default: true, }); - shouldSetCustomPort = shouldSetCustomPortResponse; - } - - if (shouldSetCustomPort) { - const customPort = await askForCustomPort(); - - const port = dotenv.parse(fs.readFileSync('.env')).PORT; - - fs.readFile('.env', 'utf8', (err, data) => { - const result = data.replace(`PORT=${port}`, `PORT=${customPort}`); - fs.writeFileSync('.env', result, 'utf8'); - }); - } - - let shouldSetTalawaApiUrl: boolean; - - if (process.env.REACT_APP_TALAWA_URL) { - console.log( - `\nEndpoint for accessing talawa-api graphql service already exists with the value:\n${process.env.REACT_APP_TALAWA_URL}`, - ); - shouldSetTalawaApiUrl = true; - } else { - const { shouldSetTalawaApiUrlResponse } = await inquirer.prompt({ - type: 'confirm', - name: 'shouldSetTalawaApiUrlResponse', - message: 'Would you like to set up talawa-api endpoint?', - default: true, - }); - shouldSetTalawaApiUrl = shouldSetTalawaApiUrlResponse; - } - - if (shouldSetTalawaApiUrl) { - let isConnected = false, - endpoint = ''; - - while (!isConnected) { - endpoint = await askForTalawaApiUrl(); - const url = new URL(endpoint); - isConnected = await checkConnection(url.origin); - } - const envPath = '.env'; - const currentEnvContent = fs.readFileSync(envPath, 'utf8'); - const talawaApiUrl = dotenv.parse(currentEnvContent).REACT_APP_TALAWA_URL; - - const updatedEnvContent = currentEnvContent.replace( - `REACT_APP_TALAWA_URL=${talawaApiUrl}`, - `REACT_APP_TALAWA_URL=${endpoint}`, - ); - - fs.writeFileSync(envPath, updatedEnvContent, 'utf8'); - const websocketUrl = endpoint.replace(/^http(s)?:\/\//, 'ws$1://'); - const currentWebSocketUrl = - dotenv.parse(updatedEnvContent).REACT_APP_BACKEND_WEBSOCKET_URL; - - const finalEnvContent = updatedEnvContent.replace( - `REACT_APP_BACKEND_WEBSOCKET_URL=${currentWebSocketUrl}`, - `REACT_APP_BACKEND_WEBSOCKET_URL=${websocketUrl}`, - ); - - fs.writeFileSync(envPath, finalEnvContent, 'utf8'); - } - - const { shouldUseRecaptcha } = await inquirer.prompt({ - type: 'confirm', - name: 'shouldUseRecaptcha', - message: 'Would you like to set up ReCAPTCHA?', - default: true, - }); - - if (shouldUseRecaptcha) { - const useRecaptcha = dotenv.parse( - fs.readFileSync('.env'), - ).REACT_APP_USE_RECAPTCHA; - - fs.readFile('.env', 'utf8', (err, data) => { - const result = data.replace( - `REACT_APP_USE_RECAPTCHA=${useRecaptcha}`, - `REACT_APP_USE_RECAPTCHA=yes`, - ); - fs.writeFileSync('.env', result, 'utf8'); - }); - let shouldSetRecaptchaSiteKey: boolean; - if (process.env.REACT_APP_RECAPTCHA_SITE_KEY) { - console.log( - `\nreCAPTCHA site key already exists with the value ${process.env.REACT_APP_RECAPTCHA_SITE_KEY}`, - ); - shouldSetRecaptchaSiteKey = true; - } else { - const { shouldSetRecaptchaSiteKeyResponse } = await inquirer.prompt({ - type: 'confirm', - name: 'shouldSetRecaptchaSiteKeyResponse', - message: 'Would you like to set up a reCAPTCHA site key?', - default: true, - }); - shouldSetRecaptchaSiteKey = shouldSetRecaptchaSiteKeyResponse; - } - if (shouldSetRecaptchaSiteKey) { + if (shouldUseRecaptcha) { const { recaptchaSiteKeyInput } = await inquirer.prompt([ { type: 'input', name: 'recaptchaSiteKeyInput', message: 'Enter your reCAPTCHA site key:', - validate: async (input: string): Promise<boolean | string> => { - if (validateRecaptcha(input)) { - return true; - } - return 'Invalid reCAPTCHA site key. Please try again.'; + validate: (input: string): boolean | string => { + return ( + validateRecaptcha(input) || + 'Invalid reCAPTCHA site key. Please try again.' + ); }, }, ]); - const recaptchaSiteKey = dotenv.parse( - fs.readFileSync('.env'), - ).REACT_APP_RECAPTCHA_SITE_KEY; - - fs.readFile('.env', 'utf8', (err, data) => { - const result = data.replace( - `REACT_APP_RECAPTCHA_SITE_KEY=${recaptchaSiteKey}`, - `REACT_APP_RECAPTCHA_SITE_KEY=${recaptchaSiteKeyInput}`, - ); - fs.writeFileSync('.env', result, 'utf8'); - }); + updateEnvFile('REACT_APP_RECAPTCHA_SITE_KEY', recaptchaSiteKeyInput); } + } catch (error) { + console.error('Error setting up reCAPTCHA:', error); + throw new Error(`Failed to set up reCAPTCHA: ${(error as Error).message}`); } +}; +// Ask and set up logging errors in the console +const askAndSetLogErrors = async (): Promise<void> => { const { shouldLogErrors } = await inquirer.prompt({ type: 'confirm', name: 'shouldLogErrors', @@ -169,17 +52,37 @@ export async function main(): Promise<void> { }); if (shouldLogErrors) { - const logErrors = dotenv.parse(fs.readFileSync('.env')).ALLOW_LOGS; - - fs.readFile('.env', 'utf8', (err, data) => { - const result = data.replace(`ALLOW_LOGS=${logErrors}`, 'ALLOW_LOGS=YES'); - fs.writeFileSync('.env', result, 'utf8'); - }); + updateEnvFile('ALLOW_LOGS', 'YES'); } +}; - console.log( - '\nCongratulations! Talawa Admin has been successfully setup! 🥂🎉', - ); +// Main function to run the setup process +export async function main(): Promise<void> { + try { + console.log('Welcome to the Talawa Admin setup! 🚀'); + + checkEnvFile(); + await askAndSetDockerOption(); + const envConfig = dotenv.parse(fs.readFileSync('.env', 'utf8')); + const useDocker = envConfig.USE_DOCKER === 'YES'; + + // Only run these commands if Docker is NOT used + if (!useDocker) { + await askAndUpdatePort(); + await askAndUpdateTalawaApiUrl(); + } + + await askAndSetRecaptcha(); + await askAndSetLogErrors(); + + console.log( + '\nCongratulations! Talawa Admin has been successfully set up! 🥂🎉', + ); + } catch (error) { + console.error('\n❌ Setup failed:', error); + console.log('\nPlease try again or contact support if the issue persists.'); + process.exit(1); + } } main(); diff --git a/src/assets/css/app.css b/src/assets/css/app.css index b3a8613975..bd34d56907 100644 --- a/src/assets/css/app.css +++ b/src/assets/css/app.css @@ -3442,6 +3442,7 @@ textarea.form-control.is-invalid { } } +/* To remove the green and replace by greyish hover , make changes here */ .btn:hover { color: var(--bs-btn-hover-color); background-color: var(--bs-btn-hover-bg); @@ -14066,6 +14067,7 @@ fieldset:disabled .btn { .btn-warning, .btn-info { color: #fff; + /* isolation: isolate; */ } .btn-primary:hover, @@ -14079,8 +14081,27 @@ fieldset:disabled .btn { .btn-info:hover, .btn-info:active { color: #fff !important; + box-shadow: inset 50px 50px 40px rgba(0, 0, 0, 0.5); + background-blend-mode: multiply; + /* background-color: #6c757d ; */ + /* filter: brightness(0.85); */ } +/* .btn-primary{ + --hover-bg: #6c757d !important; +} + + +.btn-primary:hover, +.btn-primary:active{ + --hover-bg: hsl(var(--button-hue, 0), 100%, 60%) !important; +} + +.btn-primary:hover, +.btn-primary:active{ + --hover-bg: hsl(var(--button-hue, 0), 100%, 0%) !important; +} */ + .btn-outline-primary:hover, .btn-outline-primary:active, .btn-outline-secondary:hover, diff --git a/src/components/EventCalendar/EventCalendar.module.css b/src/components/EventCalendar/EventCalendar.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/EventCalendar/EventHeader.spec.tsx b/src/components/EventCalendar/EventHeader.spec.tsx index be1ba4bd78..84b8ceafec 100644 --- a/src/components/EventCalendar/EventHeader.spec.tsx +++ b/src/components/EventCalendar/EventHeader.spec.tsx @@ -69,7 +69,7 @@ describe('EventHeader Component', () => { fireEvent.click(getByTestId('eventType')); await act(async () => { - fireEvent.click(getByTestId('events')); + fireEvent.click(getByTestId('Events')); }); expect(handleChangeView).toHaveBeenCalledTimes(1); diff --git a/src/components/EventCalendar/EventHeader.tsx b/src/components/EventCalendar/EventHeader.tsx index d8f949ca97..9201e8b696 100644 --- a/src/components/EventCalendar/EventHeader.tsx +++ b/src/components/EventCalendar/EventHeader.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Search } from '@mui/icons-material'; import styles from '../../style/app.module.css'; import { ViewType } from '../../screens/OrganizationEvents/OrganizationEvents'; import { useTranslation } from 'react-i18next'; +import SortingButton from 'subComponents/SortingButton'; /** * Props for the EventHeader component. @@ -63,56 +64,30 @@ function eventHeader({ </div> <div className={styles.flex_grow}></div> <div className={styles.space}> - <div> - <Dropdown - onSelect={handleChangeView} - className={styles.selectTypeEventHeader} - > - <Dropdown.Toggle - id="dropdown-basic" - className={styles.dropdown} - data-testid="selectViewType" - > - {viewType} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - eventKey={ViewType.MONTH} - data-testid="selectMonth" - > - {ViewType.MONTH} - </Dropdown.Item> - <Dropdown.Item eventKey={ViewType.DAY} data-testid="selectDay"> - {ViewType.DAY} - </Dropdown.Item> - <Dropdown.Item - eventKey={ViewType.YEAR} - data-testid="selectYear" - > - {ViewType.YEAR} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </div> - <div> - <Dropdown className={styles.selectTypeEventHeader}> - <Dropdown.Toggle - id="dropdown-basic" - className={styles.dropdown} - data-testid="eventType" - > - {t('eventType')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item eventKey="Events" data-testid="events"> - Events - </Dropdown.Item> - <Dropdown.Item eventKey="Workshops" data-testid="workshop"> - Workshops - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </div> + <SortingButton + title={t('viewType')} + sortingOptions={[ + { label: ViewType.MONTH, value: 'selectMonth' }, + { label: ViewType.DAY, value: 'selectDay' }, + { label: ViewType.YEAR, value: 'selectYear' }, + ]} + selectedOption={viewType} + onSortChange={handleChangeView} + dataTestIdPrefix="selectViewType" + className={styles.dropdown} + /> + <SortingButton + title={t('eventType')} + sortingOptions={[ + { label: 'Events', value: 'Events' }, + { label: 'Workshops', value: 'Workshops' }, + ]} + selectedOption={t('eventType')} + onSortChange={(value) => console.log(`Selected: ${value}`)} + dataTestIdPrefix="eventType" + className={styles.dropdown} + buttonLabel={t('eventType')} + /> <Button variant="success" className={styles.createButtonEventHeader} diff --git a/src/components/EventListCard/EventListCard.tsx b/src/components/EventListCard/EventListCard.tsx index dba2ef4541..759e432628 100644 --- a/src/components/EventListCard/EventListCard.tsx +++ b/src/components/EventListCard/EventListCard.tsx @@ -75,9 +75,6 @@ function eventListCard(props: InterfaceEventListCardProps): JSX.Element { <> <div className={styles.cardsEventListCard} - style={{ - backgroundColor: '#d9d9d9', - }} onClick={showViewModal} data-testid="card" > @@ -98,5 +95,5 @@ function eventListCard(props: InterfaceEventListCardProps): JSX.Element { </> ); } -export {}; + export default eventListCard; diff --git a/src/components/EventManagement/EventAttendance/EventAttendance.spec.tsx b/src/components/EventManagement/EventAttendance/EventAttendance.spec.tsx index bff1553cc0..7dc767e232 100644 --- a/src/components/EventManagement/EventAttendance/EventAttendance.spec.tsx +++ b/src/components/EventManagement/EventAttendance/EventAttendance.spec.tsx @@ -103,11 +103,14 @@ describe('Event Attendance Component', () => { await wait(); const sortDropdown = screen.getByTestId('sort-dropdown'); - userEvent.click(sortDropdown); - userEvent.click(screen.getByText('Sort')); + userEvent.click(sortDropdown); // Open the sort dropdown + + const sortOption = screen.getByText('Ascending'); // Assuming 'Ascending' is the option you choose for sorting + userEvent.click(sortOption); await waitFor(() => { const attendees = screen.getAllByTestId('attendee-name-0'); + // Check if the first attendee is 'Bruce Garza' after sorting expect(attendees[0]).toHaveTextContent('Bruce Garza'); }); }); @@ -117,10 +120,14 @@ describe('Event Attendance Component', () => { await wait(); - userEvent.click(screen.getByText('Filter: All')); - userEvent.click(screen.getByText('This Month')); + const filterDropdown = screen.getByTestId('filter-dropdown'); + userEvent.click(filterDropdown); // Open the filter dropdown + + const filterOption = screen.getByText('This Month'); // Assuming 'This Month' is the option you choose for filtering + userEvent.click(filterOption); await waitFor(() => { + // Check if the message 'Attendees not Found' is displayed expect(screen.getByText('Attendees not Found')).toBeInTheDocument(); }); }); diff --git a/src/components/EventManagement/EventAttendance/EventAttendance.tsx b/src/components/EventManagement/EventAttendance/EventAttendance.tsx index 57ce357835..3f3f140497 100644 --- a/src/components/EventManagement/EventAttendance/EventAttendance.tsx +++ b/src/components/EventManagement/EventAttendance/EventAttendance.tsx @@ -9,13 +9,7 @@ import { TableRow, Tooltip, } from '@mui/material'; -import { - Button, - Dropdown, - DropdownButton, - Table, - FormControl, -} from 'react-bootstrap'; +import { Button, Table, FormControl } from 'react-bootstrap'; import styles from '../../../style/app.module.css'; import { useLazyQuery } from '@apollo/client'; import { EVENT_ATTENDEES } from 'GraphQl/Queries/Queries'; @@ -24,11 +18,14 @@ import { useTranslation } from 'react-i18next'; import { AttendanceStatisticsModal } from './EventStatistics'; import AttendedEventList from './AttendedEventList'; import type { InterfaceMember } from './InterfaceEvents'; +import SortingButton from 'subComponents/SortingButton'; + enum FilterPeriod { ThisMonth = 'This Month', ThisYear = 'This Year', All = 'All', } + /** * Component to manage and display event attendance information * Includes filtering and sorting functionality for attendees @@ -153,18 +150,16 @@ function EventAttendance(): JSX.Element { memberData={filteredAttendees} t={t} /> - <div className="d-flex justify-content-between"> - <div className="d-flex w-100"> - <Button - className={`border-1 bg-white text-success ${styles.actionBtn}`} - onClick={showModal} - data-testid="stats-modal" - > - {t('historical_statistics')} - </Button> - </div> - <div className="d-flex justify-content-between align-items-end w-100 "> - <div className={styles.input}> + <div className="d-flex justify-content-between align-items-center mb-3"> + <Button + className={`border-1 bg-white text-success ${styles.actionBtn}`} + onClick={showModal} + data-testid="stats-modal" + > + {t('historical_statistics')} + </Button> + <div className="d-flex align-items-center"> + <div className={`${styles.input} me-3`}> <FormControl type="text" id="posttitle" @@ -182,54 +177,37 @@ function EventAttendance(): JSX.Element { <Search size={20} /> </Button> </div> - - <DropdownButton - data-testid="filter-dropdown" - className={`border-1 mx-4`} - title={ - <> - <img - src="/images/svg/up-down.svg" - width={20} - height={20} - alt="Sort" - className={styles.sortImg} - /> - <span className="ms-2">Filter: {filteringBy}</span> - </> - } - onSelect={(eventKey) => setFilteringBy(eventKey as FilterPeriod)} - > - <Dropdown.Item eventKey="This Month">This Month</Dropdown.Item> - <Dropdown.Item eventKey="This Year">This Year</Dropdown.Item> - <Dropdown.Item eventKey="All">All</Dropdown.Item> - </DropdownButton> - <DropdownButton - data-testid="sort-dropdown" - className={`border-1 `} - title={ - <> - <img - src="/images/svg/up-down.svg" - width={20} - height={20} - alt="Sort" - className={styles.sortImg} - /> - <span className="ms-2">Sort</span> - </> - } - onSelect={ - /*istanbul ignore next*/ - (eventKey) => setSortOrder(eventKey as 'ascending' | 'descending') + <SortingButton + title="Filter" + sortingOptions={[ + { + label: FilterPeriod.ThisMonth, + value: FilterPeriod.ThisMonth, + }, + { label: FilterPeriod.ThisYear, value: FilterPeriod.ThisYear }, + { label: FilterPeriod.All, value: 'Filter: All' }, + ]} + selectedOption={filteringBy} + onSortChange={(value) => setFilteringBy(value as FilterPeriod)} + dataTestIdPrefix="filter-dropdown" + className={`${styles.dropdown} mx-4`} + buttonLabel="Filter" + /> + <SortingButton + title="Sort" + sortingOptions={[ + { label: 'Ascending', value: 'ascending' }, + { label: 'Descending', value: 'descending' }, + ]} + selectedOption={sortOrder} + onSortChange={(value) => + setSortOrder(value as 'ascending' | 'descending') } - > - <Dropdown.Item eventKey="ascending">Ascending</Dropdown.Item> - <Dropdown.Item eventKey="descending">Descending</Dropdown.Item> - </DropdownButton> + dataTestIdPrefix="sort-dropdown" + buttonLabel="Sort" + /> </div> </div> - {/* <h3>{totalMembers}</h3> */} <TableContainer component={Paper} className="mt-3"> <Table aria-label={t('event_attendance_table')} role="grid"> <TableHead> diff --git a/src/components/EventStats/Statistics/AverageRating.tsx b/src/components/EventStats/Statistics/AverageRating.tsx index 9f1a157e01..f2e22338ec 100644 --- a/src/components/EventStats/Statistics/AverageRating.tsx +++ b/src/components/EventStats/Statistics/AverageRating.tsx @@ -4,7 +4,7 @@ import Rating from '@mui/material/Rating'; import FavoriteIcon from '@mui/icons-material/Favorite'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; import Typography from '@mui/material/Typography'; - +import styles from '../../../style/app.module.css'; // Props for the AverageRating component type ModalPropType = { data: { @@ -33,7 +33,7 @@ type FeedbackType = { export const AverageRating = ({ data }: ModalPropType): JSX.Element => { return ( <> - <Card style={{ width: '300px' }}> + <Card className={styles.cardContainer}> <Card.Body> <Card.Title> <h4>Average Review Score</h4> @@ -50,13 +50,9 @@ export const AverageRating = ({ data }: ModalPropType): JSX.Element => { icon={<FavoriteIcon fontSize="inherit" />} size="medium" emptyIcon={<FavoriteBorderIcon fontSize="inherit" />} - sx={{ - '& .MuiRating-iconFilled': { - color: '#ff6d75', // Color for filled stars - }, - '& .MuiRating-iconHover': { - color: '#ff3d47', // Color for star on hover - }, + classes={{ + iconFilled: styles.ratingFilled, + iconHover: styles.ratingHover, }} /> </Card.Body> diff --git a/src/components/LeftDrawer/LeftDrawer.tsx b/src/components/LeftDrawer/LeftDrawer.tsx index eabf9722f8..1ef8192ae1 100644 --- a/src/components/LeftDrawer/LeftDrawer.tsx +++ b/src/components/LeftDrawer/LeftDrawer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import Button from 'react-bootstrap/Button'; import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router-dom'; @@ -31,6 +31,12 @@ const leftDrawer = ({ const { getItem } = useLocalStorage(); const superAdmin = getItem('SuperAdmin'); + useEffect(() => { + if (hideDrawer === null) { + setHideDrawer(false); + } + }, []); + /** * Handles link click to hide the drawer on smaller screens. */ diff --git a/src/components/OrgListCard/OrgListCard.tsx b/src/components/OrgListCard/OrgListCard.tsx index 10365d2364..cf651e9dfe 100644 --- a/src/components/OrgListCard/OrgListCard.tsx +++ b/src/components/OrgListCard/OrgListCard.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import TruncatedText from './TruncatedText'; +// import {useState} from 'react'; import FlaskIcon from 'assets/svgs/flask.svg?react'; import Button from 'react-bootstrap/Button'; import { useTranslation } from 'react-i18next'; @@ -94,17 +96,18 @@ function orgListCard(props: InterfaceOrgListCardProps): JSX.Element { <h4 className={`${styles.orgName} fw-semibold`}>{name}</h4> </Tooltip> {/* Description of the organization */} - <h6 className={`${styles.orgdesc} fw-semibold`}> - <span>{userData?.organizations[0].description}</span> - </h6> + <div className={`${styles.orgdesc} fw-semibold`}> + <TruncatedText + text={userData?.organizations[0]?.description || ''} + /> + </div> + {/* Display the organization address if available */} - {address && address.city && ( + {address?.city && ( <div className={styles.address}> - <h6 className="text-secondary"> - <span className="address-line">{address.line1}, </span> - <span className="address-line">{address.city}, </span> - <span className="address-line">{address.countryCode}</span> - </h6> + <TruncatedText + text={`${address?.line1}, ${address?.city}, ${address?.countryCode}`} + /> </div> )} {/* Display the number of admins and members */} diff --git a/src/components/OrgListCard/TruncatedText.tsx b/src/components/OrgListCard/TruncatedText.tsx new file mode 100644 index 0000000000..94617178cb --- /dev/null +++ b/src/components/OrgListCard/TruncatedText.tsx @@ -0,0 +1,80 @@ +import React, { useState, useEffect, useRef } from 'react'; +import useDebounce from './useDebounce'; + +/** + * Props for the `TruncatedText` component. + * + * Includes the text to be displayed and an optional maximum width override. + */ +interface InterfaceTruncatedTextProps { + /** The full text to display. It may be truncated if it exceeds the maximum width. */ + text: string; + /** Optional: Override the maximum width for truncation. */ + maxWidthOverride?: number; +} + +/** + * A React functional component that displays text and truncates it with an ellipsis (`...`) + * if the text exceeds the available width or the `maxWidthOverride` value. + * + * The component adjusts the truncation dynamically based on the available space + * or the `maxWidthOverride` value. It also listens for window resize events to reapply truncation. + * + * @param props - The props for the component. + * @returns A heading element (`<h6>`) containing the truncated or full text. + * + * @example + * ```tsx + * <TruncatedText text="This is a very long text" maxWidthOverride={150} /> + * ``` + */ +const TruncatedText: React.FC<InterfaceTruncatedTextProps> = ({ + text, + maxWidthOverride, +}) => { + const [truncatedText, setTruncatedText] = useState<string>(''); + const textRef = useRef<HTMLHeadingElement>(null); + + const { debouncedCallback, cancel } = useDebounce(() => { + truncateText(); + }, 100); + + /** + * Truncate the text based on the available width or the `maxWidthOverride` value. + */ + const truncateText = (): void => { + const element = textRef.current; + if (element) { + const maxWidth = maxWidthOverride || element.offsetWidth; + const fullText = text; + + const computedStyle = getComputedStyle(element); + const fontSize = parseFloat(computedStyle.fontSize); + const charPerPx = 0.065 + fontSize * 0.002; + const maxChars = Math.floor(maxWidth * charPerPx); + + setTruncatedText( + fullText.length > maxChars + ? `${fullText.slice(0, maxChars - 3)}...` + : fullText, + ); + } + }; + + useEffect(() => { + truncateText(); + window.addEventListener('resize', debouncedCallback); + return () => { + cancel(); + window.removeEventListener('resize', debouncedCallback); + }; + }, [text, maxWidthOverride, debouncedCallback, cancel]); + + return ( + <h6 ref={textRef} className="text-secondary"> + {truncatedText} + </h6> + ); +}; + +export default TruncatedText; diff --git a/src/components/OrgListCard/useDebounce.tsx b/src/components/OrgListCard/useDebounce.tsx new file mode 100644 index 0000000000..8ad30386e0 --- /dev/null +++ b/src/components/OrgListCard/useDebounce.tsx @@ -0,0 +1,42 @@ +import { useRef, useCallback } from 'react'; + +/** + * A custom React hook for debouncing a callback function. + * It delays the execution of the callback until after a specified delay has elapsed + * since the last time the debounced function was invoked. + * + * @param callback - The function to debounce. + * @param delay - The delay in milliseconds to wait before invoking the callback. + * @returns An object with the `debouncedCallback` function and a `cancel` method to clear the timeout. + */ +function useDebounce<T extends (...args: unknown[]) => void>( + callback: T, + delay: number, +): { debouncedCallback: (...args: Parameters<T>) => void; cancel: () => void } { + const timeoutRef = useRef<number | undefined>(); + + /** + * The debounced version of the provided callback function. + * This function resets the debounce timer on each call, ensuring the callback + * is invoked only after the specified delay has elapsed without further calls. + * + * @param args - The arguments to pass to the callback when invoked. + */ + const debouncedCallback = useCallback( + (...args: Parameters<T>) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay], + ); + + const cancel = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, []); + + return { debouncedCallback, cancel }; +} + +export default useDebounce; diff --git a/src/components/OrgPeopleListCard/OrgPeopleListCard.spec.tsx b/src/components/OrgPeopleListCard/OrgPeopleListCard.spec.tsx index 3023c82319..485ad1ae11 100644 --- a/src/components/OrgPeopleListCard/OrgPeopleListCard.spec.tsx +++ b/src/components/OrgPeopleListCard/OrgPeopleListCard.spec.tsx @@ -83,6 +83,45 @@ describe('Testing Organization People List Card', () => { }); }); + const NULL_DATA_MOCKS = [ + { + request: { + query: REMOVE_MEMBER_MUTATION, + variables: { + userid: '1', + orgid: '456', + }, + }, + result: { + data: null, + }, + }, + ]; + + test('should handle null data response from mutation', async () => { + const link = new StaticMockLink(NULL_DATA_MOCKS, true); + + render( + <MockedProvider addTypename={false} link={link}> + <BrowserRouter> + <I18nextProvider i18n={i18nForTest}> + <OrgPeopleListCard {...props} /> + </I18nextProvider> + </BrowserRouter> + </MockedProvider>, + ); + + // Click remove button + const removeButton = screen.getByTestId('removeMemberBtn'); + await userEvent.click(removeButton); + + // Verify that success toast and toggleRemoveModal were not called + await waitFor(() => { + expect(toast.success).not.toHaveBeenCalled(); + expect(props.toggleRemoveModal).not.toHaveBeenCalled(); + }); + }); + test('should render modal and handle successful member removal', async () => { const link = new StaticMockLink(MOCKS, true); @@ -123,14 +162,7 @@ describe('Testing Organization People List Card', () => { await waitFor( () => { expect(toast.success).toHaveBeenCalled(); - }, - { timeout: 3000 }, - ); - - // Check if page reload is triggered after delay - await waitFor( - () => { - expect(window.location.reload).toHaveBeenCalled(); + expect(props.toggleRemoveModal).toHaveBeenCalled(); }, { timeout: 3000 }, ); diff --git a/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx b/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx index 8a028227f1..e7171bff71 100644 --- a/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx +++ b/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx @@ -55,12 +55,9 @@ function orgPeopleListCard( orgid: currentUrl, }, }); - // If the mutation is successful, show a success message and reload the page if (data) { toast.success(t('memberRemoved') as string); - setTimeout(() => { - window.location.reload(); - }, 2000); + props.toggleRemoveModal(); } } catch (error: unknown) { errorHandler(t, error); diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx index 27eec94851..90c2a105ce 100644 --- a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx @@ -125,9 +125,9 @@ describe('Testing Organisation Action Item Categories', () => { // Filter by All fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + expect(screen.getByTestId('all')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusAll')); + fireEvent.click(screen.getByTestId('all')); await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); @@ -137,9 +137,9 @@ describe('Testing Organisation Action Item Categories', () => { // Filter by Disabled fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusDisabled')).toBeInTheDocument(); + expect(screen.getByTestId('disabled')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusDisabled')); + fireEvent.click(screen.getByTestId('disabled')); await waitFor(() => { expect(screen.queryByText('Category 1')).toBeNull(); expect(screen.getByText('Category 2')).toBeInTheDocument(); @@ -154,9 +154,9 @@ describe('Testing Organisation Action Item Categories', () => { fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusActive')).toBeInTheDocument(); + expect(screen.getByTestId('active')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusActive')); + fireEvent.click(screen.getByTestId('active')); await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); expect(screen.queryByText('Category 2')).toBeNull(); diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx index 3f1001c88b..a1f31e6da1 100644 --- a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import styles from '../../../style/app.module.css'; import { useTranslation } from 'react-i18next'; @@ -8,13 +8,7 @@ import { useQuery } from '@apollo/client'; import { ACTION_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/Queries'; import type { InterfaceActionItemCategoryInfo } from 'utils/interfaces'; import Loader from 'components/Loader/Loader'; -import { - Circle, - Search, - Sort, - WarningAmberRounded, - FilterAltOutlined, -} from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import { DataGrid, type GridCellParams, @@ -23,6 +17,7 @@ import { import dayjs from 'dayjs'; import { Chip, Stack } from '@mui/material'; import CategoryModal from './CategoryModal'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { SAME = 'same', @@ -311,63 +306,47 @@ const OrgActionItemCategories: FC<InterfaceActionItemCategoryProps> = ({ </div> <div className="d-flex gap-4 mb-1"> <div className="d-flex justify-space-between align-items-center gap-4"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {tCommon('createdLatest')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {tCommon('createdEarliest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="filter" - > - <FilterAltOutlined className={'me-1'} /> - {t('status')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setStatus(null)} - data-testid="statusAll" - > - {tCommon('all')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setStatus(CategoryStatus.Active)} - data-testid="statusActive" - > - {tCommon('active')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setStatus(CategoryStatus.Disabled)} - data-testid="statusDisabled" - > - {tCommon('disabled')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + title={tCommon('sort')} + sortingOptions={[ + { label: tCommon('createdLatest'), value: 'createdAt_DESC' }, + { label: tCommon('createdEarliest'), value: 'createdAt_ASC' }, + ]} + selectedOption={ + sortBy === 'createdAt_DESC' + ? tCommon('createdLatest') + : tCommon('createdEarliest') + } + onSortChange={(value) => + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + className={styles.dropdown} + /> + <SortingButton + title={t('status')} + sortingOptions={[ + { label: tCommon('all'), value: 'all' }, + { label: tCommon('active'), value: CategoryStatus.Active }, + { label: tCommon('disabled'), value: CategoryStatus.Disabled }, + ]} + selectedOption={ + status === null + ? tCommon('all') + : status === CategoryStatus.Active + ? tCommon('active') + : tCommon('disabled') + } + onSortChange={(value) => + setStatus(value === 'all' ? null : (value as CategoryStatus)) + } + dataTestIdPrefix="filter" + buttonLabel={t('status')} + className={styles.dropdown} + /> </div> + <div> <Button variant="success" diff --git a/src/components/RequestsTableItem/RequestsTableItem.spec.tsx b/src/components/RequestsTableItem/RequestsTableItem.spec.tsx index b5194fcdce..737b0fa926 100644 --- a/src/components/RequestsTableItem/RequestsTableItem.spec.tsx +++ b/src/components/RequestsTableItem/RequestsTableItem.spec.tsx @@ -12,7 +12,6 @@ const link = new StaticMockLink(MOCKS, true); import useLocalStorage from 'utils/useLocalstorage'; import userEvent from '@testing-library/user-event'; import { vi } from 'vitest'; - const { setItem } = useLocalStorage(); async function wait(ms = 100): Promise<void> { @@ -23,7 +22,6 @@ async function wait(ms = 100): Promise<void> { }); } const resetAndRefetchMock = vi.fn(); - vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), diff --git a/src/components/RequestsTableItem/RequestsTableItem.tsx b/src/components/RequestsTableItem/RequestsTableItem.tsx index 07feb5d289..cd688a2966 100644 --- a/src/components/RequestsTableItem/RequestsTableItem.tsx +++ b/src/components/RequestsTableItem/RequestsTableItem.tsx @@ -65,13 +65,11 @@ const RequestsTableItem = (props: Props): JSX.Element => { id: membershipRequestId, }, }); - /* istanbul ignore next */ if (data) { toast.success(t('acceptedSuccessfully') as string); resetAndRefetch(); } } catch (error: unknown) { - /* istanbul ignore next */ errorHandler(t, error); } }; @@ -93,13 +91,11 @@ const RequestsTableItem = (props: Props): JSX.Element => { id: membershipRequestId, }, }); - /* istanbul ignore next */ if (data) { toast.success(t('rejectedSuccessfully') as string); resetAndRefetch(); } } catch (error: unknown) { - /* istanbul ignore next */ errorHandler(t, error); } }; diff --git a/src/components/UsersTableItem/UsersTableItem.tsx b/src/components/UsersTableItem/UsersTableItem.tsx index 9e94b8a9f5..6da3c1d6f4 100644 --- a/src/components/UsersTableItem/UsersTableItem.tsx +++ b/src/components/UsersTableItem/UsersTableItem.tsx @@ -161,6 +161,7 @@ const UsersTableItem = (props: Props): JSX.Element => { <td>{user.user.email}</td> <td> <Button + className="btn btn-success" onClick={() => setShowJoinedOrganizations(true)} data-testid={`showJoinedOrgsBtn${user.user._id}`} > diff --git a/src/components/Venues/VenueModal.spec.tsx b/src/components/Venues/VenueModal.spec.tsx index 45560c40cb..c840b6de53 100644 --- a/src/components/Venues/VenueModal.spec.tsx +++ b/src/components/Venues/VenueModal.spec.tsx @@ -292,3 +292,88 @@ describe('VenueModal', () => { ); }); }); + +describe('VenueModal with error scenarios', () => { + test('displays error toast when creating a venue fails', async () => { + const errorMocks = [ + { + request: { + query: CREATE_VENUE_MUTATION, + variables: { + name: 'Error Venue', + description: 'This should fail', + capacity: 50, + organizationId: 'orgId', + file: '', + }, + }, + error: new Error('Failed to create venue'), + }, + ]; + + const errorLink = new StaticMockLink(errorMocks, true); + renderVenueModal(props[0], errorLink); + + const nameInput = screen.getByPlaceholderText('Enter Venue Name'); + fireEvent.change(nameInput, { target: { value: 'Error Venue' } }); + + const descriptionInput = screen.getByPlaceholderText( + 'Enter Venue Description', + ); + fireEvent.change(descriptionInput, { + target: { value: 'This should fail' }, + }); + + const capacityInput = screen.getByPlaceholderText('Enter Venue Capacity'); + fireEvent.change(capacityInput, { target: { value: 50 } }); + + const submitButton = screen.getByTestId('createVenueBtn'); + fireEvent.click(submitButton); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith('Failed to create venue'); + }); + + test('displays error toast when updating a venue fails', async () => { + const errorMocks = [ + { + request: { + query: UPDATE_VENUE_MUTATION, + variables: { + capacity: 150, + description: 'Failed update description', + file: 'image1', + id: 'venue1', + name: 'Failed Update Venue', + organizationId: 'orgId', + }, + }, + error: new Error('Failed to update venue'), + }, + ]; + + const errorLink = new StaticMockLink(errorMocks, true); + renderVenueModal(props[1], errorLink); + + const nameInput = screen.getByDisplayValue('Venue 1'); + fireEvent.change(nameInput, { target: { value: 'Failed Update Venue' } }); + + const descriptionInput = screen.getByDisplayValue( + 'Updated description for venue 1', + ); + fireEvent.change(descriptionInput, { + target: { value: 'Failed update description' }, + }); + + const capacityInput = screen.getByDisplayValue('100'); + fireEvent.change(capacityInput, { target: { value: 150 } }); + + const submitButton = screen.getByTestId('updateVenueBtn'); + fireEvent.click(submitButton); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith('Failed to update venue'); + }); +}); diff --git a/src/components/Venues/VenueModal.tsx b/src/components/Venues/VenueModal.tsx index 0e3ab48466..73aa0e49c5 100644 --- a/src/components/Venues/VenueModal.tsx +++ b/src/components/Venues/VenueModal.tsx @@ -98,7 +98,6 @@ const VenueModal = ({ ...(edit && { id: venueData?._id }), }, }); - /* istanbul ignore next */ if (data) { toast.success( edit ? (t('venueUpdated') as string) : (t('venueAdded') as string), @@ -114,7 +113,6 @@ const VenueModal = ({ setVenueImage(false); } } catch (error) { - /* istanbul ignore next */ errorHandler(t, error); } }, [ @@ -136,7 +134,6 @@ const VenueModal = ({ const clearImageInput = useCallback(() => { setFormState((prevState) => ({ ...prevState, imageURL: '' })); setVenueImage(false); - /* istanbul ignore next */ if (fileInputRef.current) { fileInputRef.current.value = ''; } @@ -236,7 +233,6 @@ const VenueModal = ({ })); setVenueImage(true); const file = e.target.files?.[0]; - /* istanbul ignore next */ if (file) { setFormState({ ...formState, diff --git a/src/screens/BlockUser/BlockUser.spec.tsx b/src/screens/BlockUser/BlockUser.spec.tsx index dff4ec955e..6f787ce71d 100644 --- a/src/screens/BlockUser/BlockUser.spec.tsx +++ b/src/screens/BlockUser/BlockUser.spec.tsx @@ -353,7 +353,7 @@ describe('Testing Block/Unblock user screen', () => { ); userEvent.click(screen.getByTestId('userFilter')); - userEvent.click(screen.getByTestId('showMembers')); + userEvent.click(screen.getByTestId('allMembers')); await wait(); expect(screen.getByTestId('unBlockUser123')).toBeInTheDocument(); @@ -383,7 +383,7 @@ describe('Testing Block/Unblock user screen', () => { </MockedProvider>, ); userEvent.click(screen.getByTestId('userFilter')); - userEvent.click(screen.getByTestId('showMembers')); + userEvent.click(screen.getByTestId('allMembers')); await wait(); @@ -415,14 +415,14 @@ describe('Testing Block/Unblock user screen', () => { ); userEvent.click(screen.getByTestId('userFilter')); - userEvent.click(screen.getByTestId('showBlockedMembers')); + userEvent.click(screen.getByTestId('blockedUsers')); await wait(); expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.queryByText('Sam Smith')).not.toBeInTheDocument(); userEvent.click(screen.getByTestId('userFilter')); - userEvent.click(screen.getByTestId('showMembers')); + userEvent.click(screen.getByTestId('allMembers')); await wait(); expect(screen.getByText('John Doe')).toBeInTheDocument(); @@ -459,7 +459,7 @@ describe('Testing Block/Unblock user screen', () => { ); userEvent.click(screen.getByTestId('userFilter')); - userEvent.click(screen.getByTestId('showMembers')); + userEvent.click(screen.getByTestId('allMembers')); await wait(); expect(screen.getByText('John Doe')).toBeInTheDocument(); @@ -508,7 +508,7 @@ describe('Testing Block/Unblock user screen', () => { ); userEvent.click(screen.getByTestId('userFilter')); - userEvent.click(screen.getByTestId('showMembers')); + userEvent.click(screen.getByTestId('allMembers')); await wait(); userEvent.click(screen.getByTestId('blockUser456')); diff --git a/src/screens/BlockUser/BlockUser.tsx b/src/screens/BlockUser/BlockUser.tsx index ddea104662..b7bee8fbfa 100644 --- a/src/screens/BlockUser/BlockUser.tsx +++ b/src/screens/BlockUser/BlockUser.tsx @@ -1,11 +1,10 @@ import { useMutation, useQuery } from '@apollo/client'; import React, { useEffect, useState, useCallback } from 'react'; -import { Dropdown, Form, Table } from 'react-bootstrap'; +import { Form, Table } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; import { toast } from 'react-toastify'; import { Search } from '@mui/icons-material'; -import SortIcon from '@mui/icons-material/Sort'; import { BLOCK_USER_MUTATION, UNBLOCK_USER_MUTATION, @@ -16,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import { errorHandler } from 'utils/errorHandler'; import styles from '../../style/app.module.css'; import { useParams } from 'react-router-dom'; +import SortingButton from 'subComponents/SortingButton'; interface InterfaceMember { _id: string; @@ -216,66 +216,39 @@ const Requests = (): JSX.Element => { </div> <div className={styles.btnsBlockBlockAndUnblock}> <div className={styles.largeBtnsWrapper}> - {/* Dropdown for filtering members */} - <Dropdown aria-expanded="false" title="Sort organizations"> - <Dropdown.Toggle - variant="success" - data-testid="userFilter" - className={`${styles.createButton} mt-2`} - > - <SortIcon className={'me-1'} /> - {showBlockedMembers ? t('blockedUsers') : t('allMembers')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - active={!showBlockedMembers} - className={styles.dropdownItem} - data-testid="showMembers" - onClick={(): void => setShowBlockedMembers(false)} - > - {t('allMembers')} - </Dropdown.Item> - <Dropdown.Item - active={showBlockedMembers} - className={styles.dropdownItem} - data-testid="showBlockedMembers" - onClick={(): void => setShowBlockedMembers(true)} - > - {t('blockedUsers')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - {/* Dropdown for sorting by name */} - <Dropdown aria-expanded="false"> - <Dropdown.Toggle - variant="success" - data-testid="nameFilter" - className={`${styles.createButton} mt-2`} - > - <SortIcon className={'me-1'} /> - {searchByFirstName + <SortingButton + title={t('sortOrganizations')} + sortingOptions={[ + { label: t('allMembers'), value: 'allMembers' }, + { label: t('blockedUsers'), value: 'blockedUsers' }, + ]} + selectedOption={ + showBlockedMembers ? t('blockedUsers') : t('allMembers') + } + onSortChange={(value) => + setShowBlockedMembers(value === 'blockedUsers') + } + dataTestIdPrefix="userFilter" + className={`${styles.createButton} mt-2`} + /> + + <SortingButton + title={t('sortByName')} + sortingOptions={[ + { label: t('searchByFirstName'), value: 'searchByFirstName' }, + { label: t('searchByLastName'), value: 'searchByLastName' }, + ]} + selectedOption={ + searchByFirstName ? t('searchByFirstName') - : t('searchByLastName')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - active={searchByFirstName} - data-testid="searchByFirstName" - className={styles.dropdownItem} - onClick={(): void => setSearchByFirstName(true)} - > - {t('searchByFirstName')} - </Dropdown.Item> - <Dropdown.Item - active={!searchByFirstName} - className={styles.dropdownItem} - data-testid="searchByLastName" - onClick={(): void => setSearchByFirstName(false)} - > - {t('searchByLastName')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + : t('searchByLastName') + } + onSortChange={(value) => + setSearchByFirstName(value === 'searchByFirstName') + } + dataTestIdPrefix="nameFilter" + className={`${styles.createButton} mt-2`} + /> </div> </div> </div> diff --git a/src/screens/EventManagement/EventManagement.spec.tsx b/src/screens/EventManagement/EventManagement.spec.tsx new file mode 100644 index 0000000000..49b50e4b36 --- /dev/null +++ b/src/screens/EventManagement/EventManagement.spec.tsx @@ -0,0 +1,253 @@ +import React, { act } 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 i18nForTest from 'utils/i18nForTest'; +import { MemoryRouter, Route, Routes, useParams } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import EventManagement from './EventManagement'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { MOCKS_WITH_TIME } from 'components/EventManagement/Dashboard/EventDashboard.mocks'; +import useLocalStorage from 'utils/useLocalstorage'; +import { vi } from 'vitest'; +const { setItem } = useLocalStorage(); + +const mockWithTime = new StaticMockLink(MOCKS_WITH_TIME, true); + +const renderEventManagement = (): RenderResult => { + return render( + <MockedProvider + addTypename={false} + link={mockWithTime} + mocks={MOCKS_WITH_TIME} + > + <MemoryRouter initialEntries={['/event/orgId/eventId']}> + <Provider store={store}> + <I18nextProvider i18n={i18nForTest}> + <Routes> + <Route + path="/event/:orgId/:eventId" + element={<EventManagement />} + /> + <Route + path="/orglist" + element={<div data-testid="paramsError">paramsError</div>} + /> + <Route + path="/orgevents/:orgId" + element={<div data-testid="eventsScreen">eventsScreen</div>} + /> + <Route + path="/user/events/:orgId" + element={ + <div data-testid="userEventsScreen">userEventsScreen</div> + } + /> + </Routes> + </I18nextProvider> + </Provider> + </MemoryRouter> + </MockedProvider>, + ); +}; + +describe('Event Management', () => { + beforeAll(() => { + vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: vi.fn(), + }; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + describe('Navigation Tests', () => { + beforeEach(() => { + vi.mocked(useParams).mockReturnValue({ + orgId: 'orgId', + eventId: 'eventId', + }); + }); + + it('Testing back button navigation when userType is SuperAdmin', async () => { + setItem('SuperAdmin', true); + renderEventManagement(); + + const backButton = screen.getByTestId('backBtn'); + userEvent.click(backButton); + await waitFor(() => { + const eventsScreen = screen.getByTestId('eventsScreen'); + expect(eventsScreen).toBeInTheDocument(); + }); + }); + + it('Testing back button navigation when userType is USER', async () => { + setItem('SuperAdmin', false); + setItem('AdminFor', []); + + renderEventManagement(); + + const backButton = screen.getByTestId('backBtn'); + userEvent.click(backButton); + + await waitFor(() => { + const userEventsScreen = screen.getByTestId('userEventsScreen'); + expect(userEventsScreen).toBeInTheDocument(); + }); + }); + + it('Testing back button navigation when userType is ADMIN', async () => { + setItem('SuperAdmin', false); + setItem('AdminFor', ['someOrg']); + + renderEventManagement(); + + const backButton = screen.getByTestId('backBtn'); + userEvent.click(backButton); + + await waitFor(() => { + const eventsScreen = screen.getByTestId('eventsScreen'); + expect(eventsScreen).toBeInTheDocument(); + }); + }); + it('redirects to orglist when params are missing', async () => { + vi.mocked(useParams).mockReturnValue({}); + + renderEventManagement(); + + await waitFor(() => { + const paramsError = screen.getByTestId('paramsError'); + expect(paramsError).toBeInTheDocument(); + }); + }); + }); + + describe('Tab Management Tests', () => { + beforeEach(() => { + vi.mocked(useParams).mockReturnValue({ + orgId: 'orgId', + eventId: 'event123', + }); + }); + + it('renders dashboard tab by default', async () => { + renderEventManagement(); + expect(screen.getByTestId('eventDashboardTab')).toBeInTheDocument(); + }); + + it('switches between all available tabs', async () => { + renderEventManagement(); + + const tabsToTest = [ + { button: 'registrantsBtn', tab: 'eventRegistrantsTab' }, + { button: 'attendanceBtn', tab: 'eventAttendanceTab' }, + { button: 'actionsBtn', tab: 'eventActionsTab' }, + { button: 'agendasBtn', tab: 'eventAgendasTab' }, + { button: 'statisticsBtn', tab: 'eventStatsTab' }, + { button: 'volunteersBtn', tab: 'eventVolunteersTab' }, + ]; + + for (const { button, tab } of tabsToTest) { + userEvent.click(screen.getByTestId(button)); + expect(screen.getByTestId(tab)).toBeInTheDocument(); + } + }); + + it('returns dashboard tab for an invalid tab selection', async () => { + const setTab = vi.fn(); + const useStateSpy = vi.spyOn(React, 'useState'); + useStateSpy.mockReturnValueOnce(['invalid', setTab]); + await act(async () => { + renderEventManagement(); + }); + + expect(screen.queryByTestId('eventDashboardTab')).toBeInTheDocument(); + expect( + screen.queryByTestId('eventRegistrantsTab'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('eventAttendanceTab'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventActionsTab')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('eventVolunteersTab'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventAgendasTab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventStatsTab')).not.toBeInTheDocument(); + }); + }); + + describe('Responsive Dropdown Tests', () => { + beforeEach(() => { + vi.mocked(useParams).mockReturnValue({ + orgId: 'orgId', + eventId: 'event123', + }); + }); + + it('renders dropdown with all options', async () => { + await act(async () => { + renderEventManagement(); + }); + + const dropdownContainer = screen.getByTestId('tabsDropdownContainer'); + expect(dropdownContainer).toBeInTheDocument(); + + await act(async () => { + userEvent.click(screen.getByTestId('tabsDropdownToggle')); + }); + + const tabOptions = [ + 'dashboard', + 'registrants', + 'attendance', + 'agendas', + 'actions', + 'volunteers', + 'statistics', + ]; + + tabOptions.forEach((option) => { + expect(screen.getByTestId(`${option}DropdownItem`)).toBeInTheDocument(); + }); + }); + + it('switches tabs through dropdown selection', async () => { + await act(async () => { + renderEventManagement(); + }); + await act(async () => { + userEvent.click(screen.getByTestId('tabsDropdownToggle')); + }); + + const tabOptions = [ + 'dashboard', + 'registrants', + 'attendance', + 'agendas', + 'actions', + 'volunteers', + 'statistics', + ]; + + for (const option of tabOptions) { + act(() => { + userEvent.click(screen.getByTestId(`${option}DropdownItem`)); + }); + + expect(screen.getByTestId(`${option}DropdownItem`)).toHaveClass( + 'text-secondary', + ); + } + }); + }); +}); diff --git a/src/screens/EventManagement/EventManagement.test.tsx b/src/screens/EventManagement/EventManagement.test.tsx deleted file mode 100644 index a119caad42..0000000000 --- a/src/screens/EventManagement/EventManagement.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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 i18nForTest from 'utils/i18nForTest'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import { store } from 'state/store'; -import EventManagement from './EventManagement'; -import userEvent from '@testing-library/user-event'; -import { StaticMockLink } from 'utils/StaticMockLink'; -import { MOCKS_WITH_TIME } from 'components/EventManagement/Dashboard/EventDashboard.mocks'; -import useLocalStorage from 'utils/useLocalstorage'; -const { setItem } = useLocalStorage(); - -const mockWithTime = new StaticMockLink(MOCKS_WITH_TIME, true); - -const renderEventManagement = (): RenderResult => { - return render( - <MockedProvider addTypename={false} link={mockWithTime}> - <MemoryRouter initialEntries={['/event/orgId/eventId']}> - <Provider store={store}> - <I18nextProvider i18n={i18nForTest}> - <Routes> - <Route - path="/event/:orgId/:eventId" - element={<EventManagement />} - /> - <Route - path="/orglist" - element={<div data-testid="paramsError">paramsError</div>} - /> - <Route - path="/orgevents/:orgId" - element={<div data-testid="eventsScreen">eventsScreen</div>} - /> - <Route - path="/user/events/:orgId" - element={ - <div data-testid="userEventsScreen">userEventsScreen</div> - } - /> - </Routes> - </I18nextProvider> - </Provider> - </MemoryRouter> - </MockedProvider>, - ); -}; - -describe('Event Management', () => { - beforeAll(() => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), - })); - }); - - afterAll(() => { - jest.clearAllMocks(); - }); - - test('Testing back button navigation when userType is SuperAdmin', async () => { - setItem('SuperAdmin', true); - renderEventManagement(); - - const backButton = screen.getByTestId('backBtn'); - userEvent.click(backButton); - await waitFor(() => { - const eventsScreen = screen.getByTestId('eventsScreen'); - expect(eventsScreen).toBeInTheDocument(); - }); - }); - - test('Testing event management tab switching', async () => { - renderEventManagement(); - - const registrantsButton = screen.getByTestId('registrantsBtn'); - userEvent.click(registrantsButton); - - const registrantsTab = screen.getByTestId('eventRegistrantsTab'); - expect(registrantsTab).toBeInTheDocument(); - const eventAttendanceButton = screen.getByTestId('attendanceBtn'); - userEvent.click(eventAttendanceButton); - const eventAttendanceTab = screen.getByTestId('eventAttendanceTab'); - expect(eventAttendanceTab).toBeInTheDocument(); - const eventActionsButton = screen.getByTestId('actionsBtn'); - userEvent.click(eventActionsButton); - - const eventActionsTab = screen.getByTestId('eventActionsTab'); - expect(eventActionsTab).toBeInTheDocument(); - - const eventAgendasButton = screen.getByTestId('agendasBtn'); - userEvent.click(eventAgendasButton); - - const eventAgendasTab = screen.getByTestId('eventAgendasTab'); - expect(eventAgendasTab).toBeInTheDocument(); - - const eventStatsButton = screen.getByTestId('statisticsBtn'); - userEvent.click(eventStatsButton); - - const eventStatsTab = screen.getByTestId('eventStatsTab'); - expect(eventStatsTab).toBeInTheDocument(); - - const volunteerButton = screen.getByTestId('volunteersBtn'); - userEvent.click(volunteerButton); - - const eventVolunteersTab = screen.getByTestId('eventVolunteersTab'); - expect(eventVolunteersTab).toBeInTheDocument(); - }); - test('renders nothing when invalid tab is selected', () => { - render( - <MockedProvider addTypename={false} link={mockWithTime}> - <MemoryRouter initialEntries={['/event/orgId/eventId']}> - <Provider store={store}> - <I18nextProvider i18n={i18nForTest}> - <Routes> - <Route - path="/event/:orgId/:eventId" - element={<EventManagement />} - /> - </Routes> - </I18nextProvider> - </Provider> - </MemoryRouter> - </MockedProvider>, - ); - - // Force an invalid tab state - const setTab = jest.fn(); - React.useState = jest.fn().mockReturnValue(['invalidTab', setTab]); - - // Verify nothing is rendered - expect(screen.queryByTestId('eventDashboardTab')).toBeInTheDocument(); - expect(screen.queryByTestId('eventRegistrantsTab')).not.toBeInTheDocument(); - expect(screen.queryByTestId('eventAttendanceTab')).not.toBeInTheDocument(); - expect(screen.queryByTestId('eventActionsTab')).not.toBeInTheDocument(); - expect(screen.queryByTestId('eventVolunteersTab')).not.toBeInTheDocument(); - expect(screen.queryByTestId('eventAgendasTab')).not.toBeInTheDocument(); - expect(screen.queryByTestId('eventStatsTab')).not.toBeInTheDocument(); - }); -}); diff --git a/src/screens/EventManagement/EventManagement.tsx b/src/screens/EventManagement/EventManagement.tsx index e355ac1acc..21c23c5bb3 100644 --- a/src/screens/EventManagement/EventManagement.tsx +++ b/src/screens/EventManagement/EventManagement.tsx @@ -100,7 +100,6 @@ const EventManagement = (): JSX.Element => { // Determine user role based on local storage const superAdmin = getItem('SuperAdmin'); const adminFor = getItem('AdminFor'); - /*istanbul ignore next*/ const userRole = superAdmin ? 'SUPERADMIN' : adminFor?.length > 0 @@ -109,7 +108,6 @@ const EventManagement = (): JSX.Element => { // Extract event and organization IDs from URL parameters const { eventId, orgId } = useParams(); - /*istanbul ignore next*/ if (!eventId || !orgId) { // Redirect if event ID or organization ID is missing return <Navigate to={'/orglist'} />; @@ -159,7 +157,6 @@ const EventManagement = (): JSX.Element => { }; const handleBack = (): void => { - /*istanbul ignore next*/ if (userRole === 'USER') { navigate(`/user/events/${orgId}`); } else { @@ -203,11 +200,9 @@ const EventManagement = (): JSX.Element => { {eventDashboardTabs.map(({ value, icon }, index) => ( <Dropdown.Item key={index} - onClick={ - /* istanbul ignore next */ - () => setTab(value) - } + onClick={() => setTab(value)} className={`d-flex gap-2 ${tab === value ? 'text-secondary' : ''}`} + data-testid={`${value}DropdownItem`} > {icon} {t(value)} </Dropdown.Item> @@ -272,10 +267,9 @@ const EventManagement = (): JSX.Element => { <h2>Statistics</h2> </div> ); - /*istanbul ignore next*/ - default: - /*istanbul ignore next*/ - return null; + // no use of default here as the default tab is the dashboard selected in useState code wont reach here + // default: + // return null; } })()} </div> diff --git a/src/screens/EventVolunteers/Requests/Requests.tsx b/src/screens/EventVolunteers/Requests/Requests.tsx index b19be3d2a0..d8efd92a90 100644 --- a/src/screens/EventVolunteers/Requests/Requests.tsx +++ b/src/screens/EventVolunteers/Requests/Requests.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; import { FaXmark } from 'react-icons/fa6'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { useMutation, useQuery } from '@apollo/client'; import Loader from 'components/Loader/Loader'; @@ -20,6 +20,7 @@ import dayjs from 'dayjs'; import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; import { toast } from 'react-toastify'; import { debounce } from '@mui/material'; +import SortingButton from 'subComponents/SortingButton'; const dataGridStyle = { '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { @@ -279,30 +280,18 @@ function requests(): JSX.Element { </div> <div className="d-flex gap-3 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - className={styles.dropdowns} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('latest')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('earliest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('latest'), value: 'createdAt_DESC' }, + { label: t('earliest'), value: 'createdAt_ASC' }, + ]} + selectedOption={sortBy ?? ''} + onSortChange={(value) => + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> </div> </div> </div> diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx index 3c70b1db49..b8577acaac 100644 --- a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { useQuery } from '@apollo/client'; @@ -21,6 +21,7 @@ import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQuerie import VolunteerGroupModal from './VolunteerGroupModal'; import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; import VolunteerGroupViewModal from './VolunteerGroupViewModal'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { SAME = 'same', @@ -321,56 +322,29 @@ function volunteerGroups(): JSX.Element { </div> <div className="d-flex gap-3 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdowns} - data-testid="searchByToggle" - > - <Sort className={'me-1'} /> - {tCommon('searchBy', { item: '' })} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSearchBy('leader')} - data-testid="leader" - > - {t('leader')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSearchBy('group')} - data-testid="group" - > - {t('group')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdowns} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('volunteers_DESC')} - data-testid="volunteers_DESC" - > - {t('mostVolunteers')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('volunteers_ASC')} - data-testid="volunteers_ASC" - > - {t('leastVolunteers')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('leader'), value: 'leader' }, + { label: t('group'), value: 'group' }, + ]} + selectedOption={searchBy} + onSortChange={(value) => setSearchBy(value as 'leader' | 'group')} + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + <SortingButton + title={tCommon('sort')} + sortingOptions={[ + { label: t('mostVolunteers'), value: 'volunteers_DESC' }, + { label: t('leastVolunteers'), value: 'volunteers_ASC' }, + ]} + selectedOption={sortBy ?? ''} + onSortChange={(value) => + setSortBy(value as 'volunteers_DESC' | 'volunteers_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> </div> <div> <Button diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.spec.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.spec.tsx index be08ec61fb..cc3291274a 100644 --- a/src/screens/EventVolunteers/Volunteers/Volunteers.spec.tsx +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.spec.tsx @@ -162,9 +162,9 @@ describe('Testing Volunteers Screen', () => { // Filter by All fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + expect(screen.getByTestId('all')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusAll')); + fireEvent.click(screen.getByTestId('all')); const volunteerName = await screen.findAllByTestId('volunteerName'); expect(volunteerName).toHaveLength(2); @@ -183,9 +183,9 @@ describe('Testing Volunteers Screen', () => { // Filter by Pending fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + expect(screen.getByTestId('pending')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusPending')); + fireEvent.click(screen.getByTestId('pending')); const volunteerName = await screen.findAllByTestId('volunteerName'); expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); @@ -204,9 +204,9 @@ describe('Testing Volunteers Screen', () => { // Filter by Accepted fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusAccepted')).toBeInTheDocument(); + expect(screen.getByTestId('accepted')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusAccepted')); + fireEvent.click(screen.getByTestId('accepted')); const volunteerName = await screen.findAllByTestId('volunteerName'); expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx index 875431ad6f..1cd7a19e88 100644 --- a/src/screens/EventVolunteers/Volunteers/Volunteers.tsx +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx @@ -1,15 +1,9 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { - Circle, - FilterAltOutlined, - Search, - Sort, - WarningAmberRounded, -} from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import { useQuery } from '@apollo/client'; import Loader from 'components/Loader/Loader'; @@ -26,6 +20,7 @@ import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; import VolunteerCreateModal from './VolunteerCreateModal'; import VolunteerDeleteModal from './VolunteerDeleteModal'; import VolunteerViewModal from './VolunteerViewModal'; +import SortingButton from 'subComponents/SortingButton'; enum VolunteerStatus { All = 'all', @@ -61,7 +56,7 @@ const dataGridStyle = { }; /** - * Component for managing and displaying event volunteers realted to an event. + * Component for managing and displaying event volunteers related 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. * @@ -338,60 +333,39 @@ function volunteers(): JSX.Element { </div> <div className="d-flex gap-3 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - className={styles.dropdowns} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('hoursVolunteered_DESC')} - data-testid="hoursVolunteered_DESC" - > - {t('mostHoursVolunteered')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('hoursVolunteered_ASC')} - data-testid="hoursVolunteered_ASC" - > - {t('leastHoursVolunteered')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - className={styles.dropdowns} - data-testid="filter" - > - <FilterAltOutlined className={'me-1'} /> - {t('status')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setStatus(VolunteerStatus.All)} - data-testid="statusAll" - > - {tCommon('all')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setStatus(VolunteerStatus.Pending)} - data-testid="statusPending" - > - {tCommon('pending')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setStatus(VolunteerStatus.Accepted)} - data-testid="statusAccepted" - > - {t('accepted')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { + label: t('mostHoursVolunteered'), + value: 'hoursVolunteered_DESC', + }, + { + label: t('leastHoursVolunteered'), + value: 'hoursVolunteered_ASC', + }, + ]} + selectedOption={sortBy ?? ''} + onSortChange={(value) => + setSortBy( + value as 'hoursVolunteered_DESC' | 'hoursVolunteered_ASC', + ) + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + + <SortingButton + type="filter" + sortingOptions={[ + { label: tCommon('all'), value: VolunteerStatus.All }, + { label: tCommon('pending'), value: VolunteerStatus.Pending }, + { label: t('accepted'), value: VolunteerStatus.Accepted }, + ]} + selectedOption={status} + onSortChange={(value) => setStatus(value as VolunteerStatus)} + dataTestIdPrefix="filter" + buttonLabel={t('status')} + /> </div> <div> <Button diff --git a/src/screens/ForgotPassword/ForgotPassword.tsx b/src/screens/ForgotPassword/ForgotPassword.tsx index 00fdb77c3b..49c0a2af6e 100644 --- a/src/screens/ForgotPassword/ForgotPassword.tsx +++ b/src/screens/ForgotPassword/ForgotPassword.tsx @@ -16,7 +16,7 @@ import { Form } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; import { useTranslation } from 'react-i18next'; import { errorHandler } from 'utils/errorHandler'; -import styles from 'style/app.module.css'; +import styles from '../../style/app.module.css'; import useLocalStorage from 'utils/useLocalstorage'; /** diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.test.tsx b/src/screens/FundCampaignPledge/FundCampaignPledge.spec.tsx similarity index 92% rename from src/screens/FundCampaignPledge/FundCampaignPledge.test.tsx rename to src/screens/FundCampaignPledge/FundCampaignPledge.spec.tsx index 3fb5993775..3e6ecb74ae 100644 --- a/src/screens/FundCampaignPledge/FundCampaignPledge.test.tsx +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.spec.tsx @@ -2,13 +2,7 @@ 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 { - cleanup, - fireEvent, - render, - screen, - waitFor, -} 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'; @@ -24,18 +18,19 @@ import { } from './PledgesMocks'; import React from 'react'; import type { ApolloLink } from '@apollo/client'; +import { vi } from 'vitest'; -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - success: jest.fn(), - error: jest.fn(), + success: vi.fn(), + error: vi.fn(), }, })); -jest.mock('@mui/x-date-pickers/DateTimePicker', () => { +vi.mock('@mui/x-date-pickers/DateTimePicker', () => { return { - DateTimePicker: jest.requireActual( - '@mui/x-date-pickers/DesktopDateTimePicker', - ).DesktopDateTimePicker, + DateTimePicker: vi + .importActual('@mui/x-date-pickers/DesktopDateTimePicker') + .then((module) => module.DesktopDateTimePicker), }; }); @@ -46,6 +41,11 @@ const translations = JSON.parse( JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), ); +const mockParamsState = { + orgId: 'orgId', + fundCampaignId: 'fundCampaignId', +}; + const renderFundCampaignPledge = (link: ApolloLink): RenderResult => { return render( <MockedProvider addTypename={false} link={link}> @@ -74,18 +74,30 @@ const renderFundCampaignPledge = (link: ApolloLink): RenderResult => { }; describe('Testing Campaign Pledge Screen', () => { - beforeAll(() => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: 'orgId', fundCampaignId: 'fundCampaignId' }), - })); + const mockNavigate = vi.fn(); + + vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => ({ ...mockParamsState }), + useNavigate: () => mockNavigate, + }; + }); + + beforeEach(() => { + mockParamsState.orgId = 'orgId'; + mockParamsState.fundCampaignId = 'fundCampaignId'; }); afterAll(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should redirect to fallback URL if URL params are undefined', async () => { + mockParamsState.orgId = ''; + mockParamsState.fundCampaignId = ''; + render( <MockedProvider addTypename={false} link={link1}> <MemoryRouter initialEntries={['/fundCampaignPledge/']}> diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx index 8942265eea..323f30aa67 100644 --- a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx @@ -1,11 +1,11 @@ import { useQuery, type ApolloQueryResult } from '@apollo/client'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { FUND_CAMPAIGN_PLEDGE } from 'GraphQl/Queries/fundQueries'; import Loader from 'components/Loader/Loader'; import { Unstable_Popup as BasePopup } from '@mui/base/Unstable_Popup'; import dayjs from 'dayjs'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { Navigate, useParams } from 'react-router-dom'; import { currencySymbols } from 'utils/currency'; @@ -23,6 +23,8 @@ import type { } from 'utils/interfaces'; import ProgressBar from 'react-bootstrap/ProgressBar'; +import SortingButton from 'subComponents/SortingButton'; + interface InterfaceCampaignInfo { name: string; goal: number; @@ -56,6 +58,7 @@ const dataGridStyle = { borderRadius: '0.5rem', }, }; + const fundCampaignPledge = (): JSX.Element => { const { t } = useTranslation('translation', { keyPrefix: 'pledges', @@ -492,43 +495,26 @@ const fundCampaignPledge = (): JSX.Element => { </div> <div className="d-flex gap-4 mb-1"> <div className="d-flex justify-space-between"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="filter" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('amount_ASC')} - data-testid="amount_ASC" - > - {t('lowestAmount')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('amount_DESC')} - data-testid="amount_DESC" - > - {t('highestAmount')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('lowestAmount'), value: 'amount_ASC' }, + { label: t('highestAmount'), value: 'amount_DESC' }, + { label: t('latestEndDate'), value: 'endDate_DESC' }, + { label: t('earliestEndDate'), value: 'endDate_ASC' }, + ]} + selectedOption={sortBy ?? ''} + onSortChange={(value) => + setSortBy( + value as + | 'amount_ASC' + | 'amount_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + /> </div> <div> <Button diff --git a/src/screens/FundCampaignPledge/PledgeDeleteModal.test.tsx b/src/screens/FundCampaignPledge/PledgeDeleteModal.spec.tsx similarity index 95% rename from src/screens/FundCampaignPledge/PledgeDeleteModal.test.tsx rename to src/screens/FundCampaignPledge/PledgeDeleteModal.spec.tsx index dbaae76504..34b743973f 100644 --- a/src/screens/FundCampaignPledge/PledgeDeleteModal.test.tsx +++ b/src/screens/FundCampaignPledge/PledgeDeleteModal.spec.tsx @@ -15,11 +15,12 @@ import i18nForTest from '../../utils/i18nForTest'; import { MOCKS_DELETE_PLEDGE_ERROR, MOCKS } from './PledgesMocks'; import { StaticMockLink } from 'utils/StaticMockLink'; import { toast } from 'react-toastify'; +import { vi } from 'vitest'; -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - success: jest.fn(), - error: jest.fn(), + success: vi.fn(), + error: vi.fn(), }, })); @@ -31,7 +32,7 @@ const translations = JSON.parse( const pledgeProps: InterfaceDeletePledgeModal = { isOpen: true, - hide: jest.fn(), + hide: vi.fn(), pledge: { _id: '1', amount: 100, @@ -47,7 +48,7 @@ const pledgeProps: InterfaceDeletePledgeModal = { }, ], }, - refetchPledge: jest.fn(), + refetchPledge: vi.fn(), }; const renderPledgeDeleteModal = ( diff --git a/src/screens/Leaderboard/Leaderboard.spec.tsx b/src/screens/Leaderboard/Leaderboard.spec.tsx index b4c73bef5c..3ef73835a6 100644 --- a/src/screens/Leaderboard/Leaderboard.spec.tsx +++ b/src/screens/Leaderboard/Leaderboard.spec.tsx @@ -173,7 +173,7 @@ describe('Testing Leaderboard Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const timeFrameAll = await screen.findByTestId('timeFrameAll'); + const timeFrameAll = await screen.findByTestId('allTime'); expect(timeFrameAll).toBeInTheDocument(); fireEvent.click(timeFrameAll); @@ -196,7 +196,7 @@ describe('Testing Leaderboard Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const timeFrameWeekly = await screen.findByTestId('timeFrameWeekly'); + const timeFrameWeekly = await screen.findByTestId('weekly'); expect(timeFrameWeekly).toBeInTheDocument(); fireEvent.click(timeFrameWeekly); @@ -217,7 +217,7 @@ describe('Testing Leaderboard Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const timeFrameMonthly = await screen.findByTestId('timeFrameMonthly'); + const timeFrameMonthly = await screen.findByTestId('monthly'); expect(timeFrameMonthly).toBeInTheDocument(); fireEvent.click(timeFrameMonthly); @@ -238,7 +238,7 @@ describe('Testing Leaderboard Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const timeFrameYearly = await screen.findByTestId('timeFrameYearly'); + const timeFrameYearly = await screen.findByTestId('yearly'); expect(timeFrameYearly).toBeInTheDocument(); fireEvent.click(timeFrameYearly); diff --git a/src/screens/Leaderboard/Leaderboard.tsx b/src/screens/Leaderboard/Leaderboard.tsx index e0ea513e9c..f83d7e273d 100644 --- a/src/screens/Leaderboard/Leaderboard.tsx +++ b/src/screens/Leaderboard/Leaderboard.tsx @@ -1,14 +1,9 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useNavigate, useParams } from 'react-router-dom'; -import { - FilterAltOutlined, - Search, - Sort, - WarningAmberRounded, -} from '@mui/icons-material'; +import { Search, 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'; @@ -25,6 +20,7 @@ import { debounce, Stack } from '@mui/material'; import Avatar from 'components/Avatar/Avatar'; import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; import { useQuery } from '@apollo/client'; +import SortingButton from 'subComponents/SortingButton'; enum TimeFrame { All = 'allTime', @@ -275,68 +271,31 @@ function leaderboard(): JSX.Element { </div> <div className="d-flex gap-3 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-sort" - className={styles.dropdown} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('hours_DESC')} - data-testid="hours_DESC" - > - {t('mostHours')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('hours_ASC')} - data-testid="hours_ASC" - > - {t('leastHours')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-timeFrame" - className={styles.dropdown} - data-testid="timeFrame" - > - <FilterAltOutlined className={'me-1'} /> - {t('timeFrame')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setTimeFrame(TimeFrame.All)} - data-testid="timeFrameAll" - > - {t('allTime')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setTimeFrame(TimeFrame.Weekly)} - data-testid="timeFrameWeekly" - > - {t('weekly')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setTimeFrame(TimeFrame.Monthly)} - data-testid="timeFrameMonthly" - > - {t('monthly')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setTimeFrame(TimeFrame.Yearly)} - data-testid="timeFrameYearly" - > - {t('yearly')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('mostHours'), value: 'hours_DESC' }, + { label: t('leastHours'), value: 'hours_ASC' }, + ]} + selectedOption={sortBy} + onSortChange={(value) => + setSortBy(value as 'hours_DESC' | 'hours_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + <SortingButton + sortingOptions={[ + { label: t('allTime'), value: TimeFrame.All }, + { label: t('weekly'), value: TimeFrame.Weekly }, + { label: t('monthly'), value: TimeFrame.Monthly }, + { label: t('yearly'), value: TimeFrame.Yearly }, + ]} + selectedOption={timeFrame} + onSortChange={(value) => setTimeFrame(value as TimeFrame)} + dataTestIdPrefix="timeFrame" + buttonLabel={t('timeFrame')} + type="filter" + /> </div> </div> </div> diff --git a/src/screens/ManageTag/ManageTag.spec.tsx b/src/screens/ManageTag/ManageTag.spec.tsx index 5d86ed3c17..03c7eea393 100644 --- a/src/screens/ManageTag/ManageTag.spec.tsx +++ b/src/screens/ManageTag/ManageTag.spec.tsx @@ -50,7 +50,6 @@ vi.mock('react-toastify', () => ({ }, })); -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ vi.mock('../../components/AddPeopleToTag/AddPeopleToTag', async () => { return await import('./ManageTagMockComponents/MockAddPeopleToTag'); }); @@ -58,7 +57,6 @@ vi.mock('../../components/AddPeopleToTag/AddPeopleToTag', async () => { vi.mock('../../components/TagActions/TagActions', async () => { return await import('./ManageTagMockComponents/MockTagActions'); }); -/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const renderManageTag = (link: ApolloLink): RenderResult => { return render( @@ -372,9 +370,9 @@ describe('Manage Tag Page', () => { userEvent.click(screen.getByTestId('sortPeople')); await waitFor(() => { - expect(screen.getByTestId('oldest')).toBeInTheDocument(); + expect(screen.getByTestId('ASCENDING')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('oldest')); + userEvent.click(screen.getByTestId('ASCENDING')); // returns the tags in reverse order await waitFor(() => { @@ -389,9 +387,9 @@ describe('Manage Tag Page', () => { userEvent.click(screen.getByTestId('sortPeople')); await waitFor(() => { - expect(screen.getByTestId('latest')).toBeInTheDocument(); + expect(screen.getByTestId('DESCENDING')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('latest')); + userEvent.click(screen.getByTestId('DESCENDING')); // reverse the order again await waitFor(() => { diff --git a/src/screens/ManageTag/ManageTag.tsx b/src/screens/ManageTag/ManageTag.tsx index 38466f6f11..0832b2ab3e 100644 --- a/src/screens/ManageTag/ManageTag.tsx +++ b/src/screens/ManageTag/ManageTag.tsx @@ -2,13 +2,11 @@ import type { FormEvent } from 'react'; import React, { useEffect, useState } from 'react'; import { useMutation, useQuery } from '@apollo/client'; import { Search, 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 { Col, Form } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; -import Dropdown from 'react-bootstrap/Dropdown'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; @@ -39,6 +37,7 @@ import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScroll import EditUserTagModal from './EditUserTagModal'; import RemoveUserTagModal from './RemoveUserTagModal'; import UnassignUserTagModal from './UnassignUserTagModal'; +import SortingButton from 'subComponents/SortingButton'; /** * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/manageTag/:tagId'. @@ -378,36 +377,19 @@ function ManageTag(): JSX.Element { </Button> </div> <div className={styles.btnsBlock}> - <Dropdown - aria-expanded="false" + <SortingButton title="Sort People" - data-testid="sort" - > - <Dropdown.Toggle - variant="outline-success" - data-testid="sortPeople" - className={styles.dropdown} - > - <SortIcon className={'me-1'} /> - {assignedMemberSortOrder === 'DESCENDING' - ? tCommon('Latest') - : tCommon('Oldest')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - data-testid="latest" - onClick={() => setAssignedMemberSortOrder('DESCENDING')} - > - {tCommon('Latest')} - </Dropdown.Item> - <Dropdown.Item - data-testid="oldest" - onClick={() => setAssignedMemberSortOrder('ASCENDING')} - > - {tCommon('Oldest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + sortingOptions={[ + { label: tCommon('Latest'), value: 'DESCENDING' }, + { label: tCommon('Oldest'), value: 'ASCENDING' }, + ]} + selectedOption={assignedMemberSortOrder} + onSortChange={(value) => + setAssignedMemberSortOrder(value as SortedByType) + } + dataTestIdPrefix="sortPeople" + buttonLabel={tCommon('sort')} + /> <Button variant="success" onClick={() => redirectToSubTags(currentTagId as string)} diff --git a/src/screens/OrgList/OrgList.test.tsx b/src/screens/OrgList/OrgList.spec.tsx similarity index 98% rename from src/screens/OrgList/OrgList.test.tsx rename to src/screens/OrgList/OrgList.spec.tsx index 2b2809c7c9..80a0bf1233 100644 --- a/src/screens/OrgList/OrgList.test.tsx +++ b/src/screens/OrgList/OrgList.spec.tsx @@ -10,8 +10,6 @@ import { waitFor, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import 'jest-localstorage-mock'; -import 'jest-location-mock'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; @@ -27,9 +25,10 @@ import { MOCKS_WITH_ERROR, } from './OrgListMocks'; import { ToastContainer, toast } from 'react-toastify'; - -jest.setTimeout(30000); import useLocalStorage from 'utils/useLocalstorage'; +import { vi } from 'vitest'; + +vi.setConfig({ testTimeout: 30000 }); const { setItem } = useLocalStorage(); @@ -41,10 +40,14 @@ async function wait(ms = 100): Promise<void> { }); } +beforeEach(() => { + vi.spyOn(Storage.prototype, 'setItem'); +}); + afterEach(() => { localStorage.clear(); cleanup(); - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('Organisations Page testing as SuperAdmin', () => { @@ -162,7 +165,6 @@ describe('Organisations Page testing as SuperAdmin', () => { expect( screen.queryByText('Please create an organization through dashboard'), ).toBeInTheDocument(); - expect(window.location).toBeAt('/'); }); test('Testing Organization data is not present', async () => { @@ -451,7 +453,7 @@ describe('Organisations Page testing as SuperAdmin', () => { setItem('SuperAdmin', true); setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); - jest.spyOn(toast, 'error'); + vi.spyOn(toast, 'error'); render( <MockedProvider addTypename={false} link={link3}> <BrowserRouter> @@ -523,7 +525,7 @@ describe('Organisations Page testing as Admin', () => { fireEvent.click(sortToggle); }); - const latestOption = screen.getByTestId('latest'); + const latestOption = screen.getByTestId('Latest'); await act(async () => { fireEvent.click(latestOption); @@ -535,7 +537,7 @@ describe('Organisations Page testing as Admin', () => { fireEvent.click(sortToggle); }); - const oldestOption = await waitFor(() => screen.getByTestId('oldest')); + const oldestOption = await waitFor(() => screen.getByTestId('Earliest')); await act(async () => { fireEvent.click(oldestOption); diff --git a/src/screens/OrgList/OrgList.tsx b/src/screens/OrgList/OrgList.tsx index 2d53425d2e..880dccf98d 100644 --- a/src/screens/OrgList/OrgList.tsx +++ b/src/screens/OrgList/OrgList.tsx @@ -1,6 +1,5 @@ import { useMutation, useQuery } from '@apollo/client'; import { Search } from '@mui/icons-material'; -import SortIcon from '@mui/icons-material/Sort'; import { CREATE_ORGANIZATION_MUTATION, CREATE_SAMPLE_ORGANIZATION_MUTATION, @@ -13,7 +12,7 @@ import { import OrgListCard from 'components/OrgListCard/OrgListCard'; import type { ChangeEvent } from 'react'; import React, { useEffect, useState } from 'react'; -import { Dropdown, Form } from 'react-bootstrap'; +import { Form } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; import Modal from 'react-bootstrap/Modal'; import { useTranslation } from 'react-i18next'; @@ -29,6 +28,7 @@ import type { import useLocalStorage from 'utils/useLocalstorage'; import styles from '../../style/app.module.css'; import OrganizationModal from './OrganizationModal'; +import SortingButton from 'subComponents/SortingButton'; function orgList(): JSX.Element { const { t } = useTranslation('translation', { keyPrefix: 'orgList' }); @@ -48,8 +48,10 @@ function orgList(): JSX.Element { function closeDialogModal(): void { setdialogModalIsOpen(false); } + const toggleDialogModal = (): void => setdialogModalIsOpen(!dialogModalisOpen); + document.title = t('title'); const perPageResult = 8; @@ -58,6 +60,7 @@ function orgList(): JSX.Element { option: '', selectedOption: t('sort'), }); + const [hasMore, sethasMore] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const [searchByName, setSearchByName] = useState(''); @@ -81,9 +84,7 @@ function orgList(): JSX.Element { }); const toggleModal = (): void => setShowModal(!showModal); - const [create] = useMutation(CREATE_ORGANIZATION_MUTATION); - const [createSampleOrganization] = useMutation( CREATE_SAMPLE_ORGANIZATION_MUTATION, ); @@ -277,7 +278,6 @@ function orgList(): JSX.Element { }; const loadMoreOrganizations = (): void => { - console.log('loadMoreOrganizations'); setIsLoadingMore(true); fetchMore({ variables: { @@ -312,14 +312,12 @@ function orgList(): JSX.Element { }); }; - const handleSorting = (option: string): void => { + const handleSortChange = (value: string): void => { setSortingState({ - option, - selectedOption: t(option), + option: value, + selectedOption: t(value), }); - - const orderBy = option === 'Latest' ? 'createdAt_DESC' : 'createdAt_ASC'; - + const orderBy = value === 'Latest' ? 'createdAt_DESC' : 'createdAt_ASC'; refetchOrgs({ first: perPageResult, skip: 0, @@ -345,7 +343,6 @@ function orgList(): JSX.Element { /> <Button tabIndex={-1} - // className={`position-absolute z-10 bottom-0 end-0 h-100 d-flex justify-content-center align-items-center`} className={styles.searchButtonOrgList} onClick={handleSearchByBtnClick} data-testid="searchBtn" @@ -354,38 +351,17 @@ function orgList(): JSX.Element { </Button> </div> <div className={styles.btnsBlockOrgList}> - <div className="d-flex"> - <Dropdown - aria-expanded="false" - title="Sort organizations" - data-testid="sort" - > - <Dropdown.Toggle - // className={styles.dropdown} - variant={ - sortingState.option === '' ? 'outline-success' : 'success' - } - data-testid="sortOrgs" - > - <SortIcon className={'me-1'} /> - {sortingState.selectedOption} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={(): void => handleSorting('Latest')} - data-testid="latest" - > - {t('Latest')} - </Dropdown.Item> - <Dropdown.Item - onClick={(): void => handleSorting('Earliest')} - data-testid="oldest" - > - {t('Earliest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </div> + <SortingButton + title="Sort organizations" + sortingOptions={[ + { label: t('Latest'), value: 'Latest' }, + { label: t('Earliest'), value: 'Earliest' }, + ]} + selectedOption={sortingState.selectedOption} + onSortChange={handleSortChange} + dataTestIdPrefix="sortOrgs" + dropdownTestId="sort" + /> {superAdmin && ( <Button variant="success" @@ -398,7 +374,9 @@ function orgList(): JSX.Element { )} </div> </div> + {/* Text Infos for list */} + {!isLoading && (!orgsData?.organizationsConnection || orgsData.organizationsConnection.length === 0) && @@ -485,6 +463,7 @@ function orgList(): JSX.Element { <div className={`${styles.orgImgContainer} shimmer`} ></div> + <div className={styles.content}> <h5 className="shimmer" title="Org name"></h5> <h6 className="shimmer" title="Location"></h6> diff --git a/src/screens/OrgList/OrgListMocks.ts b/src/screens/OrgList/OrgListMocks.ts index 380313ffd5..fd8794d969 100644 --- a/src/screens/OrgList/OrgListMocks.ts +++ b/src/screens/OrgList/OrgListMocks.ts @@ -6,7 +6,6 @@ import { ORGANIZATION_CONNECTION_LIST, USER_ORGANIZATION_LIST, } from 'GraphQl/Queries/Queries'; -import 'jest-location-mock'; import type { InterfaceOrgConnectionInfoType, InterfaceUserType, diff --git a/src/screens/OrgPost/OrgPost.test.tsx b/src/screens/OrgPost/OrgPost.test.tsx index 9829589350..e9952db7bd 100644 --- a/src/screens/OrgPost/OrgPost.test.tsx +++ b/src/screens/OrgPost/OrgPost.test.tsx @@ -308,7 +308,7 @@ describe('Organisation Post Page', () => { await act(async () => { fireEvent.click(inputText); }); - const toggleTite = screen.getByTestId('searchTitle'); + const toggleTite = screen.getByTestId('Title'); await act(async () => { fireEvent.click(toggleTite); }); diff --git a/src/screens/OrgPost/OrgPost.tsx b/src/screens/OrgPost/OrgPost.tsx index e9cb4d4ca2..8ccdb47692 100644 --- a/src/screens/OrgPost/OrgPost.tsx +++ b/src/screens/OrgPost/OrgPost.tsx @@ -1,6 +1,5 @@ import { useMutation, useQuery, type ApolloError } from '@apollo/client'; import { Search } from '@mui/icons-material'; -import SortIcon from '@mui/icons-material/Sort'; import { CREATE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; import { ORGANIZATION_POST_LIST } from 'GraphQl/Queries/Queries'; import Loader from 'components/Loader/Loader'; @@ -11,7 +10,6 @@ import type { ChangeEvent } 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'; import Modal from 'react-bootstrap/Modal'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; @@ -20,6 +18,7 @@ import convertToBase64 from 'utils/convertToBase64'; import { errorHandler } from 'utils/errorHandler'; import type { InterfaceQueryOrganizationPostListItem } from 'utils/interfaces'; import styles from '../../style/app.module.css'; +import SortingButton from '../../subComponents/SortingButton'; interface InterfaceOrgPost { _id: string; @@ -303,69 +302,31 @@ function orgPost(): JSX.Element { </div> <div className={styles.btnsBlockOrgPost}> <div className="d-flex"> - <Dropdown - aria-expanded="false" + <SortingButton title="SearchBy" - data-testid="sea" - > - <Dropdown.Toggle - data-testid="searchBy" - className={styles.dropdown} - > - <SortIcon className={'me-1'} /> - {t('searchBy')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - id="searchText" - onClick={(e): void => { - setShowTitle(false); - e.preventDefault(); - }} - data-testid="Text" - > - {t('Text')} - </Dropdown.Item> - <Dropdown.Item - id="searchTitle" - onClick={(e): void => { - setShowTitle(true); - e.preventDefault(); - }} - data-testid="searchTitle" - > - {t('Title')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown - aria-expanded="false" + sortingOptions={[ + { label: t('Text'), value: 'Text' }, + { label: t('Title'), value: 'Title' }, + ]} + selectedOption={showTitle ? t('Title') : t('Text')} + onSortChange={(value) => setShowTitle(value === 'Title')} + dataTestIdPrefix="searchBy" + buttonLabel={t('searchBy')} + className={`${styles.dropdown} `} + /> + <SortingButton title="Sort Post" - data-testid="sort" - > - <Dropdown.Toggle - variant="outline-success" - data-testid="sortpost" - className={styles.dropdown} - > - <SortIcon className={'me-1'} /> - {t('sortPost')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={(): void => handleSorting('latest')} - data-testid="latest" - > - {t('Latest')} - </Dropdown.Item> - <Dropdown.Item - onClick={(): void => handleSorting('oldest')} - data-testid="oldest" - > - {t('Oldest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + sortingOptions={[ + { label: t('Latest'), value: 'latest' }, + { label: t('Oldest'), value: 'oldest' }, + ]} + selectedOption={sortingOption} + onSortChange={handleSorting} + dataTestIdPrefix="sortpost" + dropdownTestId="sort" + className={`${styles.dropdown} `} + buttonLabel={t('sortPost')} + /> </div> <Button diff --git a/src/screens/OrgSettings/OrgSettings.spec.tsx b/src/screens/OrgSettings/OrgSettings.spec.tsx index a98fface20..e7d417e179 100644 --- a/src/screens/OrgSettings/OrgSettings.spec.tsx +++ b/src/screens/OrgSettings/OrgSettings.spec.tsx @@ -130,50 +130,50 @@ describe('Organisation Settings Page', () => { }); }); - it('should handle dropdown item selection correctly', async () => { - renderOrganisationSettings(); - - await waitFor(() => { - expect( - screen.getByTestId('settingsDropdownContainer'), - ).toBeInTheDocument(); - }); - - const dropdownToggle = screen.getByTestId('settingsDropdownToggle'); - userEvent.click(dropdownToggle); - - // Find all dropdown items - const dropdownItems = screen.getAllByRole('menuitem'); - expect(dropdownItems).toHaveLength(3); - - for (const item of dropdownItems) { - userEvent.click(item); - - if (item.textContent?.includes('general')) { - await waitFor(() => { - expect(screen.getByTestId('generalTab')).toBeInTheDocument(); - }); - } else if (item.textContent?.includes('actionItemCategories')) { - await waitFor(() => { - expect( - screen.getByTestId('actionItemCategoriesTab'), - ).toBeInTheDocument(); - }); - } else if (item.textContent?.includes('agendaItemCategories')) { - await waitFor(() => { - expect( - screen.getByTestId('agendaItemCategoriesTab'), - ).toBeInTheDocument(); - }); - } - - if (item !== dropdownItems[dropdownItems.length - 1]) { - userEvent.click(dropdownToggle); - } - } - - expect(dropdownToggle).toHaveTextContent( - screen.getByTestId('agendaItemCategoriesSettings').textContent || '', - ); - }); + // it('should handle dropdown item selection correctly', async () => { + // renderOrganisationSettings(); + + // await waitFor(() => { + // expect( + // screen.getByTestId('settingsDropdownContainer'), + // ).toBeInTheDocument(); + // }); + + // const dropdownToggle = screen.getByTestId('settingsDropdownToggle'); + // userEvent.click(dropdownToggle); + + // // Find all dropdown items + // const dropdownItems = screen.getAllByRole('menuitem'); + // expect(dropdownItems).toHaveLength(3); + + // for (const item of dropdownItems) { + // userEvent.click(item); + + // if (item.textContent?.includes('general')) { + // await waitFor(() => { + // expect(screen.getByTestId('generalTab')).toBeInTheDocument(); + // }); + // } else if (item.textContent?.includes('actionItemCategories')) { + // await waitFor(() => { + // expect( + // screen.getByTestId('actionItemCategoriesTab'), + // ).toBeInTheDocument(); + // }); + // } else if (item.textContent?.includes('agendaItemCategories')) { + // await waitFor(() => { + // expect( + // screen.getByTestId('agendaItemCategoriesTab'), + // ).toBeInTheDocument(); + // }); + // } + + // if (item !== dropdownItems[dropdownItems.length - 1]) { + // userEvent.click(dropdownToggle); + // } + // } + + // expect(dropdownToggle).toHaveTextContent( + // screen.getByTestId('agendaItemCategoriesSettings').textContent || '', + // ); + // }); }); diff --git a/src/screens/OrgSettings/OrgSettings.tsx b/src/screens/OrgSettings/OrgSettings.tsx index 641bc27c7d..57758d2867 100644 --- a/src/screens/OrgSettings/OrgSettings.tsx +++ b/src/screens/OrgSettings/OrgSettings.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Button, Dropdown, Row, Col } from 'react-bootstrap'; +import { Button, Row, Col } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import styles from 'style/app.module.css'; import OrgActionItemCategories from 'components/OrgSettings/ActionItemCategories/OrgActionItemCategories'; @@ -62,34 +62,6 @@ function OrgSettings(): JSX.Element { </Button> ))} </div> - - {/* Dropdown menu for selecting settings category */} - <Dropdown - className={styles.settingsDropdown} - data-testid="settingsDropdownContainer" - drop="down" - > - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - data-testid="settingsDropdownToggle" - > - <span className="me-1">{t(tab)}</span> - </Dropdown.Toggle> - <Dropdown.Menu> - {/* Render dropdown items for each settings category */} - {settingtabs.map((setting, index) => ( - <Dropdown.Item - key={index} - role="menuitem" - onClick={() => setTab(setting)} - className={tab === setting ? 'text-secondary' : ''} - > - {t(setting)} - </Dropdown.Item> - ))} - </Dropdown.Menu> - </Dropdown> </Col> <Row className="mt-3"> diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx index 7ae0fc58eb..89bcc5d824 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx @@ -252,11 +252,11 @@ describe('Testing Organization Action Items Screen', () => { }); await waitFor(() => { - expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + expect(screen.getByTestId('all')).toBeInTheDocument(); }); await act(() => { - fireEvent.click(screen.getByTestId('statusAll')); + fireEvent.click(screen.getByTestId('all')); }); await waitFor(() => { @@ -269,11 +269,11 @@ describe('Testing Organization Action Items Screen', () => { }); await waitFor(() => { - expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + expect(screen.getByTestId('pending')).toBeInTheDocument(); }); await act(() => { - fireEvent.click(screen.getByTestId('statusPending')); + fireEvent.click(screen.getByTestId('pending')); }); await waitFor(() => { @@ -314,11 +314,11 @@ describe('Testing Organization Action Items Screen', () => { }); await waitFor(() => { - expect(screen.getByTestId('statusCompleted')).toBeInTheDocument(); + expect(screen.getByTestId('completed')).toBeInTheDocument(); }); await act(() => { - fireEvent.click(screen.getByTestId('statusCompleted')); + fireEvent.click(screen.getByTestId('completed')); }); await waitFor(() => { diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx index 6061ba7e7d..e3d55648b0 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx @@ -1,15 +1,9 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { - Circle, - FilterAltOutlined, - Search, - Sort, - WarningAmberRounded, -} from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import dayjs from 'dayjs'; import { useQuery } from '@apollo/client'; @@ -32,6 +26,7 @@ import ItemModal from './ItemModal'; import ItemDeleteModal from './ItemDeleteModal'; import Avatar from 'components/Avatar/Avatar'; import ItemUpdateStatusModal from './ItemUpdateStatusModal'; +import SortingButton from 'subComponents/SortingButton'; enum ItemStatus { Pending = 'pending', @@ -141,6 +136,11 @@ function organizationActionItems(): JSX.Element { [], ); + // Trigger refetch on sortBy or status change + useEffect(() => { + actionItemsRefetch(); + }, [sortBy, status, actionItemsRefetch]); + if (actionItemsLoading) { return <Loader size="xl" />; } @@ -388,89 +388,57 @@ function organizationActionItems(): JSX.Element { </Button> </div> <div className="d-flex gap-3 mb-1"> - <div className="d-flex justify-space-between align-items-center gap-3 overflow-y-auto"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="searchByToggle" - > - <Sort className={'me-1'} /> - {tCommon('searchBy', { item: '' })} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSearchBy('assignee')} - data-testid="assignee" - > - {t('assignee')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSearchBy('category')} - data-testid="category" - > - {t('category')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('dueDate_DESC')} - data-testid="dueDate_DESC" - > - {t('latestDueDate')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('dueDate_ASC')} - data-testid="dueDate_ASC" - > - {t('earliestDueDate')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="filter" - > - <FilterAltOutlined className={'me-1'} /> - {t('status')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setStatus(null)} - data-testid="statusAll" - > - {tCommon('all')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setStatus(ItemStatus.Pending)} - data-testid="statusPending" - > - {tCommon('pending')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setStatus(ItemStatus.Completed)} - data-testid="statusCompleted" - > - {tCommon('completed')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </div> + <SortingButton + title={tCommon('searchBy')} + sortingOptions={[ + { label: t('assignee'), value: 'assignee' }, + { label: t('category'), value: 'category' }, + ]} + selectedOption={t(searchBy)} + onSortChange={(value) => + setSearchBy(value as 'assignee' | 'category') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + className={styles.dropdown} // Pass a custom class name if needed + /> + <SortingButton + title={tCommon('sort')} + sortingOptions={[ + { label: t('latestDueDate'), value: 'dueDate_DESC' }, + { label: t('earliestDueDate'), value: 'dueDate_ASC' }, + ]} + selectedOption={t( + sortBy === 'dueDate_DESC' ? 'latestDueDate' : 'earliestDueDate', + )} + onSortChange={(value) => + setSortBy(value as 'dueDate_DESC' | 'dueDate_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + className={styles.dropdown} // Pass a custom class name if needed + /> + <SortingButton + title={t('status')} + sortingOptions={[ + { label: tCommon('all'), value: 'all' }, + { label: tCommon('pending'), value: ItemStatus.Pending }, + { label: tCommon('completed'), value: ItemStatus.Completed }, + ]} + selectedOption={t( + status === null + ? 'all' + : status === ItemStatus.Pending + ? 'pending' + : 'completed', + )} + onSortChange={(value) => + setStatus(value === 'all' ? null : (value as ItemStatus)) + } + dataTestIdPrefix="filter" + buttonLabel={t('status')} + className={styles.dropdown} // Pass a custom class name if needed + /> <div> <Button variant="success" diff --git a/src/screens/OrganizationFundCampaign/CampaignModal.test.tsx b/src/screens/OrganizationFundCampaign/CampaignModal.spec.tsx similarity index 95% rename from src/screens/OrganizationFundCampaign/CampaignModal.test.tsx rename to src/screens/OrganizationFundCampaign/CampaignModal.spec.tsx index 2a5a61a22b..f20b1ace3d 100644 --- a/src/screens/OrganizationFundCampaign/CampaignModal.test.tsx +++ b/src/screens/OrganizationFundCampaign/CampaignModal.spec.tsx @@ -21,19 +21,21 @@ import { toast } from 'react-toastify'; import { MOCKS, MOCK_ERROR } from './OrganizationFundCampaignMocks'; import type { InterfaceCampaignModal } from './CampaignModal'; import CampaignModal from './CampaignModal'; +import { vi } from 'vitest'; -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - success: jest.fn(), - error: jest.fn(), + success: vi.fn(), + error: vi.fn(), }, })); -jest.mock('@mui/x-date-pickers/DateTimePicker', () => { +vi.mock('@mui/x-date-pickers/DateTimePicker', async () => { + const actual = await vi.importActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ); return { - DateTimePicker: jest.requireActual( - '@mui/x-date-pickers/DesktopDateTimePicker', - ).DesktopDateTimePicker, + DateTimePicker: actual.DesktopDateTimePicker, }; }); @@ -46,7 +48,7 @@ const translations = JSON.parse( const campaignProps: InterfaceCampaignModal[] = [ { isOpen: true, - hide: jest.fn(), + hide: vi.fn(), fundId: 'fundId', orgId: 'orgId', campaign: { @@ -58,12 +60,12 @@ const campaignProps: InterfaceCampaignModal[] = [ currency: 'USD', createdAt: '2021-01-01', }, - refetchCampaign: jest.fn(), + refetchCampaign: vi.fn(), mode: 'create', }, { isOpen: true, - hide: jest.fn(), + hide: vi.fn(), fundId: 'fundId', orgId: 'orgId', campaign: { @@ -75,7 +77,7 @@ const campaignProps: InterfaceCampaignModal[] = [ currency: 'USD', createdAt: '2021-01-01', }, - refetchCampaign: jest.fn(), + refetchCampaign: vi.fn(), mode: 'edit', }, ]; @@ -100,7 +102,7 @@ const renderCampaignModal = ( describe('CampaignModal', () => { afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); cleanup(); }); diff --git a/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx b/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx index 8a5455c663..184794bb17 100644 --- a/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx +++ b/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx @@ -1,12 +1,12 @@ import { useQuery } from '@apollo/client'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { Stack, Typography, Breadcrumbs, Link } from '@mui/material'; import { DataGrid, type GridCellParams, type GridColDef, } from '@mui/x-data-grid'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate, useParams } from 'react-router-dom'; import React, { useCallback, useMemo, useState } from 'react'; @@ -20,6 +20,7 @@ import type { InterfaceCampaignInfo, InterfaceQueryOrganizationFundCampaigns, } from 'utils/interfaces'; +import SortingButton from 'subComponents/SortingButton'; const dataGridStyle = { '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { @@ -360,43 +361,25 @@ const orgFundCampaign = (): JSX.Element => { </div> <div className={styles.btnsBbtnsBlockOrganizationFundCampaignlock}> <div className="d-flex justify-space-between"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdownOrganizationFundCampaign} - data-testid="filter" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('fundingGoal_ASC')} - data-testid="fundingGoal_ASC" - > - {t('lowestGoal')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('fundingGoal_DESC')} - data-testid="fundingGoal_DESC" - > - {t('highestGoal')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('lowestGoal'), value: 'fundingGoal_ASC' }, + { label: t('highestGoal'), value: 'fundingGoal_DESC' }, + { label: t('latestEndDate'), value: 'endDate_DESC' }, + { label: t('earliestEndDate'), value: 'endDate_ASC' }, + ]} + onSortChange={(value) => + setSortBy( + value as + | 'fundingGoal_ASC' + | 'fundingGoal_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + /> </div> <div> <Button diff --git a/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.test.tsx b/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.spec.tsx similarity index 88% rename from src/screens/OrganizationFundCampaign/OrganizationFundCampaign.test.tsx rename to src/screens/OrganizationFundCampaign/OrganizationFundCampaign.spec.tsx index 9c169e355a..68bbc325b0 100644 --- a/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.test.tsx +++ b/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.spec.tsx @@ -3,17 +3,11 @@ 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 { - cleanup, - fireEvent, - render, - screen, - waitFor, -} 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 { MemoryRouter, Route, Routes, useParams } from 'react-router-dom'; import { store } from '../../state/store'; import { StaticMockLink } from '../../utils/StaticMockLink'; import i18nForTest from '../../utils/i18nForTest'; @@ -24,19 +18,21 @@ import { MOCK_ERROR, } from './OrganizationFundCampaignMocks'; import type { ApolloLink } from '@apollo/client'; +import { vi } from 'vitest'; -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - success: jest.fn(), - error: jest.fn(), + success: vi.fn(), + error: vi.fn(), }, })); -jest.mock('@mui/x-date-pickers/DateTimePicker', () => { +vi.mock('@mui/x-date-pickers/DateTimePicker', async () => { + const actual = await vi.importActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ); return { - DateTimePicker: jest.requireActual( - '@mui/x-date-pickers/DesktopDateTimePicker', - ).DesktopDateTimePicker, + DateTimePicker: actual.DesktopDateTimePicker, }; }); @@ -83,21 +79,25 @@ const renderFundCampaign = (link: ApolloLink): RenderResult => { describe('FundCampaigns Screen', () => { beforeEach(() => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: 'orgId', fundId: 'fundId' }), - })); + vi.mock('react-router-dom', async () => { + const actualDom = await vi.importActual('react-router-dom'); + return { + ...actualDom, + useParams: vi.fn(), + }; + }); }); afterEach(() => { - cleanup(); + vi.clearAllMocks(); }); - afterAll(() => { - jest.clearAllMocks(); - }); + const mockRouteParams = (orgId = 'orgId', fundId = 'fundId'): void => { + vi.mocked(useParams).mockReturnValue({ orgId, fundId }); + }; it('should render the Campaign Pledge screen', async () => { + mockRouteParams(); renderFundCampaign(link1); await waitFor(() => { expect(screen.getByTestId('searchFullName')).toBeInTheDocument(); @@ -108,6 +108,7 @@ describe('FundCampaigns Screen', () => { }); it('should redirect to fallback URL if URL params are undefined', async () => { + mockRouteParams('', ''); render( <MockedProvider addTypename={false} link={link1}> <MemoryRouter initialEntries={['/orgfundcampaign/']}> @@ -136,6 +137,7 @@ describe('FundCampaigns Screen', () => { }); it('open and close Create Campaign modal', async () => { + mockRouteParams(); renderFundCampaign(link1); const addCampaignBtn = await screen.findByTestId('addCampaignBtn'); @@ -152,6 +154,7 @@ describe('FundCampaigns Screen', () => { }); it('open and close update campaign modal', async () => { + mockRouteParams(); renderFundCampaign(link1); await waitFor(() => { @@ -174,6 +177,7 @@ describe('FundCampaigns Screen', () => { }); it('Search the Campaigns list by Name', async () => { + mockRouteParams(); renderFundCampaign(link1); const searchField = await screen.findByTestId('searchFullName'); fireEvent.change(searchField, { @@ -187,6 +191,7 @@ describe('FundCampaigns Screen', () => { }); it('should render the Campaign screen with error', async () => { + mockRouteParams(); renderFundCampaign(link2); await waitFor(() => { expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); @@ -194,6 +199,7 @@ describe('FundCampaigns Screen', () => { }); it('renders the empty campaign component', async () => { + mockRouteParams(); renderFundCampaign(link3); await waitFor(() => expect( @@ -203,6 +209,7 @@ describe('FundCampaigns Screen', () => { }); it('Sort the Campaigns list by Latest end Date', async () => { + mockRouteParams(); renderFundCampaign(link1); const sortBtn = await screen.findByTestId('filter'); @@ -224,6 +231,7 @@ describe('FundCampaigns Screen', () => { }); it('Sort the Campaigns list by Earliest end Date', async () => { + mockRouteParams(); renderFundCampaign(link1); const sortBtn = await screen.findByTestId('filter'); @@ -245,6 +253,7 @@ describe('FundCampaigns Screen', () => { }); it('Sort the Campaigns list by lowest goal', async () => { + mockRouteParams(); renderFundCampaign(link1); const sortBtn = await screen.findByTestId('filter'); @@ -264,6 +273,7 @@ describe('FundCampaigns Screen', () => { }); it('Sort the Campaigns list by highest goal', async () => { + mockRouteParams(); renderFundCampaign(link1); const sortBtn = await screen.findByTestId('filter'); @@ -283,6 +293,7 @@ describe('FundCampaigns Screen', () => { }); it('Click on Campaign Name', async () => { + mockRouteParams(); renderFundCampaign(link1); const campaignName = await screen.findAllByTestId('campaignName'); @@ -295,6 +306,7 @@ describe('FundCampaigns Screen', () => { }); it('Click on View Pledge', async () => { + mockRouteParams(); renderFundCampaign(link1); const viewBtn = await screen.findAllByTestId('viewBtn'); @@ -307,6 +319,7 @@ describe('FundCampaigns Screen', () => { }); it('should render the Fund screen on fund breadcrumb click', async () => { + mockRouteParams(); renderFundCampaign(link1); const fundBreadcrumb = await screen.findByTestId('fundsLink'); diff --git a/src/screens/OrganizationFunds/OrganizationFunds.tsx b/src/screens/OrganizationFunds/OrganizationFunds.tsx index 1935e5306e..6af889ab83 100644 --- a/src/screens/OrganizationFunds/OrganizationFunds.tsx +++ b/src/screens/OrganizationFunds/OrganizationFunds.tsx @@ -1,12 +1,12 @@ import { useQuery } from '@apollo/client'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { Stack } from '@mui/material'; import { DataGrid, type GridCellParams, type GridColDef, } from '@mui/x-data-grid'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate, useParams } from 'react-router-dom'; import React, { useCallback, useMemo, useState } from 'react'; @@ -16,6 +16,7 @@ import FundModal from './FundModal'; import { FUND_LIST } from 'GraphQl/Queries/fundQueries'; import styles from '../../style/app.module.css'; import type { InterfaceFundInfo } from 'utils/interfaces'; +import SortingButton from 'subComponents/SortingButton'; const dataGridStyle = { borderRadius: '20px', @@ -307,33 +308,23 @@ const organizationFunds = (): JSX.Element => { </Button> </div> <div className="d-flex gap-4 mb-1"> - <div className="d-flex justify-space-between align-items-center"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdowns} - data-testid="filter" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('createdLatest')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('createdEarliest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </div> + <SortingButton + title={tCommon('sort')} + sortingOptions={[ + { label: t('createdLatest'), value: 'createdAt_DESC' }, + { label: t('createdEarliest'), value: 'createdAt_ASC' }, + ]} + selectedOption={ + sortBy === 'createdAt_DESC' + ? t('createdLatest') + : t('createdEarliest') + } + onSortChange={(value) => + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + /> <div> <Button variant="success" diff --git a/src/screens/OrganizationPeople/AddMember.tsx b/src/screens/OrganizationPeople/AddMember.tsx index 3bdbc72c21..7743768611 100644 --- a/src/screens/OrganizationPeople/AddMember.tsx +++ b/src/screens/OrganizationPeople/AddMember.tsx @@ -21,7 +21,7 @@ import { import Loader from 'components/Loader/Loader'; import type { ChangeEvent } from 'react'; import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Form, InputGroup, Modal } from 'react-bootstrap'; +import { Button, Form, InputGroup, Modal } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { Link, useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -32,6 +32,7 @@ import type { } from 'utils/interfaces'; import styles from '../../style/app.module.css'; import Avatar from 'components/Avatar/Avatar'; +import SortingButton from 'subComponents/SortingButton'; const StyledTableCell = styled(TableCell)(() => ({ [`&.${tableCellClasses.head}`]: { @@ -270,44 +271,27 @@ function AddMember(): JSX.Element { }); }; + const handleSortChange = (value: string): void => { + if (value === 'existingUser') { + openAddUserModal(); + } else if (value === 'newUser') { + openCreateNewUserModal(); + } + }; + return ( <> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="addMembers" - > - {translateOrgPeople('addMembers')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - id="existingUser" - data-value="existingUser" - data-name="existingUser" - data-testid="existingUser" - onClick={(): void => { - openAddUserModal(); - }} - > - <Form.Label htmlFor="existingUser"> - {translateOrgPeople('existingUser')} - </Form.Label> - </Dropdown.Item> - <Dropdown.Item - id="newUser" - data-value="newUser" - data-name="newUser" - data-testid="newUser" - onClick={(): void => { - openCreateNewUserModal(); - }} - > - <label htmlFor="memberslist">{translateOrgPeople('newUser')}</label> - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + title={translateOrgPeople('addMembers')} + sortingOptions={[ + { label: translateOrgPeople('existingUser'), value: 'existingUser' }, + { label: translateOrgPeople('newUser'), value: 'newUser' }, + ]} + selectedOption={translateOrgPeople('addMembers')} + onSortChange={handleSortChange} + dataTestIdPrefix="addMembers" + className={styles.dropdown} + /> {/* Existing User Modal */} <Modal diff --git a/src/screens/OrganizationPeople/OrganizationPeople.tsx b/src/screens/OrganizationPeople/OrganizationPeople.tsx index 3bab1dda8b..4198cadaef 100644 --- a/src/screens/OrganizationPeople/OrganizationPeople.tsx +++ b/src/screens/OrganizationPeople/OrganizationPeople.tsx @@ -1,5 +1,5 @@ import { useLazyQuery } from '@apollo/client'; -import { Delete, Search, Sort } from '@mui/icons-material'; +import { Delete, Search } from '@mui/icons-material'; import { ORGANIZATIONS_LIST, ORGANIZATIONS_MEMBER_CONNECTION_LIST, @@ -10,7 +10,7 @@ import OrgAdminListCard from 'components/OrgAdminListCard/OrgAdminListCard'; import OrgPeopleListCard from 'components/OrgPeopleListCard/OrgPeopleListCard'; import dayjs from 'dayjs'; import React, { useEffect, useState } from 'react'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useParams } from 'react-router-dom'; @@ -21,7 +21,7 @@ import { DataGrid } from '@mui/x-data-grid'; import type { GridColDef, GridCellParams } from '@mui/x-data-grid'; import { Stack } from '@mui/material'; import Avatar from 'components/Avatar/Avatar'; - +import SortingButton from 'subComponents/SortingButton'; /** * OrganizationPeople component is used to display the list of members, admins and users of the organization. * It also provides the functionality to search the members, admins and users by their full name. @@ -57,6 +57,7 @@ function organizationPeople(): JSX.Element { const [selectedMemId, setSelectedMemId] = React.useState< string | undefined >(); + const toggleRemoveModal = (): void => { setShowRemoveModal((prev) => !prev); }; @@ -139,7 +140,6 @@ function organizationPeople(): JSX.Element { const handleFullNameSearchChange = (e: React.FormEvent): void => { e.preventDefault(); - /* istanbul ignore next */ const [firstName, lastName] = userName.split(' '); const newFilterData = { firstName_contains: firstName || '', @@ -184,18 +184,52 @@ function organizationPeople(): JSX.Element { headerAlign: 'center', headerClassName: `${styles.tableHeader}`, sortable: false, + renderCell: (params: GridCellParams) => { - return params.row?.image ? ( - <img - src={params.row?.image} - alt="avatar" - className={styles.TableImage} - /> - ) : ( - <Avatar - avatarStyle={styles.TableImage} - name={`${params.row.firstName} ${params.row.lastName}`} - /> + // Fallback to a fixed width if computedWidth is unavailable + const columnWidth = params.colDef.computedWidth || 150; + const imageSize = Math.min(columnWidth * 0.6, 60); // Max size 40px, responsive scaling + + return ( + <div + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', + }} + > + {params.row?.image ? ( + <img + src={params.row?.image} + alt="avatar" + style={{ + width: `${imageSize}px`, + height: `${imageSize}px`, + borderRadius: '50%', + objectFit: 'cover', + }} + /> + ) : ( + <div + style={{ + width: `${imageSize}px`, + height: `${imageSize}px`, + fontSize: `${imageSize * 0.4}px`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + backgroundColor: '#ccc', + }} + > + <Avatar + name={`${params.row.firstName} ${params.row.lastName}`} + /> + </div> + )} + </div> ); }, }, @@ -275,6 +309,11 @@ function organizationPeople(): JSX.Element { }, }, ]; + + const handleSortChange = (value: string): void => { + setState(value === 'users' ? 2 : value === 'members' ? 0 : 1); + }; + return ( <> <Row className={styles.head}> @@ -295,7 +334,7 @@ function organizationPeople(): JSX.Element { /> <Button type="submit" - className={`${styles.searchButton} `} + className={`${styles.searchButton}`} data-testid={'searchbtn'} > <Search className={styles.searchIcon} /> @@ -303,70 +342,27 @@ function organizationPeople(): JSX.Element { </Form> </div> <div className={styles.btnsBlock}> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="role" - > - <Sort /> - {t('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - d-inline - id="userslist" - data-value="userslist" - className={styles.dropdownItem} - data-name="displaylist" - data-testid="users" - defaultChecked={state == 2 ? true : false} - onClick={(): void => { - setState(2); - }} - > - <Form.Label htmlFor="userslist"> - {tCommon('users')} - </Form.Label> - </Dropdown.Item> - <Dropdown.Item - d-inline - id="memberslist" - data-value="memberslist" - className={styles.dropdownItem} - data-name="displaylist" - data-testid="members" - defaultChecked={state == 0 ? true : false} - onClick={(): void => { - setState(0); - }} - > - <Form.Label htmlFor="memberslist"> - {tCommon('members')} - </Form.Label> - </Dropdown.Item> - <Dropdown.Item - d-inline - id="adminslist" - data-value="adminslist" - data-name="displaylist" - className={styles.dropdownItem} - data-testid="admins" - defaultChecked={state == 1 ? true : false} - onClick={(): void => { - setState(1); - }} - > - <Form.Label htmlFor="adminslist"> - {tCommon('admins')} - </Form.Label> - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + className={styles.dropdown} + title={tCommon('sort')} + sortingOptions={[ + { label: tCommon('users'), value: 'users' }, + { label: tCommon('members'), value: 'members' }, + { label: tCommon('admins'), value: 'admins' }, + ]} + selectedOption={ + state === 2 + ? tCommon('users') + : state === 0 + ? tCommon('members') + : tCommon('admins') + } + onSortChange={handleSortChange} + dataTestIdPrefix="role" + /> </div> <div className={styles.btnsBlock}> - <AddMember></AddMember> + <AddMember /> </div> </div> </div> diff --git a/src/screens/OrganizationTags/OrganizationTags.tsx b/src/screens/OrganizationTags/OrganizationTags.tsx index 558eb4eaf8..0b233cfaef 100644 --- a/src/screens/OrganizationTags/OrganizationTags.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.tsx @@ -1,13 +1,11 @@ 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 { useNavigate, useParams, Link } from 'react-router-dom'; import type { ChangeEvent } 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'; import Modal from 'react-bootstrap/Modal'; import Row from 'react-bootstrap/Row'; import { useTranslation } from 'react-i18next'; @@ -30,7 +28,7 @@ import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; import InfiniteScroll from 'react-infinite-scroll-component'; import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; - +import SortingButton from 'subComponents/SortingButton'; /** * Component that renders the Organization Tags screen when the app navigates to '/orgtags/:orgId'. * @@ -294,6 +292,10 @@ function OrganizationTags(): JSX.Element { }, ]; + const handleSortChange = (value: string): void => { + setTagSortOrder(value === 'latest' ? 'DESCENDING' : 'ASCENDING'); + }; + return ( <> <Row> @@ -312,40 +314,24 @@ function OrganizationTags(): JSX.Element { /> </div> <div className={styles.btnsBlock}> - <Dropdown - aria-expanded="false" + <SortingButton title="Sort Tags" - data-testid="sort" - > - <Dropdown.Toggle - variant="outline-success" - data-testid="sortTags" - className={styles.dropdown} - > - <SortIcon className={'me-1'} /> - {tagSortOrder === 'DESCENDING' + sortingOptions={[ + { label: tCommon('Latest'), value: 'latest' }, + { label: tCommon('Oldest'), value: 'oldest' }, + ]} + selectedOption={ + tagSortOrder === 'DESCENDING' ? tCommon('Latest') - : tCommon('Oldest')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - data-testid="latest" - onClick={() => setTagSortOrder('DESCENDING')} - > - {tCommon('Latest')} - </Dropdown.Item> - <Dropdown.Item - data-testid="oldest" - onClick={() => setTagSortOrder('ASCENDING')} - > - {tCommon('Oldest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + : tCommon('Oldest') + } + onSortChange={handleSortChange} + dataTestIdPrefix="sortTags" + className={styles.dropdown} + /> </div> <div> <Button - // variant="success" onClick={showCreateTagModal} data-testid="createTagBtn" className={`${styles.createButton} mb-2`} diff --git a/src/screens/OrganizationVenues/OrganizationVenues.tsx b/src/screens/OrganizationVenues/OrganizationVenues.tsx index 3914ed5748..82b30f3035 100644 --- a/src/screens/OrganizationVenues/OrganizationVenues.tsx +++ b/src/screens/OrganizationVenues/OrganizationVenues.tsx @@ -9,11 +9,12 @@ import { VENUE_LIST } from 'GraphQl/Queries/OrganizationQueries'; import Loader from 'components/Loader/Loader'; import { Navigate, useParams } from 'react-router-dom'; import VenueModal from 'components/Venues/VenueModal'; -import { Dropdown, Form } from 'react-bootstrap'; -import { Search, Sort } from '@mui/icons-material'; +import { Form } from 'react-bootstrap'; +import { Search } from '@mui/icons-material'; import { DELETE_VENUE_MUTATION } from 'GraphQl/Mutations/VenueMutations'; import type { InterfaceQueryVenueListItem } from 'utils/interfaces'; import VenueCard from 'components/Venues/VenueCard'; +import SortingButton from 'subComponents/SortingButton'; /** * Component to manage and display the list of organization venues. @@ -92,12 +93,16 @@ function organizationVenues(): JSX.Element { setSearchTerm(event.target.value); }; + const handleSearchByChange = (value: string): void => { + setSearchBy(value as 'name' | 'desc'); + }; + /** * Updates the sort order state when the user selects a sort option. - * @param order - The order to sort venues by (highest or lowest capacity). + * @param value - The order to sort venues by (highest or lowest capacity). */ - const handleSortChange = (order: 'highest' | 'lowest'): void => { - setSortOrder(order); + const handleSortChange = (value: string): void => { + setSortOrder(value as 'highest' | 'lowest'); }; /** @@ -157,73 +162,31 @@ function organizationVenues(): JSX.Element { <Search /> </Button> </div> - <div className="d-flex gap-3 flex-wrap "> - <div className="d-flex gap-3 justify-content-between "> - <Dropdown - aria-expanded="false" - title="SearchBy" - data-tesid="searchByToggle" - > - <Dropdown.Toggle - data-testid="searchByDrpdwn" - variant="outline-success" - className={styles.dropdown} - > - <Sort className={'me-1'} /> - {t('searchBy')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - id="searchName" - onClick={(e): void => { - setSearchBy('name'); - e.preventDefault(); - }} - data-testid="name" - > - {tCommon('name')} - </Dropdown.Item> - <Dropdown.Item - id="searchDesc" - onClick={(e): void => { - setSearchBy('desc'); - e.preventDefault(); - }} - data-testid="desc" - > - {tCommon('description')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown - aria-expanded="false" - title="Sort Venues" - data-testid="sort" - > - <Dropdown.Toggle - variant="outline-success" - data-testid="sortVenues" - className={styles.dropdown} - > - <Sort className={'me-1'} /> - {t('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={(): void => handleSortChange('highest')} - data-testid="highest" - > - {t('highestCapacity')} - </Dropdown.Item> - <Dropdown.Item - onClick={(): void => handleSortChange('lowest')} - data-testid="lowest" - > - {t('lowestCapacity')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </div> + <div className="d-flex gap-3 flex-wrap"> + <SortingButton + title="SearchBy" + sortingOptions={[ + { label: tCommon('name'), value: 'name' }, + { label: tCommon('description'), value: 'desc' }, + ]} + selectedOption={tCommon(searchBy)} + onSortChange={handleSearchByChange} + dataTestIdPrefix="searchByDrpdwn" + className={styles.dropdown} // Pass a custom class name if needed + /> + <SortingButton + title="Sort Venues" + sortingOptions={[ + { label: t('highestCapacity'), value: 'highest' }, + { label: t('lowestCapacity'), value: 'lowest' }, + ]} + selectedOption={t( + sortOrder === 'highest' ? 'highestCapacity' : 'lowestCapacity', + )} + onSortChange={handleSortChange} + dataTestIdPrefix="sortVenues" + className={styles.dropdown} // Pass a custom class name if needed + /> <Button variant="success" className={styles.dropdown} diff --git a/src/screens/SubTags/SubTags.spec.tsx b/src/screens/SubTags/SubTags.spec.tsx index 6db92bcab6..e61d3c98db 100644 --- a/src/screens/SubTags/SubTags.spec.tsx +++ b/src/screens/SubTags/SubTags.spec.tsx @@ -284,9 +284,9 @@ describe('Organisation Tags Page', () => { userEvent.click(screen.getByTestId('sortTags')); await waitFor(() => { - expect(screen.getByTestId('oldest')).toBeInTheDocument(); + expect(screen.getByTestId('ASCENDING')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('oldest')); + userEvent.click(screen.getByTestId('ASCENDING')); // returns the tags in reverse order await waitFor(() => { @@ -301,9 +301,9 @@ describe('Organisation Tags Page', () => { userEvent.click(screen.getByTestId('sortTags')); await waitFor(() => { - expect(screen.getByTestId('latest')).toBeInTheDocument(); + expect(screen.getByTestId('DESCENDING')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('latest')); + userEvent.click(screen.getByTestId('DESCENDING')); // reverse the order again await waitFor(() => { diff --git a/src/screens/SubTags/SubTags.tsx b/src/screens/SubTags/SubTags.tsx index 6a20e875ec..034c7dfea9 100644 --- a/src/screens/SubTags/SubTags.tsx +++ b/src/screens/SubTags/SubTags.tsx @@ -1,6 +1,5 @@ import { useMutation, useQuery } from '@apollo/client'; import { Search, 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'; @@ -8,7 +7,6 @@ import type { ChangeEvent } from 'react'; import React, { useState } from 'react'; import { 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'; @@ -30,6 +28,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; /** * Component that renders the SubTags screen when the app navigates to '/orgtags/:orgId/subtags/:tagId'. @@ -301,37 +300,16 @@ function SubTags(): JSX.Element { </Button> </div> - <Dropdown - title="Sort Tag" - // className={styles.dropdown} - // className="ms-4 mb-4" - data-testid="sort" - > - <Dropdown.Toggle - data-testid="sortTags" - // className="color-red" - className={styles.dropdown} - > - <SortIcon className={'me-1'} /> - {tagSortOrder === 'DESCENDING' - ? tCommon('Latest') - : tCommon('Oldest')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - data-testid="latest" - onClick={() => setTagSortOrder('DESCENDING')} - > - {tCommon('Latest')} - </Dropdown.Item> - <Dropdown.Item - data-testid="oldest" - onClick={() => setTagSortOrder('ASCENDING')} - > - {tCommon('Oldest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: tCommon('Latest'), value: 'DESCENDING' }, + { label: tCommon('Oldest'), value: 'ASCENDING' }, + ]} + selectedOption={tagSortOrder} + onSortChange={(value) => setTagSortOrder(value as SortedByType)} + dataTestIdPrefix="sortTags" + buttonLabel={tCommon('sort')} + /> <Button onClick={() => redirectToManageTag(parentTagId as string)} diff --git a/src/screens/UserPortal/Campaigns/Campaigns.tsx b/src/screens/UserPortal/Campaigns/Campaigns.tsx index e4483f87fe..cf6af795d3 100644 --- a/src/screens/UserPortal/Campaigns/Campaigns.tsx +++ b/src/screens/UserPortal/Campaigns/Campaigns.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react'; -import { Dropdown, Form, Button, ProgressBar } from 'react-bootstrap'; +import { Form, Button, ProgressBar } from 'react-bootstrap'; import styles from './Campaigns.module.css'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate, useParams } from 'react-router-dom'; -import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import { Accordion, AccordionDetails, @@ -19,6 +19,7 @@ import { useQuery } from '@apollo/client'; import type { InterfaceUserCampaign } from 'utils/interfaces'; import { currencySymbols } from 'utils/currency'; import Loader from 'components/Loader/Loader'; +import SortingButton from 'subComponents/SortingButton'; /** * The `Campaigns` component displays a list of fundraising campaigns for a specific organization. @@ -150,44 +151,26 @@ const Campaigns = (): JSX.Element => { </div> <div className="d-flex gap-4 mb-1"> <div className="d-flex justify-space-between"> - {/* Dropdown menu for sorting campaigns */} - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="filter" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('fundingGoal_ASC')} - data-testid="fundingGoal_ASC" - > - {t('lowestGoal')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('fundingGoal_DESC')} - data-testid="fundingGoal_DESC" - > - {t('highestGoal')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('lowestGoal'), value: 'fundingGoal_ASC' }, + { label: t('highestGoal'), value: 'fundingGoal_DESC' }, + { label: t('latestEndDate'), value: 'endDate_DESC' }, + { label: t('earliestEndDate'), value: 'endDate_ASC' }, + ]} + selectedOption={sortBy} + onSortChange={(value) => + setSortBy( + value as + | 'fundingGoal_ASC' + | 'fundingGoal_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + /> </div> <div> {/* Button to navigate to the user's pledges */} diff --git a/src/screens/UserPortal/Pledges/Pledges.tsx b/src/screens/UserPortal/Pledges/Pledges.tsx index 33e8bf63c2..2ab8214265 100644 --- a/src/screens/UserPortal/Pledges/Pledges.tsx +++ b/src/screens/UserPortal/Pledges/Pledges.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Dropdown, Form, Button, ProgressBar } from 'react-bootstrap'; +import { Form, Button, ProgressBar } from 'react-bootstrap'; import styles from './Pledges.module.css'; import { useTranslation } from 'react-i18next'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import useLocalStorage from 'utils/useLocalstorage'; import type { InterfacePledgeInfo, InterfaceUserInfo } from 'utils/interfaces'; import { Unstable_Popup as BasePopup } from '@mui/base/Unstable_Popup'; @@ -21,6 +21,7 @@ import { currencySymbols } from 'utils/currency'; import PledgeDeleteModal from 'screens/FundCampaignPledge/PledgeDeleteModal'; import { Navigate, useParams } from 'react-router-dom'; import PledgeModal from '../Campaigns/PledgeModal'; +import SortingButton from 'subComponents/SortingButton'; const dataGridStyle = { '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { @@ -393,75 +394,40 @@ const Pledges = (): JSX.Element => { <Search /> </Button> </div> - <div className="d-flex gap-4 mb-1"> - <Dropdown - aria-expanded="false" - title="SearchBy" - data-tesid="searchByToggle" - className="flex-fill" - > - <Dropdown.Toggle - data-testid="searchByDrpdwn" - variant="outline-success" - > - <Sort className={'me-1'} /> - {t('searchBy')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - id="searchPledgers" - onClick={(): void => setSearchBy('pledgers')} - data-testid="pledgers" - > - {t('pledgers')} - </Dropdown.Item> - <Dropdown.Item - id="searchCampaigns" - onClick={(): void => setSearchBy('campaigns')} - data-testid="campaigns" - > - {t('campaigns')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <div className="d-flex gap-4 "> + <SortingButton + sortingOptions={[ + { label: t('pledgers'), value: 'pledgers' }, + { label: t('campaigns'), value: 'campaigns' }, + ]} + selectedOption={searchBy} + onSortChange={(value) => + setSearchBy(value as 'pledgers' | 'campaigns') + } + dataTestIdPrefix="searchByDrpdwn" + buttonLabel={t('searchBy')} + /> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="filter" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('amount_ASC')} - data-testid="amount_ASC" - > - {t('lowestAmount')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('amount_DESC')} - data-testid="amount_DESC" - > - {t('highestAmount')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('lowestAmount'), value: 'amount_ASC' }, + { label: t('highestAmount'), value: 'amount_DESC' }, + { label: t('latestEndDate'), value: 'endDate_DESC' }, + { label: t('earliestEndDate'), value: 'endDate_ASC' }, + ]} + selectedOption={sortBy} + onSortChange={(value) => + setSortBy( + value as + | 'amount_ASC' + | 'amount_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + /> </div> </div> diff --git a/src/screens/UserPortal/Posts/Posts.test.tsx b/src/screens/UserPortal/Posts/Posts.spec.tsx similarity index 89% rename from src/screens/UserPortal/Posts/Posts.test.tsx rename to src/screens/UserPortal/Posts/Posts.spec.tsx index 433e36f94a..83b626ba20 100644 --- a/src/screens/UserPortal/Posts/Posts.test.tsx +++ b/src/screens/UserPortal/Posts/Posts.spec.tsx @@ -16,17 +16,29 @@ import i18nForTest from 'utils/i18nForTest'; import Home from './Posts'; import useLocalStorage from 'utils/useLocalstorage'; import { DELETE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; +import { expect, describe, it, vi } from 'vitest'; const { setItem } = useLocalStorage(); -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - error: jest.fn(), - info: jest.fn(), - success: jest.fn(), + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), }, })); +const mockUseParams = vi.fn().mockReturnValue({ orgId: 'orgId' }); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => mockUseParams(), + useNavigate: () => vi.fn(), + }; +}); + const MOCKS = [ { request: { @@ -262,31 +274,27 @@ const renderHomeScreen = (): RenderResult => Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation((query) => ({ + value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), })), }); describe('Testing Home Screen: User Portal', () => { - beforeAll(() => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: 'orgId' }), - })); + beforeEach(() => { + mockUseParams.mockReturnValue({ orgId: 'orgId' }); }); - afterAll(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); - test('Check if HomeScreen renders properly', async () => { + it('Check if HomeScreen renders properly', async () => { renderHomeScreen(); await wait(); @@ -294,7 +302,7 @@ describe('Testing Home Screen: User Portal', () => { expect(startPostBtn).toBeInTheDocument(); }); - test('StartPostModal should render on click of StartPost btn', async () => { + it('StartPostModal should render on click of StartPost btn', async () => { renderHomeScreen(); await wait(); @@ -306,7 +314,7 @@ describe('Testing Home Screen: User Portal', () => { expect(startPostModal).toBeInTheDocument(); }); - test('StartPostModal should close on clicking the close button', async () => { + it('StartPostModal should close on clicking the close button', async () => { renderHomeScreen(); await wait(); @@ -325,7 +333,6 @@ describe('Testing Home Screen: User Portal', () => { userEvent.type(screen.getByTestId('postInput'), 'some content'); - // Check that the content and image have been added expect(screen.getByTestId('postInput')).toHaveValue('some content'); await screen.findByAltText('Post Image Preview'); expect(screen.getByAltText('Post Image Preview')).toBeInTheDocument(); @@ -342,7 +349,7 @@ describe('Testing Home Screen: User Portal', () => { expect(screen.getByTestId('postImageInput')).toHaveValue(''); }); - test('Check whether Posts render in PostCard', async () => { + it('Check whether Posts render in PostCard', async () => { setItem('userId', '640d98d9eb6a743d75341067'); renderHomeScreen(); await wait(); @@ -359,7 +366,7 @@ describe('Testing Home Screen: User Portal', () => { expect(screen.queryByText('This is the post two')).toBeInTheDocument(); }); - test('Checking if refetch works after deleting this post', async () => { + it('Checking if refetch works after deleting this post', async () => { setItem('userId', '640d98d9eb6a743d75341067'); renderHomeScreen(); expect(screen.queryAllByTestId('dropdown')).not.toBeNull(); @@ -371,11 +378,15 @@ describe('Testing Home Screen: User Portal', () => { }); describe('HomeScreen with invalid orgId', () => { - test('Redirect to /user when organizationId is falsy', async () => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: undefined }), - })); + beforeEach(() => { + mockUseParams.mockReturnValue({ orgId: undefined }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Redirect to /user when organizationId is falsy', async () => { render( <MockedProvider addTypename={false} link={link}> <MemoryRouter initialEntries={['/user/organization/']}> diff --git a/src/screens/UserPortal/Settings/Settings.spec.tsx b/src/screens/UserPortal/Settings/Settings.spec.tsx index 184789ab04..f24c59d50a 100644 --- a/src/screens/UserPortal/Settings/Settings.spec.tsx +++ b/src/screens/UserPortal/Settings/Settings.spec.tsx @@ -12,6 +12,21 @@ import { StaticMockLink } from 'utils/StaticMockLink'; import Settings from './Settings'; import userEvent from '@testing-library/user-event'; import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; + +vi.mock('react-toastify', () => ({ + toast: { + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('utils/errorHandler', () => ({ + errorHandler: vi.fn(), +})); + const MOCKS = [ { request: { @@ -109,9 +124,71 @@ const Mocks2 = [ }, ]; +const updateMock = [ + { + request: { + query: UPDATE_USER_MUTATION, + variables: { + firstName: 'John', + lastName: 'randomUpdated', + createdAt: '2021-03-01T00:00:00.000Z', + gender: 'MALE', + email: 'johndoe@gmail.com', + phoneNumber: '+174567890', + birthDate: '2024-03-01', + grade: 'GRADUATE', + empStatus: 'PART_TIME', + maritalStatus: 'SINGLE', + address: 'random', + state: 'random', + country: 'IN', + eventsAttended: [{ _id: 'event1' }, { _id: 'event2' }], + image: '', + }, + }, + result: { + data: { + updateUserProfile: { + _id: '65ba1621b7b00c20e5f1d8d2', + }, + }, + }, + }, + ...Mocks1, +]; + +const errorMock = [ + { + request: { + query: UPDATE_USER_MUTATION, + variables: { + firstName: 'John', + lastName: 'Doe2', + createdAt: '2021-03-01T00:00:00.000Z', + gender: 'MALE', + email: 'johndoe@gmail.com', + phoneNumber: '4567890', + birthDate: '2024-03-01', + grade: 'GRADUATE', + empStatus: 'PART_TIME', + maritalStatus: 'SINGLE', + address: 'random', + state: 'random', + country: 'IN', + eventsAttended: [{ _id: 'event1' }, { _id: 'event2' }], + image: '', + }, + }, + error: new Error('Please enter a valid phone number'), + }, + ...Mocks1, +]; + const link = new StaticMockLink(MOCKS, true); const link1 = new StaticMockLink(Mocks1, true); const link2 = new StaticMockLink(Mocks2, true); +const link3 = new StaticMockLink(updateMock, true); +const link4 = new StaticMockLink(errorMock, true); const resizeWindow = (width: number): void => { window.innerWidth = width; @@ -443,3 +520,79 @@ it('prevents selecting future dates for birth date', async () => { fireEvent.change(birthDateInput, { target: { value: today } }); expect(birthDateInput.value).toBe(today); }); + +it('should update user profile successfully', async () => { + const toastSuccessSpy = vi.spyOn(toast, 'success'); + await act(async () => { + render( + <MockedProvider link={link3} addTypename={false}> + <BrowserRouter> + <Provider store={store}> + <I18nextProvider i18n={i18nForTest}> + <Settings /> + </I18nextProvider> + </Provider> + </BrowserRouter> + </MockedProvider>, + ); + }); + + await wait(); + + const lastNameInput = screen.getByTestId('inputLastName'); + expect(lastNameInput).toHaveValue('Doe'); + await act(async () => { + fireEvent.change(lastNameInput, { target: { value: 'randomUpdated' } }); + }); + + const saveButton = screen.getByTestId('updateUserBtn'); + expect(saveButton).toBeInTheDocument(); + await act(async () => { + fireEvent.click(saveButton); + }); + await wait(); + + expect(lastNameInput).toHaveValue('randomUpdated'); + expect(toastSuccessSpy).toHaveBeenCalledWith('Profile updated Successfully'); + + toastSuccessSpy.mockRestore(); +}); + +it('should call errorHandler when updating profile with an invalid phone number', async () => { + await act(async () => { + render( + <MockedProvider link={link4} addTypename={false}> + <BrowserRouter> + <Provider store={store}> + <I18nextProvider i18n={i18nForTest}> + <Settings /> + </I18nextProvider> + </Provider> + </BrowserRouter> + </MockedProvider>, + ); + }); + + await wait(200); + + const lastNameInput = screen.getByTestId('inputLastName'); + await act(async () => { + fireEvent.change(lastNameInput, { target: { value: 'Doe2' } }); + }); + + const phoneNumberInput = screen.getByTestId('inputPhoneNumber'); + + await act(async () => { + fireEvent.change(phoneNumberInput, { target: { value: '4567890' } }); + }); + await wait(200); + + const saveButton = screen.getByTestId('updateUserBtn'); + expect(saveButton).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(saveButton); + }); + await wait(); + expect(errorHandler).toHaveBeenCalled(); +}); diff --git a/src/screens/UserPortal/Settings/Settings.tsx b/src/screens/UserPortal/Settings/Settings.tsx index 385c3d639e..5f1313784c 100644 --- a/src/screens/UserPortal/Settings/Settings.tsx +++ b/src/screens/UserPortal/Settings/Settings.tsx @@ -92,7 +92,6 @@ export default function settings(): JSX.Element { * and reloads the page on success. */ - /*istanbul ignore next*/ const handleUpdateUserDetails = async (): Promise<void> => { try { let updatedUserDetails = { ...userDetails }; @@ -102,7 +101,6 @@ export default function settings(): JSX.Element { const { data } = await updateUserDetails({ variables: updatedUserDetails, }); - /* istanbul ignore next */ if (data) { toast.success( tCommon('updatedSuccessfully', { item: 'Profile' }) as string, @@ -114,7 +112,6 @@ export default function settings(): JSX.Element { setItem('name', userFullName); } } catch (error: unknown) { - /*istanbul ignore next*/ errorHandler(t, error); } }; diff --git a/src/screens/UserPortal/UserScreen/UserScreen.spec.tsx b/src/screens/UserPortal/UserScreen/UserScreen.spec.tsx index 65c5e6a650..95a1f633da 100644 --- a/src/screens/UserPortal/UserScreen/UserScreen.spec.tsx +++ b/src/screens/UserPortal/UserScreen/UserScreen.spec.tsx @@ -130,6 +130,25 @@ describe('UserScreen tests with LeftDrawer functionality', () => { expect(titleElement).toHaveTextContent('People'); }); + it('renders the correct title for chat', () => { + mockLocation = '/user/chat/123'; + + render( + <MockedProvider addTypename={false} link={link}> + <BrowserRouter> + <Provider store={store}> + <I18nextProvider i18n={i18nForTest}> + <UserScreen /> + </I18nextProvider> + </Provider> + </BrowserRouter> + </MockedProvider>, + ); + + const titleElement = screen.getByRole('heading', { level: 1 }); + expect(titleElement).toHaveTextContent('Chats'); + }); + it('toggles LeftDrawer correctly based on window size and user interaction', () => { render( <MockedProvider addTypename={false} link={link}> diff --git a/src/screens/UserPortal/UserScreen/UserScreen.tsx b/src/screens/UserPortal/UserScreen/UserScreen.tsx index 39b422858f..5a877865c3 100644 --- a/src/screens/UserPortal/UserScreen/UserScreen.tsx +++ b/src/screens/UserPortal/UserScreen/UserScreen.tsx @@ -17,6 +17,7 @@ const map: InterfaceMapType = { people: 'people', events: 'userEvents', donate: 'donate', + chat: 'userChat', campaigns: 'userCampaigns', pledges: 'userPledges', volunteer: 'userVolunteer', diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx index 9bc23969c2..9fc2c44884 100644 --- a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import dayjs from 'dayjs'; import { useQuery } from '@apollo/client'; @@ -22,6 +22,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { VIEW = 'view', @@ -373,54 +374,29 @@ function actions(): JSX.Element { </div> <div className="d-flex gap-3 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - className={styles.dropdown} - data-testid="searchByToggle" - > - <Sort className={'me-1'} /> - {tCommon('searchBy', { item: '' })} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSearchBy('assignee')} - data-testid="assignee" - > - {t('assignee')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSearchBy('category')} - data-testid="category" - > - {t('category')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - className={styles.dropdown} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('dueDate_DESC')} - data-testid="dueDate_DESC" - > - {t('latestDueDate')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('dueDate_ASC')} - data-testid="dueDate_ASC" - > - {t('earliestDueDate')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('assignee'), value: 'assignee' }, + { label: t('category'), value: 'category' }, + ]} + selectedOption={searchBy} + onSortChange={(value) => + setSearchBy(value as 'assignee' | 'category') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + <SortingButton + sortingOptions={[ + { label: t('latestDueDate'), value: 'dueDate_DESC' }, + { label: t('earliestDueDate'), value: 'dueDate_ASC' }, + ]} + onSortChange={(value) => + setSortBy(value as 'dueDate_DESC' | 'dueDate_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> </div> </div> </div> diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx index 160dc0b23a..4cd2470010 100644 --- a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx @@ -1,11 +1,10 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; - -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; - +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { useQuery } from '@apollo/client'; +import { debounce, Stack } from '@mui/material'; import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; import Loader from 'components/Loader/Loader'; @@ -14,13 +13,13 @@ import { type GridCellParams, type GridColDef, } from '@mui/x-data-grid'; -import { debounce, Stack } from '@mui/material'; import Avatar from 'components/Avatar/Avatar'; import styles from '../../../../style/app.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'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { EDIT = 'edit', @@ -313,56 +312,27 @@ function groups(): JSX.Element { </div> <div className="d-flex gap-3 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="searchByToggle" - > - <Sort className={'me-1'} /> - {tCommon('searchBy', { item: '' })} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSearchBy('leader')} - data-testid="leader" - > - {t('leader')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSearchBy('group')} - data-testid="group" - > - {t('group')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('volunteers_DESC')} - data-testid="volunteers_DESC" - > - {t('mostVolunteers')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('volunteers_ASC')} - data-testid="volunteers_ASC" - > - {t('leastVolunteers')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('leader'), value: 'leader' }, + { label: t('group'), value: 'group' }, + ]} + selectedOption={searchBy} + onSortChange={(value) => setSearchBy(value as 'leader' | 'group')} + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + <SortingButton + sortingOptions={[ + { label: t('mostVolunteers'), value: 'volunteers_DESC' }, + { label: t('leastVolunteers'), value: 'volunteers_ASC' }, + ]} + onSortChange={(value) => + setSortBy(value as 'volunteers_DESC' | 'volunteers_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> </div> </div> </div> diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx index 867f95c1aa..2c8d0835ca 100644 --- a/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx @@ -171,7 +171,7 @@ describe('Testing Invvitations Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const filterAll = await screen.findByTestId('filterAll'); + const filterAll = await screen.findByTestId('all'); expect(filterAll).toBeInTheDocument(); fireEvent.click(filterAll); @@ -189,7 +189,7 @@ describe('Testing Invvitations Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const filterGroup = await screen.findByTestId('filterGroup'); + const filterGroup = await screen.findByTestId('group'); expect(filterGroup).toBeInTheDocument(); fireEvent.click(filterGroup); @@ -210,7 +210,7 @@ describe('Testing Invvitations Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const filterIndividual = await screen.findByTestId('filterIndividual'); + const filterIndividual = await screen.findByTestId('individual'); expect(filterIndividual).toBeInTheDocument(); fireEvent.click(filterIndividual); diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx index a79b64251d..35dbe67264 100644 --- a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx @@ -1,14 +1,9 @@ import React, { useMemo, useState } from 'react'; -import { Dropdown, Form, Button } from 'react-bootstrap'; +import { 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 { Search, WarningAmberRounded } from '@mui/icons-material'; import { TbCalendarEvent } from 'react-icons/tb'; import { FaUserGroup } from 'react-icons/fa6'; import { debounce, Stack } from '@mui/material'; @@ -21,6 +16,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; enum ItemFilter { Group = 'group', @@ -120,7 +116,7 @@ const Invitations = (): JSX.Element => { // loads the invitations when the component mounts if (invitationLoading) return <Loader size="xl" />; if (invitationError) { - // Displays an error message if there is an issue loading the invvitations + // Displays an error message if there is an issue loading the invitations return ( <div className={`${styles.container} bg-white rounded-4 my-3`}> <div className={styles.message} data-testid="errorMsg"> @@ -162,63 +158,30 @@ const Invitations = (): JSX.Element => { </div> <div className="d-flex gap-4 mb-1"> <div className="d-flex gap-3 justify-space-between"> - {/* Dropdown menu for sorting invitations */} - <Dropdown> - <Dropdown.Toggle - variant="success" - className={styles.dropdown} - data-testid="sort" - > - <Sort className={'me-1'} /> - {tCommon('sort')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('receivedLatest')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('receivedEarliest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="filter" - > - <FilterAltOutlined className={'me-1'} /> - {t('filter')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setFilter(null)} - data-testid="filterAll" - > - {tCommon('all')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setFilter(ItemFilter.Group)} - data-testid="filterGroup" - > - {t('groupInvite')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setFilter(ItemFilter.Individual)} - data-testid="filterIndividual" - > - {t('individualInvite')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('receivedLatest'), value: 'createdAt_DESC' }, + { label: t('receivedEarliest'), value: 'createdAt_ASC' }, + ]} + onSortChange={(value) => + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + <SortingButton + sortingOptions={[ + { label: tCommon('all'), value: 'all' }, + { label: t('groupInvite'), value: 'group' }, + { label: t('individualInvite'), value: 'individual' }, + ]} + onSortChange={(value) => + setFilter(value === 'all' ? null : (value as ItemFilter)) + } + dataTestIdPrefix="filter" + buttonLabel={t('filter')} + type="filter" + /> </div> </div> </div> diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx index bd61ca97e0..eecb874210 100644 --- a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { Dropdown, Form, Button } from 'react-bootstrap'; +import { Form, Button } from 'react-bootstrap'; import styles from '../VolunteerManagement.module.css'; import { useTranslation } from 'react-i18next'; import { Navigate, useParams } from 'react-router-dom'; @@ -19,7 +19,7 @@ import { Stack, debounce, } from '@mui/material'; -import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import { GridExpandMoreIcon } from '@mui/x-data-grid'; import useLocalStorage from 'utils/useLocalstorage'; @@ -31,6 +31,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; /** * The `UpcomingEvents` component displays list of upcoming events for the user to volunteer. @@ -90,7 +91,7 @@ const UpcomingEvents = (): JSX.Element => { } }; - // Fetches upcomin events based on the organization ID, search term, and sorting order + // Fetches upcoming events based on the organization ID, search term, and sorting order const { data: eventsData, loading: eventsLoading, @@ -169,31 +170,18 @@ const UpcomingEvents = (): JSX.Element => { </div> <div className="d-flex gap-4 mb-1"> <div className="d-flex justify-space-between align-items-center gap-3"> - <Dropdown> - <Dropdown.Toggle - variant="success" - id="dropdown-basic" - className={styles.dropdown} - data-testid="searchByToggle" - > - <Sort className={'me-1'} /> - {tCommon('searchBy', { item: '' })} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={() => setSearchBy('title')} - data-testid="title" - > - {t('name')} - </Dropdown.Item> - <Dropdown.Item - onClick={() => setSearchBy('location')} - data-testid="location" - > - {tCommon('location')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('name'), value: 'title' }, + { label: tCommon('location'), value: 'location' }, + ]} + selectedOption={searchBy} + onSortChange={(value) => + setSearchBy(value as 'title' | 'location') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> </div> </div> </div> diff --git a/src/screens/Users/Users.tsx b/src/screens/Users/Users.tsx index 936807f1ec..ef9f001f4d 100644 --- a/src/screens/Users/Users.tsx +++ b/src/screens/Users/Users.tsx @@ -1,13 +1,11 @@ import { useQuery } from '@apollo/client'; import React, { useEffect, useState } from 'react'; -import { Dropdown, Form, Table } from 'react-bootstrap'; +import { Form, Table } from 'react-bootstrap'; import Button from 'react-bootstrap/Button'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { Search } from '@mui/icons-material'; -import FilterListIcon from '@mui/icons-material/FilterList'; -import SortIcon from '@mui/icons-material/Sort'; import { ORGANIZATION_CONNECTION_LIST, USER_LIST, @@ -19,6 +17,8 @@ import type { InterfaceQueryUserListItem } from 'utils/interfaces'; import styles from '../../style/app.module.css'; import useLocalStorage from 'utils/useLocalstorage'; import type { ApolloError } from '@apollo/client'; +import SortingButton from 'subComponents/SortingButton'; + /** * The `Users` component is responsible for displaying a list of users in a paginated and sortable format. * It supports search functionality, filtering, and sorting of users. The component integrates with GraphQL @@ -372,74 +372,29 @@ const Users = (): JSX.Element => { </div> <div className={styles.btnsBlock}> <div className="d-flex"> - <Dropdown - aria-expanded="false" - title="Sort Users" - data-testid="sort" - > - <Dropdown.Toggle variant="success" data-testid="sortUsers"> - <SortIcon className={'me-1'} /> - {sortingOption === 'newest' ? t('Newest') : t('Oldest')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - onClick={(): void => { - handleSorting('newest'); - }} - data-testid="newest" - > - {t('Newest')} - </Dropdown.Item> - <Dropdown.Item - onClick={(): void => { - handleSorting('oldest'); - }} - data-testid="oldest" - > - {t('Oldest')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - <Dropdown - aria-expanded="false" - title="Filter organizations" - data-testid="filter" - > - <Dropdown.Toggle - variant="outline-success" - data-testid="filterUsers" - > - <FilterListIcon className={'me-1'} /> - {tCommon('filter')} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item - data-testid="admin" - onClick={(): void => handleFiltering('admin')} - > - {tCommon('admin')} - </Dropdown.Item> - <Dropdown.Item - data-testid="superAdmin" - onClick={(): void => handleFiltering('superAdmin')} - > - {tCommon('superAdmin')} - </Dropdown.Item> - - <Dropdown.Item - data-testid="user" - onClick={(): void => handleFiltering('user')} - > - {tCommon('user')} - </Dropdown.Item> - <Dropdown.Item - data-testid="cancel" - onClick={(): void => handleFiltering('cancel')} - > - {tCommon('cancel')} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> + <SortingButton + sortingOptions={[ + { label: t('Newest'), value: 'newest' }, + { label: t('Oldest'), value: 'oldest' }, + ]} + selectedOption={sortingOption} + onSortChange={handleSorting} + dataTestIdPrefix="sortUsers" + /> + <SortingButton + sortingOptions={[ + { label: tCommon('admin'), value: 'admin' }, + { label: tCommon('superAdmin'), value: 'superAdmin' }, + { label: tCommon('user'), value: 'user' }, + { label: tCommon('cancel'), value: 'cancel' }, + ]} + selectedOption={filteringOption} + onSortChange={handleFiltering} + dataTestIdPrefix="filterUsers" + buttonLabel={tCommon('filter')} + type="filter" + dropdownTestId="filter" + /> </div> </div> </div> diff --git a/src/setup/askAndSetDockerOption/askAndSetDockerOption.spec.ts b/src/setup/askAndSetDockerOption/askAndSetDockerOption.spec.ts new file mode 100644 index 0000000000..6efc8a7a1d --- /dev/null +++ b/src/setup/askAndSetDockerOption/askAndSetDockerOption.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock modules +vi.mock('inquirer', () => ({ + default: { + prompt: vi.fn(), + }, +})); + +vi.mock('setup/updateEnvFile/updateEnvFile', () => ({ + default: vi.fn(), +})); + +vi.mock('setup/askForDocker/askForDocker', () => ({ + askForDocker: vi.fn(), +})); + +// Import after mocking +import askAndSetDockerOption from './askAndSetDockerOption'; +import inquirer from 'inquirer'; +import updateEnvFile from 'setup/updateEnvFile/updateEnvFile'; +import { askForDocker } from 'setup/askForDocker/askForDocker'; + +describe('askAndSetDockerOption', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should set up Docker when user selects yes', async () => { + (inquirer.prompt as unknown as jest.Mock).mockResolvedValueOnce({ + useDocker: true, + }); + (askForDocker as jest.Mock).mockResolvedValueOnce(8080); + + await askAndSetDockerOption(); + + expect(updateEnvFile).toHaveBeenCalledWith('USE_DOCKER', 'YES'); + expect(updateEnvFile).toHaveBeenCalledWith('DOCKER_PORT', 8080); + }); + + it('should set up without Docker when user selects no', async () => { + (inquirer.prompt as unknown as jest.Mock).mockResolvedValueOnce({ + useDocker: false, + }); + + await askAndSetDockerOption(); + + expect(updateEnvFile).toHaveBeenCalledWith('USE_DOCKER', 'NO'); + }); + + it('should handle errors when askForDocker fails', async () => { + (inquirer.prompt as unknown as jest.Mock).mockResolvedValueOnce({ + useDocker: true, + }); + (askForDocker as jest.Mock).mockRejectedValueOnce( + new Error('Docker error'), + ); + + await expect(askAndSetDockerOption()).rejects.toThrow('Docker error'); + }); +}); diff --git a/src/setup/askAndSetDockerOption/askAndSetDockerOption.ts b/src/setup/askAndSetDockerOption/askAndSetDockerOption.ts new file mode 100644 index 0000000000..877ef92faf --- /dev/null +++ b/src/setup/askAndSetDockerOption/askAndSetDockerOption.ts @@ -0,0 +1,35 @@ +import inquirer from 'inquirer'; +import updateEnvFile from 'setup/updateEnvFile/updateEnvFile'; +import { askForDocker } from 'setup/askForDocker/askForDocker'; + +// Function to manage Docker setup +const askAndSetDockerOption = async (): Promise<void> => { + const { useDocker } = await inquirer.prompt({ + type: 'confirm', + name: 'useDocker', + message: 'Would you like to set up with Docker?', + default: false, + }); + + if (useDocker) { + console.log('Setting up with Docker...'); + updateEnvFile('USE_DOCKER', 'YES'); + const answers = await askForDocker(); + const DOCKER_PORT_NUMBER = answers; + updateEnvFile('DOCKER_PORT', DOCKER_PORT_NUMBER); + + const DOCKER_NAME = 'talawa-admin'; + console.log(` + + Run the commands below after setup:- + 1. docker build -t ${DOCKER_NAME} . + 2. docker run -d -p ${DOCKER_PORT_NUMBER}:${DOCKER_PORT_NUMBER} ${DOCKER_NAME} + + `); + } else { + console.log('Setting up without Docker...'); + updateEnvFile('USE_DOCKER', 'NO'); + } +}; + +export default askAndSetDockerOption; diff --git a/src/setup/askAndUpdatePort/askAndUpdatePort.ts b/src/setup/askAndUpdatePort/askAndUpdatePort.ts new file mode 100644 index 0000000000..5dfe997288 --- /dev/null +++ b/src/setup/askAndUpdatePort/askAndUpdatePort.ts @@ -0,0 +1,25 @@ +import updateEnvFile from 'setup/updateEnvFile/updateEnvFile'; +import { askForCustomPort } from 'setup/askForCustomPort/askForCustomPort'; +import inquirer from 'inquirer'; + +// Ask and update the custom port +const askAndUpdatePort = async (): Promise<void> => { + const { shouldSetCustomPortResponse } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldSetCustomPortResponse', + message: + 'Would you like to set up a custom port for running Talawa Admin without Docker?', + default: true, + }); + + if (shouldSetCustomPortResponse) { + const customPort = await askForCustomPort(); + if (customPort < 1024 || customPort > 65535) { + throw new Error('Port must be between 1024 and 65535'); + } + + updateEnvFile('PORT', String(customPort)); + } +}; + +export default askAndUpdatePort; diff --git a/src/setup/askAndUpdatePort/askForUpdatePort.spec.ts b/src/setup/askAndUpdatePort/askForUpdatePort.spec.ts new file mode 100644 index 0000000000..3f01605a55 --- /dev/null +++ b/src/setup/askAndUpdatePort/askForUpdatePort.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import askAndUpdatePort from './askAndUpdatePort'; +import { askForCustomPort } from 'setup/askForCustomPort/askForCustomPort'; +import updateEnvFile from 'setup/updateEnvFile/updateEnvFile'; +import inquirer from 'inquirer'; + +vi.mock('setup/askForCustomPort/askForCustomPort'); +vi.mock('setup/updateEnvFile/updateEnvFile'); +vi.mock('inquirer'); + +describe('askAndUpdatePort', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should update the port when user confirms and provides a valid port', async () => { + // Mock user confirmation and valid port + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldSetCustomPortResponse: true, + }); + vi.mocked(askForCustomPort).mockResolvedValueOnce(3000); + + // Act + await askAndUpdatePort(); + + // Assert + expect(updateEnvFile).toHaveBeenCalledWith('PORT', '3000'); + }); + + it('should not update the port when user declines', async () => { + // Mock user declining by returning false + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldSetCustomPortResponse: false, + }); + + // Act + await askAndUpdatePort(); + + // Assert + expect(updateEnvFile).not.toHaveBeenCalled(); + }); + + it('should throw an error for an invalid port', async () => { + // Mock user confirmation and invalid port + vi.mocked(inquirer.prompt).mockResolvedValueOnce({ + shouldSetCustomPortResponse: true, + }); + vi.mocked(askForCustomPort).mockResolvedValueOnce(800); + + // Act & Assert + await expect(askAndUpdatePort()).rejects.toThrowError( + 'Port must be between 1024 and 65535', + ); + }); +}); diff --git a/src/setup/askForDocker/askForDocker.spec.ts b/src/setup/askForDocker/askForDocker.spec.ts new file mode 100644 index 0000000000..a791b67da9 --- /dev/null +++ b/src/setup/askForDocker/askForDocker.spec.ts @@ -0,0 +1,69 @@ +import inquirer from 'inquirer'; +import { askForDocker } from './askForDocker'; +import { describe, test, expect, vi } from 'vitest'; + +vi.mock('inquirer'); + +describe('askForDocker', () => { + test('should return default Docker port if user provides no input', async () => { + vi.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + dockerAppPort: '4321', + }); + + const result = await askForDocker(); + expect(result).toBe('4321'); + }); + + test('should return user-provided valid port', async () => { + vi.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + dockerAppPort: '8080', + }); + + const result = await askForDocker(); + expect(result).toBe('8080'); + }); + + test('should reject non-numeric input with validation error', async () => { + // Mock the validation function to simulate an error for non-numeric input + vi.spyOn(inquirer, 'prompt').mockImplementationOnce(() => { + throw new Error( + 'Please enter a valid port number between 1024 and 65535', + ); + }); + + await expect(askForDocker()).rejects.toThrow( + 'Please enter a valid port number between 1024 and 65535', + ); + }); + + test('should reject port outside valid range with validation error', async () => { + // Mock the validation function to simulate an error for an out-of-range port + vi.spyOn(inquirer, 'prompt').mockImplementationOnce(() => { + throw new Error( + 'Please enter a valid port number between 1024 and 65535', + ); + }); + + await expect(askForDocker()).rejects.toThrow( + 'Please enter a valid port number between 1024 and 65535', + ); + }); + + test('should handle edge case: maximum valid port', async () => { + vi.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + dockerAppPort: '65535', + }); + + const result = await askForDocker(); + expect(result).toBe('65535'); + }); + + test('should handle edge case: minimum valid port', async () => { + vi.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + dockerAppPort: '1024', + }); + + const result = await askForDocker(); + expect(result).toBe('1024'); + }); +}); diff --git a/src/setup/askForDocker/askForDocker.ts b/src/setup/askForDocker/askForDocker.ts new file mode 100644 index 0000000000..fa926839d4 --- /dev/null +++ b/src/setup/askForDocker/askForDocker.ts @@ -0,0 +1,99 @@ +import inquirer from 'inquirer'; +import { askForTalawaApiUrl } from '../askForTalawaApiUrl/askForTalawaApiUrl'; +import updateEnvFile from '../updateEnvFile/updateEnvFile'; + +// Mock implementation of checkConnection +const checkConnection = async (): Promise<boolean> => { + // Simulate checking connection + return true; // Replace with actual connection check logic +}; + +// Function to ask for Docker port +export const askForDocker = async (): Promise<string> => { + const answers = await inquirer.prompt<{ dockerAppPort: string }>([ + { + type: 'input', + name: 'dockerAppPort', + message: 'Enter the port to expose Docker (default: 4321):', + default: '4321', + validate: (input: string) => { + const port = Number(input); + if (Number.isNaN(port) || port < 1024 || port > 65535) { + return 'Please enter a valid port number between 1024 and 65535'; + } + return true; + }, + }, + ]); + + return answers.dockerAppPort; +}; + +// Function to ask and update Talawa API URL +export const askAndUpdateTalawaApiUrl = async (): Promise<void> => { + try { + const { shouldSetTalawaApiUrlResponse } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldSetTalawaApiUrlResponse', + message: 'Would you like to set up Talawa API endpoint?', + default: true, + }); + + if (shouldSetTalawaApiUrlResponse) { + let endpoint = ''; + let isConnected = false; + let retryCount = 0; + const MAX_RETRIES = 3; + while (!isConnected && retryCount < MAX_RETRIES) { + try { + endpoint = await askForTalawaApiUrl(); + const url = new URL(endpoint); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Invalid URL protocol. Must be http or https'); + } + isConnected = await checkConnection(); + if (!isConnected) { + console.log( + `Connection attempt ${retryCount + 1}/${MAX_RETRIES} failed`, + ); + } + } catch (error) { + console.error('Error checking connection:', error); + isConnected = false; + } + retryCount++; + } + if (!isConnected) { + throw new Error( + 'Failed to establish connection after maximum retry attempts', + ); + } + updateEnvFile('REACT_APP_TALAWA_URL', endpoint); + const websocketUrl = endpoint.replace(/^http(s)?:\/\//, 'ws$1://'); + try { + const wsUrl = new URL(websocketUrl); + if (!['ws:', 'wss:'].includes(wsUrl.protocol)) { + throw new Error('Invalid WebSocket protocol'); + } + updateEnvFile('REACT_APP_BACKEND_WEBSOCKET_URL', websocketUrl); + } catch { + throw new Error('Invalid WebSocket URL generated: '); + } + + if (endpoint.includes('localhost')) { + const dockerUrl = endpoint.replace('localhost', 'host.docker.internal'); + try { + const url = new URL(dockerUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Invalid Docker URL protocol'); + } + } catch { + throw new Error('Invalid Docker URL generated'); + } + updateEnvFile('REACT_APP_DOCKER_TALAWA_URL', dockerUrl); + } + } + } catch (error) { + console.error('Error setting up Talawa API URL:', error); + } +}; diff --git a/src/setup/updateEnvFile/updateEnvFile.spec.ts b/src/setup/updateEnvFile/updateEnvFile.spec.ts new file mode 100644 index 0000000000..c3ff4a5242 --- /dev/null +++ b/src/setup/updateEnvFile/updateEnvFile.spec.ts @@ -0,0 +1,88 @@ +import fs from 'fs'; +import updateEnvFile from './updateEnvFile'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; + +/** + * Unit tests for the `updateEnvFile` function. + * + * These tests verify: + * - Updating an existing key in the `.env` file. + * - Appending a new key if it does not exist in the `.env` file. + * - Handling an empty `.env` file. + */ + +vi.mock('fs'); + +describe('updateEnvFile', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should update an existing key in the .env file', () => { + const envContent = 'EXISTING_KEY=old_value\nANOTHER_KEY=another_value\n'; + const updatedEnvContent = + 'EXISTING_KEY=new_value\nANOTHER_KEY=another_value\n'; + + // Mock file system read and write operations + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(envContent); + const writeMock = vi.spyOn(fs, 'writeFileSync'); + + updateEnvFile('EXISTING_KEY', 'new_value'); + + // Verify that the updated content is written to the file + expect(writeMock).toHaveBeenCalledWith('.env', updatedEnvContent, 'utf8'); + }); + + it('should append a new key if it does not exist in the .env file', () => { + const envContent = 'EXISTING_KEY=existing_value\n'; + const newKey = 'NEW_KEY=new_value'; + + // Mock file system read and append operations + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(envContent); + const appendMock = vi.spyOn(fs, 'appendFileSync'); + + updateEnvFile('NEW_KEY', 'new_value'); + + // Verify that the new key is appended to the file + expect(appendMock).toHaveBeenCalledWith('.env', `\n${newKey}`, 'utf8'); + }); + + it('should handle an empty .env file and append the new key', () => { + const envContent = ''; + const newKey = 'NEW_KEY=new_value'; + + // Mock file system read and append operations + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(envContent); + const appendMock = vi.spyOn(fs, 'appendFileSync'); + + updateEnvFile('NEW_KEY', 'new_value'); + + // Verify that the new key is appended to the file + expect(appendMock).toHaveBeenCalledWith('.env', `\n${newKey}`, 'utf8'); + }); + + it('should not throw errors when .env file does not exist and create the file with the key', () => { + const newKey = 'NEW_KEY=new_value'; + + const appendMock = vi.spyOn(fs, 'appendFileSync'); + + updateEnvFile('NEW_KEY', 'new_value'); + + // Verify that the new key is appended to the file + expect(appendMock).toHaveBeenCalledWith('.env', `\n${newKey}`, 'utf8'); + }); + + it('should correctly handle keys with special characters', () => { + const envContent = 'EXISTING_KEY=old_value\n'; + const updatedEnvContent = 'EXISTING_KEY=value_with=special_characters\n'; + + // Mock file system read and write operations + vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(envContent); + const writeMock = vi.spyOn(fs, 'writeFileSync'); + + updateEnvFile('EXISTING_KEY', 'value_with=special_characters'); + + // Verify that the updated content is written to the file + expect(writeMock).toHaveBeenCalledWith('.env', updatedEnvContent, 'utf8'); + }); +}); diff --git a/src/setup/updateEnvFile/updateEnvFile.ts b/src/setup/updateEnvFile/updateEnvFile.ts new file mode 100644 index 0000000000..93ba918749 --- /dev/null +++ b/src/setup/updateEnvFile/updateEnvFile.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; + +const updateEnvFile = (key: string, value: string): void => { + try { + const currentEnvContent = fs.readFileSync('.env', 'utf8'); + const keyRegex = new RegExp(`^${key}=.*$`, 'm'); + if (keyRegex.test(currentEnvContent)) { + const updatedEnvContent = currentEnvContent.replace( + keyRegex, + `${key}=${value}`, + ); + fs.writeFileSync('.env', updatedEnvContent, 'utf8'); + } else { + fs.appendFileSync('.env', `\n${key}=${value}`, 'utf8'); + } + } catch (error) { + console.error('Error updating the .env file:', error); + } +}; + +export default updateEnvFile; diff --git a/src/setup/validateRecaptcha/validateRecaptcha.test.ts b/src/setup/validateRecaptcha/validateRecaptcha.spec.ts similarity index 95% rename from src/setup/validateRecaptcha/validateRecaptcha.test.ts rename to src/setup/validateRecaptcha/validateRecaptcha.spec.ts index c77c9ed62b..cd1ff7125a 100644 --- a/src/setup/validateRecaptcha/validateRecaptcha.test.ts +++ b/src/setup/validateRecaptcha/validateRecaptcha.spec.ts @@ -1,3 +1,4 @@ +import { describe, it, expect } from 'vitest'; import { validateRecaptcha } from './validateRecaptcha'; describe('validateRecaptcha', () => { diff --git a/src/style/app.module.css b/src/style/app.module.css index b9f7d49328..d9645b2bdd 100644 --- a/src/style/app.module.css +++ b/src/style/app.module.css @@ -48,7 +48,8 @@ --subtle-blue-grey-hover: #5f7e91; --white: #fff; --black: black; - + --rating-star-filled: #ff6d75; + --rating-star-hover: #ff3d47; /* Background and Border */ --table-bg: #eaebef; --tablerow-bg: #eff1f7; @@ -90,9 +91,11 @@ --bs-gray-400: #9ca3af; --bs-gray-300: #d1d5db; --toggle-button-bg: #1e4e8c; - --table-head-bg: var(--bs-primary, var(--blue-color)); + + --table-head-bg: var(--blue-subtle, var(--blue-color)); --table-head-color: var(--bs-white, var(--white-color)); - --table-header-color: var(--bs-greyish-black, var(--black-color)); + + --table-header-color: var(--bs-white, var(--bs-gray-300)); --input-area-color: #f1f3f6; --date-picker-background: #f2f2f2; --grey-bg-color-dark: #707070; @@ -105,6 +108,7 @@ --breakpoint-tablet: 768px; --breakpoint-desktop: 1024px; } + .fonts { color: var(--grey-bg-color-dark); } @@ -270,13 +274,13 @@ } .dropdown { - background-color: var(--bs-white); + background-color: var(--bs-white) !important; border: 1px solid var(--brown-color); - color: var(--brown-color); + color: var(--brown-color) !important; position: relative; display: inline-block; - margin-top: 10px; - margin-bottom: 10px; + /* margin-top: 10px; + margin-bottom: 10px; */ } .dropdown:is(:hover, :focus, :active, :focus-visible, .show) { @@ -939,7 +943,17 @@ hr { .card { width: fit-content; } +.cardContainer { + width: 300px; +} +.ratingFilled { + color: var(--rating-star-filled); /* Color for filled stars */ +} + +.ratingHover { + color: var(--rating-star-hover); /* Color for star on hover */ +} .cardHeader { padding: 1.25rem 1rem 1rem 1rem; border-bottom: 1px solid var(--bs-gray-200); @@ -1005,7 +1019,7 @@ hr { } .justifyspOrganizationEvents { - display: flex; + /* display: flex; */ justify-content: space-between; margin-top: 20px; } @@ -1449,6 +1463,7 @@ hr { flex-direction: column; justify-content: center; } + .btnsContainer .btnsBlock { display: block; margin-top: 1rem; @@ -1486,13 +1501,27 @@ hr { margin: 1.5rem 0; } + .btn { + flex-direction: column; + justify-content: center; + } + + .btnsContainer > div { + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box; + } + .btnsContainer .btnsBlock { + display: block; margin: 1.5rem 0 0 0; justify-content: space-between; } .btnsContainer .btnsBlock button { - margin: 0; + margin-bottom: 1rem; + margin-right: 0; + width: 100%; } .btnsContainer .btnsBlock div button { @@ -1565,6 +1594,12 @@ hr { align-items: center; } +@media (max-width: 1020px) { + .btnsContainer .btnsBlock button { + margin-left: 0; + } +} + .errorMessage { margin-top: 25%; display: flex; @@ -1745,14 +1780,6 @@ input[type='radio']:checked + label:hover { box-shadow: 0 1px 1px var(--brand-primary); } -.dropdowns { - background-color: var(--bs-white); - border: 1px solid var(--light-green); - position: relative; - display: inline-block; - color: var(--light-green); -} - .chipIcon { height: 0.9rem !important; } @@ -4605,26 +4632,39 @@ button[data-testid='createPostBtn'] { display: flex; position: relative; width: 100%; - margin-top: 10px; + overflow: hidden; /* Ensures content doesn't overflow the card */ justify-content: center; + border: 1px solid #ccc; } .previewVenueModal img { width: 400px; height: auto; + object-fit: cover; /* Ensures the image stays within the boundaries */ } .closeButtonP { position: absolute; top: 0px; right: 0px; + width: 32px; /* Make the button circular */ + height: 32px; /* Make the button circular */ background: transparent; transform: scale(1.2); cursor: pointer; + border-radius: 50%; border: none; color: var(--grey-dark); font-weight: 600; font-size: 16px; + transition: + background-color 0.3s, + transform 0.3s; +} + +.closeButtonP:hover { + transform: scale(1.1); /* Slightly enlarge on hover */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); /* Add a shadow on hover */ } /* YearlyEventCalender.tsx */ @@ -5060,12 +5100,15 @@ button[data-testid='createPostBtn'] { position: relative; width: 100%; margin-top: 10px; + overflow: hidden; /* Ensures content doesn't overflow the card */ justify-content: center; + border: 1px solid #ccc; } .previewAdvertisementRegister img { width: 400px; height: auto; + object-fit: cover; /* Ensures the image stays within the boundaries */ } .previewAdvertisementRegister video { @@ -5074,14 +5117,27 @@ button[data-testid='createPostBtn'] { } .closeButtonAdvertisementRegister { + position: absolute; + top: 0px; + right: 0px; + width: 32px; /* Make the button circular */ + height: 32px; /* Make the button circular */ background: transparent; + transform: scale(1.2); cursor: pointer; + border-radius: 50%; border: none; color: var(--grey-dark); font-weight: 600; font-size: 16px; - margin-bottom: 10px; - cursor: pointer; + transition: + background-color 0.3s, + transform 0.3s; +} + +.closeButtonAdvertisementRegister:hover { + transform: scale(1.1); /* Slightly enlarge on hover */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); /* Add a shadow on hover */ } .buttonAdvertisementRegister { @@ -5419,7 +5475,7 @@ button[data-testid='createPostBtn'] { } .flex_grow { - flex-grow: 1; + flex-grow: 0.5; } .space { @@ -5448,6 +5504,34 @@ button[data-testid='createPostBtn'] { margin-left: 5px; } +@media (max-width: 520px) { + .calendar__header { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .space { + display: block !important; + text-align: center; + } + + .space > * { + width: 100%; + margin-bottom: 10px; + } + + /* .input { + width: 100%; + } + + .createButton { + margin: 0 auto; + width: 100%; + } */ +} + /* EventListCardModals.tsx */ .dispflexEventListCardModals { diff --git a/src/subComponents/SortingButton.tsx b/src/subComponents/SortingButton.tsx new file mode 100644 index 0000000000..7ce7703d39 --- /dev/null +++ b/src/subComponents/SortingButton.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Dropdown } from 'react-bootstrap'; +import SortIcon from '@mui/icons-material/Sort'; +import FilterAltOutlined from '@mui/icons-material/FilterAltOutlined'; +import PropTypes from 'prop-types'; +import styles from '../style/app.module.css'; + +interface InterfaceSortingOption { + /** The label to display for the sorting option */ + label: string; + /** The value associated with the sorting option */ + value: string; +} + +interface InterfaceSortingButtonProps { + /** The title attribute for the Dropdown */ + title?: string; + /** The list of sorting options to display in the Dropdown */ + sortingOptions: InterfaceSortingOption[]; + /** The currently selected sorting option */ + selectedOption?: string; + /** Callback function to handle sorting option change */ + onSortChange: (value: string) => void; + /** The prefix for data-testid attributes for testing */ + dataTestIdPrefix: string; + /** The data-testid attribute for the Dropdown */ + dropdownTestId?: string; + /** Custom class name for the Dropdown */ + className?: string; + /** Optional prop for custom button label */ + buttonLabel?: string; + /** Type to determine the icon to display: 'sort' or 'filter' */ + type?: 'sort' | 'filter'; +} + +/** + * SortingButton component renders a Dropdown with sorting options. + * It allows users to select a sorting option and triggers a callback on selection. + * + * @param props - The properties for the SortingButton component. + * @returns The rendered SortingButton component. + */ +const SortingButton: React.FC<InterfaceSortingButtonProps> = ({ + title, + sortingOptions, + selectedOption, + onSortChange, + dataTestIdPrefix, + dropdownTestId, + className = styles.dropdown, + buttonLabel, + type = 'sort', +}) => { + // Determine the icon based on the type + const IconComponent = type === 'filter' ? FilterAltOutlined : SortIcon; + + return ( + <Dropdown aria-expanded="false" title={title} data-testid={dropdownTestId}> + <Dropdown.Toggle + variant={selectedOption === '' ? 'outline-success' : 'success'} + data-testid={`${dataTestIdPrefix}`} + className={className} + > + <IconComponent className={'me-1'} /> {/* Use the appropriate icon */} + {buttonLabel || selectedOption} + {/* Use buttonLabel if provided, otherwise use selectedOption */} + </Dropdown.Toggle> + <Dropdown.Menu> + {sortingOptions.map((option) => ( + <Dropdown.Item + key={option.value} + onClick={() => onSortChange(option.value)} + data-testid={`${option.value}`} + className={styles.dropdownItem} + > + {option.label} + </Dropdown.Item> + ))} + </Dropdown.Menu> + </Dropdown> + ); +}; + +SortingButton.propTypes = { + title: PropTypes.string, + sortingOptions: PropTypes.arrayOf( + PropTypes.exact({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }).isRequired, + ).isRequired, + selectedOption: PropTypes.string, + onSortChange: PropTypes.func.isRequired, + dataTestIdPrefix: PropTypes.string.isRequired, + dropdownTestId: PropTypes.string, + buttonLabel: PropTypes.string, // Optional prop for custom button label + type: PropTypes.oneOf(['sort', 'filter']), // Type to determine the icon +}; + +export default SortingButton; diff --git a/src/utils/StaticMockLink.spec.ts b/src/utils/StaticMockLink.spec.ts new file mode 100644 index 0000000000..15c5cc3443 --- /dev/null +++ b/src/utils/StaticMockLink.spec.ts @@ -0,0 +1,725 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { StaticMockLink, mockSingleLink } from './StaticMockLink'; +import type { Observer } from '@apollo/client'; +import type { MockedResponse } from '@apollo/react-testing'; +import { gql, Observable } from '@apollo/client'; +import { print } from 'graphql'; +import type { FetchResult } from '@apollo/client/link/core'; +import { equal } from '@wry/equality'; +class TestableStaticMockLink extends StaticMockLink { + public setErrorHandler( + handler: (error: unknown, observer?: Observer<FetchResult>) => false | void, + ): void { + this.onError = handler; + } +} + +const TEST_QUERY = gql` + query TestQuery($id: ID!) { + item(id: $id) { + id + name + } + } +`; +const mockQuery = gql` + query TestQuery { + test { + id + name + } + } +`; +const sampleQuery = gql` + query SampleQuery($id: ID!) { + user(id: $id) { + id + name + } + } +`; + +const sampleResponse = { + data: { + user: { + id: '1', + name: 'Test User', + __typename: 'User', + }, + }, +}; +describe('StaticMockLink', () => { + const sampleQuery = gql` + query SampleQuery($id: ID!) { + user(id: $id) { + id + name + } + } + `; + + const sampleVariables = { id: '1' }; + + const sampleResponse = { + data: { + user: { + id: '1', + name: 'John Doe', + __typename: 'User', + }, + }, + }; + + let mockLink: StaticMockLink; + + beforeEach((): void => { + mockLink = new StaticMockLink([], true); + }); + + test('should create instance with empty mocked responses', () => { + expect(mockLink).toBeInstanceOf(StaticMockLink); + expect(mockLink.addTypename).toBe(true); + }); + + test('should add mocked response', () => { + const mockedResponse = { + request: { + query: sampleQuery, + variables: sampleVariables, + }, + result: sampleResponse, + }; + + mockLink.addMockedResponse(mockedResponse); + // This is Mocked Response + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + next: (response) => { + expect(response).toEqual(sampleResponse); + }, + complete: () => { + resolve(); + }, + }); + }); + }); + + test('should handle delayed responses', () => { + vi.useFakeTimers(); // Start using fake timers + const delay = 100; + const mockedResponse = { + request: { + query: sampleQuery, + variables: sampleVariables, + }, + result: sampleResponse, + delay, + }; + + mockLink.addMockedResponse(mockedResponse); + + let completed = false; + + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + next: (response) => { + expect(response).toEqual(sampleResponse); + completed = true; + }, + complete: () => { + expect(completed).toBe(true); + resolve(); + }, + error: (error) => { + throw error; + }, + }); + + vi.advanceTimersByTime(delay); // Advance time by the delay + }).finally(() => { + vi.useRealTimers(); // Restore real timers + }); + }); + + test('should handle errors in response', () => { + const errorResponse = { + request: { + query: sampleQuery, + variables: sampleVariables, + }, + error: new Error('GraphQL Error'), + }; + + mockLink.addMockedResponse(errorResponse); + + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toBe('GraphQL Error'); + resolve(); + }, + }); + }); + }); + + test('should handle dynamic results using newData', () => { + const dynamicResponse = { + request: { + query: sampleQuery, + variables: { id: '2' }, // Changed to match the request variables + }, + result: sampleResponse, + newData: (variables: { id: string }) => ({ + data: { + user: { + id: variables.id, + name: `User ${variables.id}`, + __typename: 'User', + }, + }, + }), + }; + + mockLink.addMockedResponse(dynamicResponse); + + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: { id: '2' }, // Matches the request variables in mocked response + }); + + observable?.subscribe({ + next: (response) => { + expect(response).toEqual({ + data: { + user: { + id: '2', + name: 'User 2', + __typename: 'User', + }, + }, + }); + }, + complete: () => { + resolve(); + }, + error: (error) => { + // Add error handling to help debug test failures + console.error('Test error:', error); + throw error; + }, + }); + }); + }); + test('should error when no matching response is found', () => { + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toContain( + 'No more mocked responses for the query', + ); + resolve(); + }, + }); + }); + }); +}); + +describe('mockSingleLink', () => { + test('should create StaticMockLink with default typename', () => { + const mockedResponse = { + request: { + query: gql` + query { + hello + } + `, + variables: {}, + }, + result: { data: { hello: 'world' } }, + }; + + const link = mockSingleLink(mockedResponse); + expect(link).toBeInstanceOf(StaticMockLink); + }); + + test('should create StaticMockLink with specified typename setting', () => { + const mockedResponse = { + request: { + query: gql` + query { + hello + } + `, + variables: {}, + }, + result: { data: { hello: 'world' } }, + }; + + const link = mockSingleLink(mockedResponse, false); + expect((link as StaticMockLink).addTypename).toBe(false); + }); + + test('should handle non-matching variables between request and mocked response', () => { + const mockLink = new StaticMockLink([]); + const mockedResponse = { + request: { + query: sampleQuery, + variables: { id: '1' }, + }, + result: sampleResponse, + }; + + mockLink.addMockedResponse(mockedResponse); + + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: { id: '2' }, // Different variables + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toContain('No more mocked responses'); + resolve(); + }, + }); + }); + }); + + test('should handle matching query but mismatched variable structure', () => { + const mockLink = new StaticMockLink([]); + const mockedResponse = { + request: { + query: sampleQuery, + variables: { id: '1', extra: 'field' }, + }, + result: sampleResponse, + }; + + mockLink.addMockedResponse(mockedResponse); + + return new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: { id: '1' }, // Missing extra field + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toContain('No more mocked responses'); + resolve(); + }, + }); + }); + }); + + test('should handle onError behavior correctly', async () => { + const mockLink = new TestableStaticMockLink([], true); + const handlerSpy = vi.fn().mockReturnValue(undefined); // Return undefined to trigger error throw + + mockLink.setErrorHandler(handlerSpy); + + await new Promise<void>((resolve) => { + const observable = mockLink.request({ + query: gql` + query TestQuery { + field + } + `, + variables: {}, + }); + + observable?.subscribe({ + next: () => { + throw new Error('Should not succeed'); + }, + error: (error) => { + // Verify the error handler was called + expect(handlerSpy).toHaveBeenCalledTimes(1); + + // Verify we got the expected error + expect(error.message).toContain('No more mocked responses'); + + resolve(); + }, + }); + }); + }, 10000); + it('should throw an error if a mocked response lacks result and error', () => { + const mockedResponses = [ + { + request: { query: mockQuery }, + // Missing `result` and `error` + }, + ]; + + const link = new StaticMockLink(mockedResponses); + + const operation = { + query: mockQuery, + variables: {}, + }; + + const observable = link.request(operation); + + expect(observable).toBeInstanceOf(Observable); + + // Subscribe to the observable and expect an error + observable?.subscribe({ + next: () => { + // This shouldn't be called + throw new Error('next() should not be called'); + }, + error: (err) => { + // Check the error message + expect(err.message).toContain( + 'Mocked response should contain either result or error', + ); + }, + complete: () => { + // This shouldn't be called + throw new Error('complete() should not be called'); + }, + }); + }); + + it('should return undefined when no mocked response matches operation variables', () => { + const mockedResponses = [ + { + request: { + query: mockQuery, + variables: { id: '123' }, + }, + result: { data: { test: { id: '123', name: 'Test Name' } } }, + }, + ]; + + const link = new StaticMockLink(mockedResponses); + + // Simulate operation with unmatched variables + const operation = { + query: mockQuery, + variables: { id: '999' }, + }; + + const key = JSON.stringify({ + query: link.addTypename + ? print(mockQuery) // Add typename if necessary + : print(mockQuery), + }); + + const mockedResponsesByKey = link['_mockedResponsesByKey'][key]; + + // Emulate the internal logic + let responseIndex = -1; + const response = (mockedResponsesByKey || []).find((res, index) => { + const requestVariables = operation.variables || {}; + const mockedResponseVariables = res.request.variables || {}; + if (equal(requestVariables, mockedResponseVariables)) { + responseIndex = index; + return true; + } + return false; + }); + + // Assertions + expect(response).toBeUndefined(); + expect(responseIndex).toBe(-1); + }); + + test('should initialize with empty mocked responses array', () => { + // Test with null/undefined + const mockLinkNull = new StaticMockLink( + null as unknown as readonly MockedResponse[], + ); + expect(mockLinkNull).toBeInstanceOf(StaticMockLink); + + // Test with defined responses + const mockResponses: readonly MockedResponse[] = [ + { + request: { + query: sampleQuery, + variables: { id: '1' }, + }, + result: { + data: { + user: { + id: '1', + name: 'Test User', + __typename: 'User', + }, + }, + }, + }, + { + request: { + query: sampleQuery, + variables: { id: '2' }, + }, + result: { + data: { + user: { + id: '2', + name: 'Test User 2', + __typename: 'User', + }, + }, + }, + }, + ]; + + const mockLink = new StaticMockLink(mockResponses, true); + + // Verify responses were added via constructor + const observable1 = mockLink.request({ + query: sampleQuery, + variables: { id: '1' }, + }); + + const observable2 = mockLink.request({ + query: sampleQuery, + variables: { id: '2' }, + }); + + return Promise.all([ + new Promise<void>((resolve) => { + observable1?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('1'); + resolve(); + }, + }); + }), + new Promise<void>((resolve) => { + observable2?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('2'); + resolve(); + }, + }); + }), + ]); + }); + + test('should handle undefined operation variables', () => { + const mockLink = new StaticMockLink([]); + const mockedResponse: MockedResponse = { + request: { + query: sampleQuery, + }, + result: { + data: { + user: { + id: '1', + name: 'Test User', + __typename: 'User', + }, + }, + }, + }; + + mockLink.addMockedResponse(mockedResponse); + + const observable = mockLink.request({ + query: sampleQuery, + // Intentionally omitting variables + }); + + return new Promise<void>((resolve) => { + observable?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('1'); + resolve(); + }, + }); + }); + }); + + test('should handle response with direct result value', async () => { + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + result: { + data: { + item: { + id: '1', + name: 'Test Item', + __typename: 'Item', + }, + }, + }, + }; + + const link = new StaticMockLink([mockResponse]); + + return new Promise<void>((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next(response) { + expect(response).toEqual(mockResponse.result); + resolve(); + }, + error: reject, + }); + }); + }); + + test('should handle response with result function', async () => { + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + result: (variables: { id: string }) => ({ + data: { + item: { + id: variables.id, + name: `Test Item ${variables.id}`, + __typename: 'Item', + }, + }, + }), + }; + + const link = new StaticMockLink([mockResponse]); + + return new Promise<void>((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next(response) { + expect(response).toEqual({ + data: { + item: { + id: '1', + name: 'Test Item 1', + __typename: 'Item', + }, + }, + }); + resolve(); + }, + error: reject, + }); + }); + }); + + test('should handle response with error', async () => { + const testError = new Error('Test error'); + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + error: testError, + }; + + const link = new StaticMockLink([mockResponse]); + + return new Promise<void>((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next() { + reject(new Error('Should not have called next')); + }, + error(error) { + expect(error).toBe(testError); + resolve(); + }, + }); + }); + }); + + test('should respect response delay', async () => { + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + result: { + data: { + item: { + id: '1', + name: 'Test Item', + __typename: 'Item', + }, + }, + }, + delay: 50, + }; + + const link = new StaticMockLink([mockResponse]); + const startTime = Date.now(); + + return new Promise<void>((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next(response) { + const elapsed = Date.now() - startTime; + expect(elapsed).toBeGreaterThanOrEqual(50); + expect(response).toEqual(mockResponse.result); + resolve(); + }, + error: reject, + }); + }); + }); +});