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 { - 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 => { + 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 => { - 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 => { const { shouldLogErrors } = await inquirer.prompt({ type: 'confirm', name: 'shouldLogErrors', @@ -169,17 +52,37 @@ export async function main(): Promise { }); 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 { + 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({
-
- - - {viewType} - - - - {ViewType.MONTH} - - - {ViewType.DAY} - - - {ViewType.YEAR} - - - -
-
- - - {t('eventType')} - - - - Events - - - Workshops - - - -
+ + console.log(`Selected: ${value}`)} + dataTestIdPrefix="eventType" + className={styles.dropdown} + buttonLabel={t('eventType')} + /> -
-
-
+
+ +
+
- - - Sort - Filter: {filteringBy} - - } - onSelect={(eventKey) => setFilteringBy(eventKey as FilterPeriod)} - > - This Month - This Year - All - - - Sort - Sort - - } - onSelect={ - /*istanbul ignore next*/ - (eventKey) => setSortOrder(eventKey as 'ascending' | 'descending') + setFilteringBy(value as FilterPeriod)} + dataTestIdPrefix="filter-dropdown" + className={`${styles.dropdown} mx-4`} + buttonLabel="Filter" + /> + + setSortOrder(value as 'ascending' | 'descending') } - > - Ascending - Descending - + dataTestIdPrefix="sort-dropdown" + buttonLabel="Sort" + />
- {/*

{totalMembers}

*/} 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 ( <> - +

Average Review Score

@@ -50,13 +50,9 @@ export const AverageRating = ({ data }: ModalPropType): JSX.Element => { icon={} size="medium" emptyIcon={} - 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, }} />
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 {

{name}

{/* Description of the organization */} -
- {userData?.organizations[0].description} -
+
+ +
+ {/* Display the organization address if available */} - {address && address.city && ( + {address?.city && (
-
- {address.line1}, - {address.city}, - {address.countryCode} -
+
)} {/* 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 (`
`) containing the truncated or full text. + * + * @example + * ```tsx + * + * ``` + */ +const TruncatedText: React.FC = ({ + text, + maxWidthOverride, +}) => { + const [truncatedText, setTruncatedText] = useState(''); + const textRef = useRef(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 ( +
+ {truncatedText} +
+ ); +}; + +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 void>( + callback: T, + delay: number, +): { debouncedCallback: (...args: Parameters) => void; cancel: () => void } { + const timeoutRef = useRef(); + + /** + * 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) => { + 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( + + + + + + + , + ); + + // 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 = ({
- - - - {tCommon('sort')} - - - setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {tCommon('createdLatest')} - - setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {tCommon('createdEarliest')} - - - - - - - {t('status')} - - - setStatus(null)} - data-testid="statusAll" - > - {tCommon('all')} - - setStatus(CategoryStatus.Active)} - data-testid="statusActive" - > - {tCommon('active')} - - setStatus(CategoryStatus.Disabled)} - data-testid="statusDisabled" - > - {tCommon('disabled')} - - - + + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + className={styles.dropdown} + /> + + setStatus(value === 'all' ? null : (value as CategoryStatus)) + } + dataTestIdPrefix="filter" + buttonLabel={t('status')} + className={styles.dropdown} + />
+
{user.user.email}
- + sortingOptions={[ + { label: tCommon('Latest'), value: 'DESCENDING' }, + { label: tCommon('Oldest'), value: 'ASCENDING' }, + ]} + selectedOption={assignedMemberSortOrder} + onSortChange={(value) => + setAssignedMemberSortOrder(value as SortedByType) + } + dataTestIdPrefix="sortPeople" + buttonLabel={tCommon('sort')} + />
-
- -
+ {superAdmin && (
+ {/* Text Infos for list */} + {!isLoading && (!orgsData?.organizationsConnection || orgsData.organizationsConnection.length === 0) && @@ -485,6 +463,7 @@ function orgList(): JSX.Element {
+
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 {
- - + 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')} + />
))}
- - {/* Dropdown menu for selecting settings category */} - - - {t(tab)} - - - {/* Render dropdown items for each settings category */} - {settingtabs.map((setting, index) => ( - setTab(setting)} - className={tab === setting ? 'text-secondary' : ''} - > - {t(setting)} - - ))} - - 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 ; } @@ -388,89 +388,57 @@ function organizationActionItems(): JSX.Element {
-
- - - - {tCommon('searchBy', { item: '' })} - - - setSearchBy('assignee')} - data-testid="assignee" - > - {t('assignee')} - - setSearchBy('category')} - data-testid="category" - > - {t('category')} - - - - - - - {tCommon('sort')} - - - setSortBy('dueDate_DESC')} - data-testid="dueDate_DESC" - > - {t('latestDueDate')} - - setSortBy('dueDate_ASC')} - data-testid="dueDate_ASC" - > - {t('earliestDueDate')} - - - - - - - {t('status')} - - - setStatus(null)} - data-testid="statusAll" - > - {tCommon('all')} - - setStatus(ItemStatus.Pending)} - data-testid="statusPending" - > - {tCommon('pending')} - - setStatus(ItemStatus.Completed)} - data-testid="statusCompleted" - > - {tCommon('completed')} - - - -
+ + setSearchBy(value as 'assignee' | 'category') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + className={styles.dropdown} // Pass a custom class name if needed + /> + + setSortBy(value as 'dueDate_DESC' | 'dueDate_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + className={styles.dropdown} // Pass a custom class name if needed + /> + + setStatus(value === 'all' ? null : (value as ItemStatus)) + } + dataTestIdPrefix="filter" + buttonLabel={t('status')} + className={styles.dropdown} // Pass a custom class name if needed + />
- - - - {tCommon('sort')} - - - setSortBy('fundingGoal_ASC')} - data-testid="fundingGoal_ASC" - > - {t('lowestGoal')} - - setSortBy('fundingGoal_DESC')} - data-testid="fundingGoal_DESC" - > - {t('highestGoal')} - - setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - - setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - - - + + setSortBy( + value as + | 'fundingGoal_ASC' + | 'fundingGoal_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
-
- - - - {tCommon('sort')} - - - setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('createdLatest')} - - setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('createdEarliest')} - - - -
+ + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
- - - - {t('sort')} - - - { - setState(2); - }} - > - - {tCommon('users')} - - - { - setState(0); - }} - > - - {tCommon('members')} - - - { - setState(1); - }} - > - - {tCommon('admins')} - - - - +
- +
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 ( <> @@ -312,40 +314,24 @@ function OrganizationTags(): JSX.Element { />
- + : tCommon('Oldest') + } + onSortChange={handleSortChange} + dataTestIdPrefix="sortTags" + className={styles.dropdown} + />
-
-
- - -
+
+ +
- - - - {tagSortOrder === 'DESCENDING' - ? tCommon('Latest') - : tCommon('Oldest')} - - - setTagSortOrder('DESCENDING')} - > - {tCommon('Latest')} - - setTagSortOrder('ASCENDING')} - > - {tCommon('Oldest')} - - - + setTagSortOrder(value as SortedByType)} + dataTestIdPrefix="sortTags" + buttonLabel={tCommon('sort')} + />
- {/* Dropdown menu for sorting campaigns */} - - - - {tCommon('sort')} - - - setSortBy('fundingGoal_ASC')} - data-testid="fundingGoal_ASC" - > - {t('lowestGoal')} - - setSortBy('fundingGoal_DESC')} - data-testid="fundingGoal_DESC" - > - {t('highestGoal')} - - setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - - setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - - - + + setSortBy( + value as + | 'fundingGoal_ASC' + | 'fundingGoal_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
{/* 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 => {
-
- +
+ + setSearchBy(value as 'pledgers' | 'campaigns') + } + dataTestIdPrefix="searchByDrpdwn" + buttonLabel={t('searchBy')} + /> - - - - {tCommon('sort')} - - - setSortBy('amount_ASC')} - data-testid="amount_ASC" - > - {t('lowestAmount')} - - setSortBy('amount_DESC')} - data-testid="amount_DESC" - > - {t('highestAmount')} - - setSortBy('endDate_DESC')} - data-testid="endDate_DESC" - > - {t('latestEndDate')} - - setSortBy('endDate_ASC')} - data-testid="endDate_ASC" - > - {t('earliestEndDate')} - - - + + setSortBy( + value as + | 'amount_ASC' + | 'amount_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
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( 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( + + + + + + + + + , + ); + }); + + 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( + + + + + + + + + , + ); + }); + + 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 => { 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( + + + + + + + + + , + ); + + const titleElement = screen.getByRole('heading', { level: 1 }); + expect(titleElement).toHaveTextContent('Chats'); + }); + it('toggles LeftDrawer correctly based on window size and user interaction', () => { render( 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 {
- - - - {tCommon('searchBy', { item: '' })} - - - setSearchBy('assignee')} - data-testid="assignee" - > - {t('assignee')} - - setSearchBy('category')} - data-testid="category" - > - {t('category')} - - - - - - - {tCommon('sort')} - - - setSortBy('dueDate_DESC')} - data-testid="dueDate_DESC" - > - {t('latestDueDate')} - - setSortBy('dueDate_ASC')} - data-testid="dueDate_ASC" - > - {t('earliestDueDate')} - - - + + setSearchBy(value as 'assignee' | 'category') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + + setSortBy(value as 'dueDate_DESC' | 'dueDate_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + />
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 {
- - - - {tCommon('searchBy', { item: '' })} - - - setSearchBy('leader')} - data-testid="leader" - > - {t('leader')} - - setSearchBy('group')} - data-testid="group" - > - {t('group')} - - - - - - - {tCommon('sort')} - - - setSortBy('volunteers_DESC')} - data-testid="volunteers_DESC" - > - {t('mostVolunteers')} - - setSortBy('volunteers_ASC')} - data-testid="volunteers_ASC" - > - {t('leastVolunteers')} - - - + setSearchBy(value as 'leader' | 'group')} + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + + setSortBy(value as 'volunteers_DESC' | 'volunteers_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + />
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 ; 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 (
@@ -162,63 +158,30 @@ const Invitations = (): JSX.Element => {
- {/* Dropdown menu for sorting invitations */} - - - - {tCommon('sort')} - - - setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('receivedLatest')} - - setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('receivedEarliest')} - - - - - - - - {t('filter')} - - - setFilter(null)} - data-testid="filterAll" - > - {tCommon('all')} - - setFilter(ItemFilter.Group)} - data-testid="filterGroup" - > - {t('groupInvite')} - - setFilter(ItemFilter.Individual)} - data-testid="filterIndividual" - > - {t('individualInvite')} - - - + + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + + setFilter(value === 'all' ? null : (value as ItemFilter)) + } + dataTestIdPrefix="filter" + buttonLabel={t('filter')} + type="filter" + />
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 => {
- - - - {tCommon('searchBy', { item: '' })} - - - setSearchBy('title')} - data-testid="title" - > - {t('name')} - - setSearchBy('location')} - data-testid="location" - > - {tCommon('location')} - - - + + setSearchBy(value as 'title' | 'location') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + />
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 => {
- - + +
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 => { + 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 => { + 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 => { + // Simulate checking connection + return true; // Replace with actual connection check logic +}; + +// Function to ask for Docker port +export const askForDocker = async (): Promise => { + 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 => { + 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 = ({ + 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 ( + + ); +}; + +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) => 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((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((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((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((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((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((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((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((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((resolve) => { + observable1?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('1'); + resolve(); + }, + }); + }), + new Promise((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((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((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((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((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((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, + }); + }); + }); +});