From a2cb2859b30056a86d902efb4503eedbf0bca780 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Date: Tue, 2 Dec 2025 23:24:45 +0530 Subject: [PATCH 1/8] feat(contact-center): add address book search to outdial --- .../task/OutdialCall/outdial-call.style.scss | 13 ++- .../task/OutdialCall/outdial-call.tsx | 108 +++++++++++++----- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss index a96be78f3..c4087861c 100644 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss @@ -1,4 +1,4 @@ -.keypad { +.outdial-container { display: flex; flex-direction: column; align-items: center; @@ -39,4 +39,15 @@ background: var(--mds-color-theme-background-primary-active); } } + + .tab-list { + mdc-button { + display: none; + } + + mdc-tab { + width: 7.395rem; + padding: 0; + } + } } \ No newline at end of file diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx index b1e4b8424..48e0a4ed7 100644 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx @@ -1,10 +1,12 @@ import React, {useEffect, useMemo, useState} from 'react'; -import {OutdialAniEntry, OutdialCallComponentProps} from '../task.types'; -import './outdial-call.style.scss'; import {withMetrics} from '@webex/cc-ui-logging'; -import {Input, Button, Option, Select} from '@momentum-design/components/dist/react'; +import {Input, Button, Option, Select, Tab, TabList} from '@momentum-design/components/dist/react'; + +import {OutdialAniEntry, OutdialCallComponentProps} from '../task.types'; import {OutdialStrings, KEY_LIST} from './constants'; +import './outdial-call.style.scss'; + /** * OutdialCallComponent renders a dialpad UI for agents to initiate outbound calls. * It allows input of a destination number, selection of an ANI, and validates input. @@ -18,7 +20,13 @@ import {OutdialStrings, KEY_LIST} from './constants'; const OutdialCallComponent: React.FunctionComponent = (props) => { const {logger, startOutdial, getOutdialANIEntries} = props; + const TABS = { + DIAL_PAD: 'dial_pad', + ADDRESS_BOOK: 'address_book', + }; + // State Hooks + const [selectedTab, setSelectedTab] = useState(TABS.DIAL_PAD); const [destination, setDestination] = useState(''); const [isValidNumber, setIsValidNumber] = useState(''); const [selectedANI, setSelectedANI] = useState(undefined); @@ -71,32 +79,76 @@ const OutdialCallComponent: React.FunctionComponent = validateOutboundNumber(destination + value); }; + const renderAddressBook = () => { + return ( +
+

Address book panel

+
+ ); + }; + + const renderDiapad = () => { + return ( +
+ { + const inputValue = (e as React.ChangeEvent).target.value; + setDestination(inputValue); + validateOutboundNumber(inputValue); + }} + /> +
    + {KEY_LIST.map((key) => ( +
  • + +
  • + ))} +
+
+ ); + }; + return ( -
- { - const inputValue = (e as React.ChangeEvent).target.value; - setDestination(inputValue); - validateOutboundNumber(inputValue); - }} - /> -
    - {KEY_LIST.map((key) => ( -
  • - -
  • - ))} -
+
+ + setSelectedTab(TABS.ADDRESS_BOOK)} + > + + setSelectedTab(TABS.DIAL_PAD)} + > + + + {selectedTab === TABS.ADDRESS_BOOK && ( +
+ {renderAddressBook()} +
+ )} + {selectedTab === TABS.DIAL_PAD && ( +
+ {renderDiapad()} +
+ )} + + +
    + {addressBookEntries.length > 0 ? ( + addressBookEntries.map((entry: AddressBookEntry) => ( +
  • handleAddressBookEntryClick(entry.number, entry.id)} + role="button" + tabIndex={0} + aria-label={`Select ${entry.name}`} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + handleAddressBookEntryClick(entry.number, entry.id); + } + }} + > + +
    +

    {entry.name}

    +

    {entry.number}

    +
    +
  • + )) + ) : addressBookLoading ? ( +
    + +
    + ) : ( +

    No address book entries found.

    + )} +
); }; @@ -126,7 +228,7 @@ const OutdialCallComponent: React.FunctionComponent = tabId={TABS.ADDRESS_BOOK} aria-controls={TABS.ADDRESS_BOOK} variant="glass" - onClick={() => setSelectedTab(TABS.ADDRESS_BOOK)} + onClick={handleAddressBookTabClick} > = tabId={TABS.DIAL_PAD} aria-controls={TABS.DIAL_PAD} variant="glass" - onClick={() => setSelectedTab(TABS.DIAL_PAD)} + onClick={handleDiapadTabClick} > {selectedTab === TABS.ADDRESS_BOOK && ( -
+
{renderAddressBook()}
)} @@ -150,7 +257,7 @@ const OutdialCallComponent: React.FunctionComponent = )} Promise; + + /** + * Flag to determine if the address book is enabled. + */ + isAddressBookEnabled: boolean; } export type OutdialCallComponentProps = Pick< OutdialCallProps, - 'logger' | 'startOutdial' | 'getOutdialANIEntries' | 'getAddressBookEntries' + 'logger' | 'startOutdial' | 'getOutdialANIEntries' | 'getAddressBookEntries' | 'isAddressBookEnabled' >; /** diff --git a/packages/contact-center/task/src/OutdialCall/index.tsx b/packages/contact-center/task/src/OutdialCall/index.tsx index e197b2cfe..cb3a163db 100644 --- a/packages/contact-center/task/src/OutdialCall/index.tsx +++ b/packages/contact-center/task/src/OutdialCall/index.tsx @@ -4,20 +4,22 @@ import {observer} from 'mobx-react-lite'; import {ErrorBoundary} from 'react-error-boundary'; import {OutdialCallComponent} from '@webex/cc-components'; import {useOutdialCall} from '../helper'; +import {OutdialProps} from '../task.types'; -const OutdialCallInternal: React.FunctionComponent = observer(() => { +const OutdialCallInternal: React.FunctionComponent = observer((props: OutdialProps) => { const {cc, logger} = store; const result = useOutdialCall({cc, logger}); - const props = { + const resultProps = { logger, ...result, + ...props, }; - return ; + return ; }); -const OutdialCall: React.FunctionComponent = (props) => { +const OutdialCall: React.FunctionComponent = (props) => { return ( <>} diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 995165002..a8b0a34fb 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -32,6 +32,12 @@ export type useCallControlProps = Pick< Partial>; export type useOutdialCallProps = Pick; +export interface OutdialProps { + /** + * Flag to determine if the address book is enabled. + */ + isAddressBookEnabled: boolean; +} /** * Helper interface for device type checks diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.scss b/widgets-samples/cc/samples-cc-react-app/src/App.scss index 9d0c09f0a..29956662f 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.scss +++ b/widgets-samples/cc/samples-cc-react-app/src/App.scss @@ -52,7 +52,8 @@ iframe { margin-bottom: 20px; } -.call-control-outer, .call-control-consult-outer { +.call-control-outer, +.call-control-consult-outer { padding: 20px; border: rgba(204, 204, 204, 0.5) 1px solid; box-shadow: 0 0 10px rgba(204, 204, 204, 0.5); @@ -68,16 +69,18 @@ iframe { flex-direction: column-reverse; gap: 10px; z-index: 9999; - } .incoming-task { width: 400px; background-color: white; border-radius: 8px; - box-shadow: 0 0 10px rgba(0,0,0,0.2); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); padding: 10px; - transition: transform 0.5s ease, width 0.3s ease, opacity 0.3s ease; + transition: + transform 0.5s ease, + width 0.3s ease, + opacity 0.3s ease; transform: translateX(100%); /* Start off-screen */ opacity: 0; animation: slide-in 0.4s forwards; @@ -103,19 +106,21 @@ iframe { cursor: pointer; } - -.table-border{ +.table-border { border: 1px solid silver; padding: '8px'; } -.stationLogoutButtonClass{ +.stationLogoutButtonClass { margin-top: 0.313rem; } .profile-loader-overlay { position: fixed; - top: 0; left: 0; right: 0; bottom: 0; + top: 0; + left: 0; + right: 0; + bottom: 0; background: var(--color-theme-common-overlays-secondary-normal, #00000066); display: flex; align-items: center; @@ -127,14 +132,18 @@ iframe { width: 4.5rem; height: 4.5rem; border: 0.375rem solid var(--mds-color-theme-outline-primary-disabled, #00000033); - border-top: 0.375rem solid var(--color-theme-inverted-text-primary-normal, #FFFFFFF2); + border-top: 0.375rem solid var(--color-theme-inverted-text-primary-normal, #fffffff2); border-radius: 50%; animation: spin 0.9s linear infinite; } @keyframes spin { - 0% { transform: rotate(0deg);} - 100% { transform: rotate(360deg);} + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } @keyframes toast-slide-in { @@ -158,7 +167,9 @@ iframe { background: #fff; color: #222; border-radius: 1rem; - box-shadow: 0 4px 24px rgba(0,0,0,0.12), 0 1.5px 4px rgba(0,0,0,0.08); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.12), + 0 1.5px 4px rgba(0, 0, 0, 0.08); display: flex; align-items: flex-start; gap: 1rem; @@ -172,9 +183,8 @@ iframe { margin-top: 0.1rem; font-size: 1.5em; mdc-icon { - --mdc-icon-fill-color: var(--mds-color-theme-text-success-normal, #185E46); + --mdc-icon-fill-color: var(--mds-color-theme-text-success-normal, #185e46); } - } .toast-content { @@ -190,7 +200,7 @@ iframe { margin-bottom: 0.1em; } -.task-rejected-popup .close-btn{ +.task-rejected-popup .close-btn { position: absolute; top: 5px; right: 5px; @@ -198,4 +208,8 @@ iframe { border: none; font-size: 16px; cursor: pointer; -} \ No newline at end of file +} + +.margin-bottom-1rem { + margin-bottom: 1rem; +} diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index c2b64c6fa..9fcc4b388 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -63,6 +63,7 @@ function App() { const [selectedState, setSelectedState] = useState(''); const [showOutdialFailedModal, setShowOutdialFailedModal] = useState(false); const [outdialFailedReason, setOutdialFailedReason] = useState(''); + const [isAddressBookEnabled, setIsAddressBookEnabled] = useState(true); const [isLoggedIn, setIsLoggedIn] = useState(false); const [incomingTasks, setIncomingTasks] = useState([]); const [loginType, setLoginType] = useState('token'); @@ -917,7 +918,16 @@ function App() {
Outdial Call - + setIsAddressBookEnabled(!isAddressBookEnabled)} + /> +
From 66c4cf207211ccd6e1a007dcd91ab64f2f3175b6 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Date: Mon, 8 Dec 2025 21:46:37 +0530 Subject: [PATCH 5/8] feat(contact-center): fix styles --- .../src/components/task/OutdialCall/outdial-call.style.scss | 6 +++++- .../src/components/task/OutdialCall/outdial-call.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss index 7b468fc85..6152efa87 100644 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.style.scss @@ -6,6 +6,10 @@ width: 15.625rem; height: 26.5rem; + .keypad { + width: 100%; + } + mdc-input { padding-bottom: 0; // default is 1 rem, 1.5 rem needed but provided by .keys } @@ -21,7 +25,7 @@ column-gap: 1.5rem; padding: 1.5rem 0; list-style-type: none; - margin: 0; + margin: 0 20%; } .key { diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx index 8baa8b076..9842ffa7d 100644 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx @@ -321,7 +321,7 @@ const OutdialCallComponent: React.FunctionComponent = )} {selectedTab === TABS.DIAL_PAD && ( -
+
{renderDiapad()}
)} From a581e1416f7fb31d13188474f5d08679174c0677 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Date: Tue, 9 Dec 2025 16:00:06 +0530 Subject: [PATCH 6/8] feat(contact-center): add and update unit tests --- jest.setup.js | 24 + package.json | 1 + .../src/components/task/OutdialCall/AGENTS.md | 320 + .../task/OutdialCall/outdial-call.tsx | 4 +- .../out-dial-call.snapshot.tsx.snap | 5322 +++++++++++------ .../OutdialCall/out-dial-call.snapshot.tsx | 122 +- .../task/OutdialCall/out-dial-call.tsx | 343 +- .../task/tests/OutdialCall/index.tsx | 41 +- yarn.lock | 243 +- 9 files changed, 4581 insertions(+), 1839 deletions(-) create mode 100644 packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md diff --git a/jest.setup.js b/jest.setup.js index ca4767aae..83a85926b 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -13,3 +13,27 @@ window.HTMLElement.prototype.attachInternals = function () { setFormValue: () => {}, }; }; + +// Mock scrollIntoView for TabList component +window.HTMLElement.prototype.scrollIntoView = function () {}; + +// Mock IntersectionObserver for infinite scroll tests +global.IntersectionObserver = class IntersectionObserver { + constructor(callback, options) { + this.callback = callback; + this.options = options; + } + observe() {} + unobserve() {} + disconnect() {} +}; + +// Mock ResizeObserver for TabList component +global.ResizeObserver = class ResizeObserver { + constructor(callback) { + this.callback = callback; + } + observe() {} + unobserve() {} + disconnect() {} +}; diff --git a/package.json b/package.json index 78c839418..a7eed946c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "packageManager": "yarn@4.5.1", "devDependencies": { "@babel/preset-typescript": "7.25.9", + "@jest/test-sequencer": "^30.2.0", "@playwright/test": "^1.51.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^6.0.3", diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md b/packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md new file mode 100644 index 000000000..eef457dae --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md @@ -0,0 +1,320 @@ +# OutdialCall Component - AI Agent Guide + +## Overview + +The OutdialCall component is part of the `@webex/cc-components` package (v1.28.0-ccwidgets.126) and provides a dialpad UI for agents to initiate outbound calls in Webex Contact Center solutions. It supports both manual dialing and address book functionality. + +## Package Information + +- **Package Name**: `@webex/cc-components` +- **Description**: Webex Contact Center UI Components Library for your custom contact center solutions +- **License**: Cisco's General Terms +- **Main Entry**: `dist/index.js` +- **Types**: `dist/types/index.d.ts` + +## Component Location + +``` +packages/contact-center/cc-components/src/components/task/OutdialCall/ +├── outdial-call.tsx # Main component implementation +├── outdial-call.style.scss # Component styles +├── constants.ts # String constants and utilities +└── AGENTS.md # This file +``` + +## Available Commands + +### From Repository Root (`/Volumes/data/repo/cisco/widgets-repos/cc-widgets`) + +#### Run Tests + +```bash +# Run all unit tests for cc-components package +yarn workspace @webex/cc-components run test:unit + +# Run specific test file +yarn workspace @webex/cc-components run test:unit packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx + +# Run snapshot tests +yarn workspace @webex/cc-components run test:unit packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.snapshot.tsx +``` + +#### Build Commands + +```bash +# Build TypeScript types +yarn workspace @webex/cc-components run build + +# Build source with webpack +yarn workspace @webex/cc-components run build:src + +# Watch mode for development +yarn workspace @webex/cc-components run build:watch +``` + +#### Code Quality + +```bash +# Run ESLint for style checking +yarn workspace @webex/cc-components run test:styles +``` + +#### Clean Commands + +```bash +# Clean dist and node_modules +yarn workspace @webex/cc-components run clean + +# Clean only dist folder +yarn workspace @webex/cc-components run clean:dist +``` + +### From Package Directory (`packages/contact-center/cc-components`) + +```bash +# Run tests +yarn test:unit + +# Build +yarn build +yarn build:src +yarn build:watch + +# Linting +yarn test:styles + +# Clean +yarn clean +yarn clean:dist +``` + +## Component Features + +### Core Functionality + +1. **Dial Pad**: Manual number entry using keypad or direct input +2. **Address Book**: Search and select contacts (when enabled) +3. **ANI Selection**: Choose outbound calling line identity +4. **Input Validation**: Validates phone number format using regex +5. **Tab Navigation**: Switch between Dial Pad and Address Book views + +### Key Props (OutdialCallComponentProps) + +- `logger`: Logger instance for tracking +- `startOutdial`: Function to initiate outbound call +- `getOutdialANIEntries`: Fetch available ANI entries +- `isTelephonyTaskActive`: Disable calling when telephony task is active +- `getAddressBookEntries`: Fetch address book contacts (optional) +- `isAddressBookEnabled`: Enable/disable address book feature (optional) + +### Constants + +Located in `constants.ts`: + +- **OutdialStrings**: UI labels and messages + - `ANI_SELECT_LABEL`, `ANI_SELECT_PLACEHOLDER` + - `CALL_BUTTON_ARIA_LABEL` + - `DN_PLACEHOLDER`, `INCORRECT_DN_FORMAT` + - `OUTDIAL_CALL` + - `ADDRESS_BOOK_SEARCH_PLACEHOLDER` +- **KEY_LIST**: Dialpad keys `['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']` + +## Testing + +### Test Files Location + +``` +packages/contact-center/cc-components/tests/components/task/OutdialCall/ +├── out-dial-call.tsx # Functional tests +└── out-dial-call.snapshot.tsx # Snapshot tests +``` + +### Test Requirements + +When creating or modifying tests, always include: + +1. **Required Props**: All test components must have: + + - `logger` + - `startOutdial` + - `getOutdialANIEntries` + - `isTelephonyTaskActive` + - `getAddressBookEntries` + - `isAddressBookEnabled` + +2. **Mock Setup**: Use `@webex/test-fixtures` for mocking + + ```typescript + import {mockCC} from '@webex/test-fixtures'; + store.store.logger = mockCC.LoggerProxy; + ``` + +3. **Snapshot Testing**: Remove dynamic IDs before snapshots + ```typescript + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + ``` + +### Test Coverage Areas + +- ✅ Basic rendering and layout +- ✅ Keypad input and validation +- ✅ Direct number input +- ✅ ANI selection +- ✅ Call button functionality +- ✅ Invalid input handling +- ✅ Address book tab switching +- ✅ Address book entry selection +- ✅ Address book search +- ✅ Empty states + +## Development Workflow + +### 1. Type Checking + +Before running tests, TypeScript compilation must pass: + +```bash +cd packages/contact-center/cc-components +yarn run -T tsc --project tsconfig.test.json +``` + +### 2. Making Changes + +1. Edit component files in `src/components/task/OutdialCall/` +2. Update tests in `tests/components/task/OutdialCall/` +3. Run type checking +4. Run tests +5. Fix any failing tests or type errors + +### 3. Common Issues + +#### Missing Props Error + +``` +Type '{ ... }' is missing the following properties from type 'OutdialCallComponentProps': getAddressBookEntries, isAddressBookEnabled +``` + +**Solution**: Add missing props to test component instantiation: + +```typescript +const props: OutdialCallComponentProps = { + logger: mockCC.LoggerProxy, + startOutdial: jest.fn(), + getOutdialANIEntries: jest.fn().mockResolvedValue([]), + isTelephonyTaskActive: false, + getAddressBookEntries: jest.fn().mockResolvedValue([]), + isAddressBookEnabled: false, +}; +``` + +#### Jest Module Errors + +If you encounter `Cannot find module '@jest/test-sequencer'` or similar: + +```bash +# From repository root +yarn install +``` + +## Dependencies + +### Runtime Dependencies + +- `@momentum-ui/illustrations`: ^1.24.0 +- `@r2wc/react-to-web-component`: 2.0.3 +- `@webex/cc-store`: workspace package +- `@webex/cc-ui-logging`: workspace package + +### Peer Dependencies (Required by consumer) + +- `@momentum-ui/react-collaboration`: >=26.197.0 +- `react`: >=18.3.1 +- `react-dom`: >=18.3.1 + +### Dev Dependencies + +- Testing: Jest 29.7.0, @testing-library/react 16.0.1 +- Build: TypeScript 5.6.3, Webpack 5.94.0 +- Linting: ESLint 9.20.1, Prettier 3.5.1 + +## Type Definitions + +Component types are defined in: + +``` +packages/contact-center/cc-components/src/components/task/task.types.ts +``` + +Look for: + +- `OutdialCallComponentProps` +- `OutdialAniEntry` +- `AddressBookEntry` (from `@webex/contact-center`) + +## Best Practices + +### For Component Development + +1. Always validate phone number input using the regex pattern +2. Handle async operations with proper error logging +3. Use debounce for search inputs (500ms) +4. Implement infinite scroll for large address books +5. Clear states when switching between tabs + +### For Testing + +1. Always compile TypeScript before running tests +2. Mock all async operations +3. Test both enabled and disabled address book scenarios +4. Verify accessibility attributes (aria-label, role, tabindex) +5. Test error states and edge cases + +### For Code Review + +1. Ensure all new props are added to type definitions +2. Update tests when adding new features +3. Verify snapshot tests capture new UI elements +4. Check that error messages are logged appropriately + +## Related Files + +- Type definitions: `../task.types.ts` +- Helper utilities: `../CallControl/CallControlCustom/call-control-custom.utils.ts` +- Constants: `../constants.ts` (DEFAULT_PAGE_SIZE) +- Store integration: `@webex/cc-store` + +## Troubleshooting + +### Tests Not Running + +1. Check TypeScript compilation: `yarn run -T tsc --project tsconfig.test.json` +2. Verify all dependencies installed: `yarn install` +3. Check Jest configuration: `jest.config.js` in package root + +### Type Errors in Tests + +1. Ensure test file imports correct types from `task.types.ts` +2. Verify all required props are provided +3. Check that mock return values match expected types + +### Snapshot Mismatches + +1. Review changes carefully +2. Update snapshots if changes are intentional: `jest --updateSnapshot` +3. Remember to remove dynamic IDs before creating snapshots + +## Quick Reference + +| Action | Command | +| ----------------- | ------------------------------------------------------------------------------------------ | +| Run all tests | `yarn workspace @webex/cc-components run test:unit` | +| Run specific test | `yarn workspace @webex/cc-components run test:unit ` | +| Build component | `yarn workspace @webex/cc-components run build:src` | +| Watch mode | `yarn workspace @webex/cc-components run build:watch` | +| Type check | `cd packages/contact-center/cc-components && yarn run -T tsc --project tsconfig.test.json` | +| Lint code | `yarn workspace @webex/cc-components run test:styles` | +| Clean build | `yarn workspace @webex/cc-components run clean:dist` | + +## Support + +For questions about Webex Contact Center components, refer to the main package documentation or contact the development team. diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx index c2ed1f941..2673831db 100644 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx @@ -138,7 +138,7 @@ const OutdialCallComponent: React.FunctionComponent = const handleDiapadTabClick = () => { setSelectedAddressBookEntry(null); - setDestination(''); + // Don't clear destination - preserve selected address book entry number setSelectedTab(TABS.DIAL_PAD); }; @@ -283,7 +283,7 @@ const OutdialCallComponent: React.FunctionComponent = helpTextType={isValidNumber ? 'error' : 'default'} placeholder={OutdialStrings.DN_PLACEHOLDER} value={destination} - onChange={(e: unknown) => { + onInput={(e: unknown) => { const inputValue = (e as React.ChangeEvent).target.value; setDestination(inputValue); validateOutboundNumber(inputValue); diff --git a/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap index 98fa2b027..f7f96aebc 100644 --- a/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap @@ -1,192 +1,1838 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Outdial Call Component Address Book functionality displays address book entries 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + John Doe +

    +

    + +14691234567 +

    +
    +
  • +
  • + +
    +

    + Jane Smith +

    +

    + +14699876543 +

    +
    +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality renders address book search input 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + John Doe +

    +

    + +14691234567 +

    +
    +
  • +
  • + +
    +

    + Jane Smith +

    +

    + +14699876543 +

    +
    +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality renders with address book enabled 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality selects an address book entry 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + John Doe +

    +

    + +14691234567 +

    +
    +
  • +
  • + +
    +

    + Jane Smith +

    +

    + +14699876543 +

    +
    +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality shows empty state when no address book entries 1`] = ` +
+
+ + + + +
+
+ +
    +

    + No address book entries found. +

    +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality switches back to dial pad from address book 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality switches to address book tab 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + John Doe +

    +

    + +14691234567 +

    +
    +
  • +
  • + +
    +

    + Jane Smith +

    +

    + +14699876543 +

    +
    +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + exports[`Outdial Call Component Rendering renders the component correctly 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -303,190 +1949,194 @@ exports[`Outdial Call Component Rendering renders the component correctly 1`] = exports[`Outdial Call Component allows special characters (* # +) from keypad 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -603,190 +2253,194 @@ exports[`Outdial Call Component allows special characters (* # +) from keypad 1` exports[`Outdial Call Component calls startOutdial with correct payload when clicking call button 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -901,190 +2555,194 @@ exports[`Outdial Call Component calls startOutdial with correct payload when cli exports[`Outdial Call Component does not allow empty input 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -1201,190 +2859,194 @@ exports[`Outdial Call Component does not allow empty input 1`] = ` exports[`Outdial Call Component does not allow invalid characters when typing 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -1501,190 +3163,194 @@ exports[`Outdial Call Component does not allow invalid characters when typing 1` exports[`Outdial Call Component has no ANI entry options when the entry list is empty 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -1784,190 +3450,194 @@ exports[`Outdial Call Component has no ANI entry options when the entry list is exports[`Outdial Call Component sets selected ani when an option is selected 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -2203,190 +3873,194 @@ exports[`Outdial Call Component sets selected ani when an option is selected 1`] exports[`Outdial Call Component shows error help text when invalid characters are entered 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -2503,190 +4177,194 @@ exports[`Outdial Call Component shows error help text when invalid characters ar exports[`Outdial Call Component updates input value when clicking keypad buttons 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
@@ -2803,190 +4481,194 @@ exports[`Outdial Call Component updates input value when clicking keypad buttons exports[`Outdial Call Component updates input value when typing directly 1`] = `
- -
    -
  • - - 1 - -
  • -
  • - - 2 - -
  • -
  • - - 3 - -
  • -
  • - - 4 - -
  • -
  • - - 5 - -
  • -
  • - - 6 - -
  • -
  • - - 7 - -
  • -
  • - - 8 - -
  • -
  • - - 9 - -
  • -
  • - - * - -
  • -
  • - - 0 - -
  • -
  • - - # - -
  • -
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
diff --git a/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.snapshot.tsx index 16d12937d..53988c191 100644 --- a/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.snapshot.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {fireEvent, render, screen, within} from '@testing-library/react'; +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import '@testing-library/jest-dom'; import OutdialCallComponent from '../../../../src/components/task/OutdialCall/outdial-call'; import store from '@webex/cc-store'; @@ -11,6 +11,15 @@ describe('Outdial Call Component', () => { // Prevent warning 'CC-Widgets: UI Metrics: No logger found' store.store.logger = mockCC.LoggerProxy; + // Helper function to get the tablist element (web component) + const getTabList = async (container: HTMLElement) => { + const tabList = container.querySelector('mdc-tablist'); + if (!tabList) { + throw new Error('TabList not found'); + } + return tabList; + }; + const props: OutdialCallComponentProps = { logger: mockCC.LoggerProxy, startOutdial: jest.fn(), @@ -19,6 +28,8 @@ describe('Outdial Call Component', () => { {name: 'name 2', number: '2'}, ]), isTelephonyTaskActive: false, + getAddressBookEntries: jest.fn().mockResolvedValue([]), + isAddressBookEnabled: false, }; beforeEach(() => { @@ -127,6 +138,8 @@ describe('Outdial Call Component', () => { startOutdial={props.startOutdial} getOutdialANIEntries={jest.fn().mockResolvedValue([])} isTelephonyTaskActive={false} + getAddressBookEntries={jest.fn().mockResolvedValue([])} + isAddressBookEnabled={false} /> ); const select = await screen.findByTestId('outdial-ani-option-select'); @@ -155,4 +168,111 @@ describe('Outdial Call Component', () => { container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); expect(container).toMatchSnapshot(); }); + + describe('Address Book functionality', () => { + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: [ + {id: '1', name: 'John Doe', number: '+14691234567'}, + {id: '2', name: 'Jane Smith', number: '+14699876543'}, + ], + total: 2, + }), + }; + + it('renders with address book enabled', async () => { + const {container} = render(); + await screen.findByTestId('outdial-number-input'); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + + it('switches to address book tab', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; // First tab is address book + fireEvent.click(addressBookTab); + await screen.findByTestId('outdial-address-book-container'); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + + it('displays address book entries', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + await screen.findByText('John Doe'); + await screen.findByText('Jane Smith'); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + + it('selects an address book entry', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + const entry = await screen.findByText('John Doe'); + fireEvent.click(entry); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + + it('renders address book search input', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + const searchInput = await screen.findByTestId('outdial-address-book-search-input'); + expect(searchInput).toBeInTheDocument(); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + + it('shows empty state when no address book entries', async () => { + const emptyProps = { + ...addressBookProps, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: [], + total: 0, + }), + }; + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + await screen.findByText('No address book entries found.'); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + + it('switches back to dial pad from address book', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + await screen.findByTestId('outdial-address-book-container'); + const dialPadTab = tabs[1]; // Second tab is dial pad + fireEvent.click(dialPadTab); + await screen.findByTestId('outdial-keypad-keys'); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx b/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx index 8087dc4c5..1c186983c 100644 --- a/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx +++ b/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx @@ -31,6 +31,8 @@ describe('OutdialCallComponent', () => { {name: 'name 2', number: '2'}, ]), isTelephonyTaskActive: false, + getAddressBookEntries: jest.fn().mockResolvedValue([]), + isAddressBookEnabled: false, }; describe('renders the component correctly, should render:', () => { it('article container', async () => { @@ -164,7 +166,9 @@ describe('OutdialCallComponent', () => { logger={props.logger} startOutdial={props.startOutdial} getOutdialANIEntries={jest.fn().mockResolvedValue([])} - currentTask={undefined} + isTelephonyTaskActive={false} + getAddressBookEntries={jest.fn().mockResolvedValue([])} + isAddressBookEnabled={false} /> ); const select = await screen.findByTestId('outdial-ani-option-select'); @@ -330,4 +334,341 @@ describe('OutdialCallComponent', () => { expect(mockStartOutdial).toHaveBeenCalledWith('+14698041796', undefined); }); }); + + describe('Address Book functionality', () => { + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: [ + {id: '1', name: 'John Doe', number: '+14691234567'}, + {id: '2', name: 'Jane Smith', number: '+14699876543'}, + ], + total: 2, + }), + }; + + // Helper function to get the tablist element (web component) + const getTabList = async (container: HTMLElement) => { + const tabList = container.querySelector('mdc-tablist'); + if (!tabList) { + throw new Error('TabList not found'); + } + return tabList; + }; + + it('does not show tabs when address book is disabled', async () => { + const {container} = render(); + const tabList = container.querySelector('mdc-tablist'); + expect(tabList).not.toBeInTheDocument(); + }); + + it('shows tabs when address book is enabled', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + expect(tabList).toBeInTheDocument(); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + expect(tabs).toHaveLength(2); + }); + + it('switches to address book tab and loads entries', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; // First tab is address book + fireEvent.click(addressBookTab); + + // Verify address book container is shown + const addressBookContainer = await screen.findByTestId('outdial-address-book-container'); + expect(addressBookContainer).toBeInTheDocument(); + + // Verify entries are loaded + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + }); + + // Verify getAddressBookEntries was called + expect(addressBookProps.getAddressBookEntries).toHaveBeenCalled(); + }); + + it('renders address book search input', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const searchInput = await screen.findByTestId('outdial-address-book-search-input'); + expect(searchInput).toBeInTheDocument(); + // Note: Web components don't always expose props as HTML attributes + }); + + it('selects an address book entry and populates destination', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + // Wait for entries to load + const johnDoe = await screen.findByText('John Doe'); + fireEvent.click(johnDoe); + + // Switch back to dial pad to verify destination was set + const dialPadTab = tabs[1]; + fireEvent.click(dialPadTab); + + const input = await screen.findByTestId('outdial-number-input'); + await waitFor(() => { + expect(input).toHaveValue('+14691234567'); + }); + }); + + it('allows making a call with address book selected number', async () => { + const mockStartOutdial = jest.fn(); + const {container} = render(); + + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + // Select an entry + const janeDoe = await screen.findByText('Jane Smith'); + fireEvent.click(janeDoe); + + // Make the call + const callButton = await screen.findByTestId('outdial-call-button'); + fireEvent.click(callButton); + + await waitFor(() => { + expect(mockStartOutdial).toHaveBeenCalledWith('+14699876543', undefined); + }); + }); + + it('shows empty state when no address book entries', async () => { + const emptyProps = { + ...addressBookProps, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: [], + total: 0, + }), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('No address book entries found.')).toBeInTheDocument(); + }); + }); + + it('clears destination when switching from dial pad to address book', async () => { + const {container} = render(); + + // Enter a number on dial pad + const input = await screen.findByTestId('outdial-number-input'); + Object.defineProperty(customEvent, 'target', { + writable: false, + value: {value: '123'}, + }); + fireEvent(input, customEvent); + + await waitFor(() => { + expect(input).toHaveValue('123'); + }); + + // Switch to address book + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + // Switch back to dial pad + const dialPadTab = tabs[1]; + fireEvent.click(dialPadTab); + + // Destination should be cleared + const inputAfter = await screen.findByTestId('outdial-number-input'); + expect(inputAfter).toHaveValue(''); + }); + + it('searches address book entries', async () => { + const mockSearch = jest.fn().mockResolvedValue({ + data: [{id: '1', name: 'John Doe', number: '+14691234567'}], + total: 1, + }); + + const searchProps = { + ...addressBookProps, + getAddressBookEntries: mockSearch, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const searchInput = await screen.findByTestId('outdial-address-book-search-input'); + + // Create a custom event for the search input + const searchEvent = new Event('input', {bubbles: true}); + Object.defineProperty(searchEvent, 'target', { + writable: false, + value: {value: 'John'}, + }); + + fireEvent(searchInput, searchEvent); + + // Wait for debounced search (500ms + some buffer) + await waitFor( + () => { + // First call is initial load with page: 0, pageSize: 25, search: '' + // Second call should be search with page: 0, pageSize: 25, search: 'John' + expect(mockSearch).toHaveBeenCalledWith({page: 0, pageSize: 25, search: 'John'}); + }, + {timeout: 1000} + ); + }); + + it('handles address book loading error gracefully', async () => { + const errorProps = { + ...addressBookProps, + getAddressBookEntries: jest.fn().mockRejectedValue(new Error('Network error')), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('No address book entries found.')).toBeInTheDocument(); + }); + }); + + it('validates phone number from address book entry', async () => { + const invalidPhoneProps = { + ...addressBookProps, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: [{id: '1', name: 'Invalid Contact', number: 'invalid'}], + total: 1, + }), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const invalidContact = await screen.findByText('Invalid Contact'); + fireEvent.click(invalidContact); + + // Switch to dial pad to check validation + const dialPadTab = tabs[1]; + fireEvent.click(dialPadTab); + + const input = await screen.findByTestId('outdial-number-input'); + await waitFor(() => { + expect(input).toHaveAttribute('help-text', 'Incorrect format.'); + }); + }); + + it('applies selected class to chosen address book entry', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const johnDoe = await screen.findByText('John Doe'); + const listItem = johnDoe.closest('li'); + + // Initially should not have selected class + expect(listItem).not.toHaveClass('selected'); + + fireEvent.click(johnDoe); + + // After selection should have selected class + await waitFor(() => { + expect(listItem).toHaveClass('selected'); + }); + }); + + it('allows keyboard navigation for address book entries', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const johnDoe = await screen.findByText('John Doe'); + const listItem = johnDoe.closest('li'); + + // Test Enter key + fireEvent.keyDown(listItem!, {key: 'Enter'}); + + const dialPadTab = tabs[1]; + fireEvent.click(dialPadTab); + + const input = await screen.findByTestId('outdial-number-input'); + await waitFor(() => { + expect(input).toHaveValue('+14691234567'); + }); + }); + + it('handles space key for address book entry selection', async () => { + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const janeDoe = await screen.findByText('Jane Smith'); + const listItem = janeDoe.closest('li'); + + // Test Space key + fireEvent.keyDown(listItem!, {key: ' '}); + + const dialPadTab = tabs[1]; + fireEvent.click(dialPadTab); + + const input = await screen.findByTestId('outdial-number-input'); + await waitFor(() => { + expect(input).toHaveValue('+14699876543'); + }); + }); + + it('clears input after successful call from address book', async () => { + const mockStartOutdial = jest.fn(); + const {container} = render(); + + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + const johnDoe = await screen.findByText('John Doe'); + fireEvent.click(johnDoe); + + const callButton = await screen.findByTestId('outdial-call-button'); + fireEvent.click(callButton); + + // Switch to dial pad + const dialPadTab = tabs[1]; + fireEvent.click(dialPadTab); + + const input = await screen.findByTestId('outdial-number-input'); + await waitFor(() => { + expect(input).toHaveValue(''); + }); + }); + }); }); diff --git a/packages/contact-center/task/tests/OutdialCall/index.tsx b/packages/contact-center/task/tests/OutdialCall/index.tsx index 4db942fbe..4ac3c8807 100644 --- a/packages/contact-center/task/tests/OutdialCall/index.tsx +++ b/packages/contact-center/task/tests/OutdialCall/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {render} from '@testing-library/react'; +import '@testing-library/jest-dom'; import * as helper from '../../src/helper'; import {OutdialCall} from '../../src/OutdialCall'; import store from '@webex/cc-store'; @@ -29,7 +30,7 @@ describe('OutdialCall Component', () => { it('render OutdialCallComponent with correct props', () => { const useOutdialCallSpy = jest.spyOn(helper, 'useOutdialCall'); - render(); + render(); expect(useOutdialCallSpy).toHaveBeenCalledTimes(1); expect(useOutdialCallSpy).toHaveBeenCalledWith({ cc: {}, @@ -42,6 +43,40 @@ describe('OutdialCall Component', () => { }); }); + it('passes isAddressBookEnabled prop correctly when set to false', () => { + const useOutdialCallSpy = jest.spyOn(helper, 'useOutdialCall').mockReturnValue({ + startOutdial: jest.fn(), + getOutdialANIEntries: jest.fn(), + getAddressBookEntries: jest.fn(), + isTelephonyTaskActive: false, + }); + + const {container} = render(); + + expect(useOutdialCallSpy).toHaveBeenCalled(); + // When address book is disabled, there should be no tablist + expect(container.querySelector('mdc-tablist')).not.toBeInTheDocument(); + // The container should not have the additional height class + expect(container.querySelector('.height-28-5rem')).not.toBeInTheDocument(); + }); + + it('passes isAddressBookEnabled prop correctly when set to true', () => { + const useOutdialCallSpy = jest.spyOn(helper, 'useOutdialCall').mockReturnValue({ + startOutdial: jest.fn(), + getOutdialANIEntries: jest.fn(), + getAddressBookEntries: jest.fn(), + isTelephonyTaskActive: false, + }); + + const {container} = render(); + + expect(useOutdialCallSpy).toHaveBeenCalled(); + // When address book is enabled, there should be a tablist + expect(container.querySelector('mdc-tablist')).toBeInTheDocument(); + // The container should have the additional height class + expect(container.querySelector('.height-28-5rem')).toBeInTheDocument(); + }); + describe('ErrorBoundary Tests', () => { it('should render empty fragment when ErrorBoundary catches an error and call the callback', () => { // Mock the useOutdialCall to throw an error @@ -49,7 +84,7 @@ describe('OutdialCall Component', () => { throw new Error('Test error in useOutdialCall'); }); - const {container} = render(); + const {container} = render(); // The fallback should render an empty fragment (no content) expect(container.firstChild).toBeNull(); @@ -62,7 +97,7 @@ describe('OutdialCall Component', () => { }); store.onErrorCallback = undefined; - const {container} = render(); + const {container} = render(); // The fallback should render an empty fragment (no content) expect(container.firstChild).toBeNull(); diff --git a/yarn.lock b/yarn.lock index bd38a42df..43b5e1e0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,6 +77,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/code-frame@npm:7.27.1" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.27.1" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.20.5, @babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.25.9": version: 7.25.9 resolution: "@babel/compat-data@npm:7.25.9" @@ -527,6 +538,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.27.1": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.24.7, @babel/helper-validator-option@npm:^7.24.8, @babel/helper-validator-option@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-option@npm:7.25.9" @@ -3010,6 +3028,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:30.2.0": + version: 30.2.0 + resolution: "@jest/console@npm:30.2.0" + dependencies: + "@jest/types": "npm:30.2.0" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + jest-message-util: "npm:30.2.0" + jest-util: "npm:30.2.0" + slash: "npm:^3.0.0" + checksum: 10c0/ecf7ca43698863095500710a5aa08c38b1731c9d89ba32f4d9da7424b53ce1e86b3db8ccbbb27b695f49b4f94bc1d7d0c63c751d73c83d59488a682bc98b7e70 + languageName: node + linkType: hard + "@jest/console@npm:^29.7.0": version: 29.7.0 resolution: "@jest/console@npm:29.7.0" @@ -3131,6 +3163,16 @@ __metadata: languageName: node linkType: hard +"@jest/pattern@npm:30.0.1": + version: 30.0.1 + resolution: "@jest/pattern@npm:30.0.1" + dependencies: + "@types/node": "npm:*" + jest-regex-util: "npm:30.0.1" + checksum: 10c0/32c5a7bfb6c591f004dac0ed36d645002ed168971e4c89bd915d1577031672870032594767557b855c5bc330aa1e39a2f54bf150d2ee88a7a0886e9cb65318bc + languageName: node + linkType: hard + "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -3168,6 +3210,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/schemas@npm:30.0.5" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10c0/449dcd7ec5c6505e9ac3169d1143937e67044ae3e66a729ce4baf31812dfd30535f2b3b2934393c97cfdf5984ff581120e6b38f62b8560c8b5b7cc07f4175f65 + languageName: node + linkType: hard + "@jest/schemas@npm:^28.1.3": version: 28.1.3 resolution: "@jest/schemas@npm:28.1.3" @@ -3197,6 +3248,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:30.2.0": + version: 30.2.0 + resolution: "@jest/test-result@npm:30.2.0" + dependencies: + "@jest/console": "npm:30.2.0" + "@jest/types": "npm:30.2.0" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + collect-v8-coverage: "npm:^1.0.2" + checksum: 10c0/87566d56b4f90630282c103f41ea9031f4647902f2cd9839bc49af6248301c1a95cbc4432a9512e61f6c6d778e8b925d0573588b26a211d3198c62471ba08c81 + languageName: node + linkType: hard + "@jest/test-result@npm:^29.7.0": version: 29.7.0 resolution: "@jest/test-result@npm:29.7.0" @@ -3221,6 +3284,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:^30.2.0": + version: 30.2.0 + resolution: "@jest/test-sequencer@npm:30.2.0" + dependencies: + "@jest/test-result": "npm:30.2.0" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.2.0" + slash: "npm:^3.0.0" + checksum: 10c0/b8366e629b885bfc4b2b95f34f47405e70120eb8601f42de20ea4de308a5088d7bd9f535abf67a2a0d083a2b49864176e1333e036426a5d6b6bd02c1c4dda40b + languageName: node + linkType: hard + "@jest/transform@npm:^26.6.2": version: 26.6.2 resolution: "@jest/transform@npm:26.6.2" @@ -3267,6 +3342,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.2.0": + version: 30.2.0 + resolution: "@jest/types@npm:30.2.0" + dependencies: + "@jest/pattern": "npm:30.0.1" + "@jest/schemas": "npm:30.0.5" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10c0/ae121f6963bd9ed1cd9651db7be91bf14c05bff0d0eec4fca9fecf586bea4005e8f1de8cc9b8ef72e424ea96a309d123bef510b55a6a17a3b4b91a39d775e5cd + languageName: node + linkType: hard + "@jest/types@npm:^26.6.2": version: 26.6.2 resolution: "@jest/types@npm:26.6.2" @@ -6734,6 +6824,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.41 + resolution: "@sinclair/typebox@npm:0.34.41" + checksum: 10c0/0fb61fc2f90c25e30b19b0096eb8ab3ccef401d3e2acfce42168ff0ee877ba5981c8243fa6b1035ac756cde95316724e978b2837dd642d7e4e095de03a999c90 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^4.0.0, @sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -7824,7 +7921,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 @@ -7840,7 +7937,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0": +"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -8288,7 +8385,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0": +"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c @@ -8456,6 +8553,15 @@ __metadata: languageName: node linkType: hard +"@types/yargs@npm:^17.0.33": + version: 17.0.35 + resolution: "@types/yargs@npm:17.0.35" + dependencies: + "@types/yargs-parser": "npm:*" + checksum: 10c0/609557826a6b85e73ccf587923f6429850d6dc70e420b455bab4601b670bfadf684b09ae288bccedab042c48ba65f1666133cf375814204b544009f57d6eef63 + languageName: node + linkType: hard + "@types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" @@ -8618,6 +8724,13 @@ __metadata: languageName: node linkType: hard +"@ungap/structured-clone@npm:^1.3.0": + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a + languageName: node + linkType: hard + "@unimodules/core@npm:*": version: 7.2.0 resolution: "@unimodules/core@npm:7.2.0" @@ -12915,7 +13028,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -12978,7 +13091,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.0, anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.0, anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -15025,6 +15138,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^4.2.0": + version: 4.3.1 + resolution: "ci-info@npm:4.3.1" + checksum: 10c0/7dd82000f514d76ddfe7775e4cb0d66e5c638f5fa0e2a3be29557e898da0d32ac04f231217d414d07fb968b1fbc6d980ee17ddde0d2c516f23da9cfff608f6c1 + languageName: node + linkType: hard + "cidr-regex@npm:^4.1.1": version: 4.1.1 resolution: "cidr-regex@npm:4.1.1" @@ -15304,6 +15424,13 @@ __metadata: languageName: node linkType: hard +"collect-v8-coverage@npm:^1.0.2": + version: 1.0.3 + resolution: "collect-v8-coverage@npm:1.0.3" + checksum: 10c0/bc62ba251bcce5e3354a8f88fa6442bee56e3e612fec08d4dfcf66179b41ea0bf544b0f78c4ebc0f8050871220af95bb5c5578a6aef346feea155640582f09dc + languageName: node + linkType: hard + "collection-visit@npm:^1.0.0": version: 1.0.0 resolution: "collection-visit@npm:1.0.0" @@ -19188,7 +19315,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.0": +"fb-watchman@npm:^2.0.0, fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -19878,7 +20005,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.1.2, fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": +"fsevents@npm:^2.1.2, fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -19907,7 +20034,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.1.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.1.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -23098,6 +23225,28 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:30.2.0": + version: 30.2.0 + resolution: "jest-haste-map@npm:30.2.0" + dependencies: + "@jest/types": "npm:30.2.0" + "@types/node": "npm:*" + anymatch: "npm:^3.1.3" + fb-watchman: "npm:^2.0.2" + fsevents: "npm:^2.3.3" + graceful-fs: "npm:^4.2.11" + jest-regex-util: "npm:30.0.1" + jest-util: "npm:30.2.0" + jest-worker: "npm:30.2.0" + micromatch: "npm:^4.0.8" + walker: "npm:^1.0.8" + dependenciesMeta: + fsevents: + optional: true + checksum: 10c0/61b4ad5a59b4dfadac2f903f3d723d9017aada268c49b9222ec1e15c4892fd4c36af59b65f37f026d747d829672ab9679509fea5d4248d07a93b892963e1bb4e + languageName: node + linkType: hard + "jest-haste-map@npm:^26.6.2": version: 26.6.2 resolution: "jest-haste-map@npm:26.6.2" @@ -23203,6 +23352,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:30.2.0": + version: 30.2.0 + resolution: "jest-message-util@npm:30.2.0" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.2.0" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + micromatch: "npm:^4.0.8" + pretty-format: "npm:30.2.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10c0/9c4aae95f9e73a754e5ecababa06e5c00cf549ff1651bbbf9aadc671ee57e688b01606ef0e9932d9dfe3d4b8f4511b6e8d01e131a49d2f82761c820ab93ae519 + languageName: node + linkType: hard + "jest-message-util@npm:^28.1.3": version: 28.1.3 resolution: "jest-message-util@npm:28.1.3" @@ -23260,6 +23426,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:30.0.1": + version: 30.0.1 + resolution: "jest-regex-util@npm:30.0.1" + checksum: 10c0/f30c70524ebde2d1012afe5ffa5691d5d00f7d5ba9e43d588f6460ac6fe96f9e620f2f9b36a02d0d3e7e77bc8efb8b3450ae3b80ac53c8be5099e01bf54f6728 + languageName: node + linkType: hard + "jest-regex-util@npm:^26.0.0": version: 26.0.0 resolution: "jest-regex-util@npm:26.0.0" @@ -23398,6 +23571,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:30.2.0": + version: 30.2.0 + resolution: "jest-util@npm:30.2.0" + dependencies: + "@jest/types": "npm:30.2.0" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.2" + checksum: 10c0/896d663554b35258a87ec1a0a0fdd8741fdf4f3239d09fc52fdd88fa5c411a5ece7903bbbbd7d5194743fcb69f62afc3287e90f57736a91e7df95ad421937936 + languageName: node + linkType: hard + "jest-util@npm:^26.6.2": version: 26.6.2 resolution: "jest-util@npm:26.6.2" @@ -23470,6 +23657,19 @@ __metadata: languageName: node linkType: hard +"jest-worker@npm:30.2.0": + version: 30.2.0 + resolution: "jest-worker@npm:30.2.0" + dependencies: + "@types/node": "npm:*" + "@ungap/structured-clone": "npm:^1.3.0" + jest-util: "npm:30.2.0" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.1.1" + checksum: 10c0/1ea47f6c682ba6cdbd50630544236aabccacf1d88335607206c10871a9777a45b0fc6336c8eb6344e32e69dd7681de17b2199b4d4552b00d48aade303627125c + languageName: node + linkType: hard + "jest-worker@npm:^26.6.2": version: 26.6.2 resolution: "jest-worker@npm:26.6.2" @@ -25362,7 +25562,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.0, micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5": +"micromatch@npm:^4.0.0, micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -27873,6 +28073,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + "pid-port@npm:^0.1.0": version: 0.1.1 resolution: "pid-port@npm:0.1.1" @@ -28619,6 +28826,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.2.0": + version: 30.2.0 + resolution: "pretty-format@npm:30.2.0" + dependencies: + "@jest/schemas": "npm:30.0.5" + ansi-styles: "npm:^5.2.0" + react-is: "npm:^18.3.1" + checksum: 10c0/8fdacfd281aa98124e5df80b2c17223fdcb84433876422b54863a6849381b3059eb42b9806d92d2853826bcb966bcb98d499bea5b1e912d869a3c3107fd38d35 + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -29363,7 +29581,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": +"react-is@npm:^18.0.0, react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 @@ -31841,7 +32059,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": +"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -32364,7 +32582,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:8.1.1, supports-color@npm:^8.0.0": +"supports-color@npm:8.1.1, supports-color@npm:^8.0.0, supports-color@npm:^8.1.1": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -34503,6 +34721,7 @@ __metadata: resolution: "webex-widgets@workspace:." dependencies: "@babel/preset-typescript": "npm:7.25.9" + "@jest/test-sequencer": "npm:^30.2.0" "@playwright/test": "npm:^1.51.1" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/exec": "npm:^6.0.3" From 977f861186a2e6d7c28955a981672235611bb686 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Date: Tue, 9 Dec 2025 16:15:39 +0530 Subject: [PATCH 7/8] fix(contact-center): remove @jest/test-sequencer --- package.json | 1 - .../src/components/task/OutdialCall/AGENTS.md | 320 ------------------ yarn.lock | 243 +------------ 3 files changed, 12 insertions(+), 552 deletions(-) delete mode 100644 packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md diff --git a/package.json b/package.json index a7eed946c..78c839418 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "packageManager": "yarn@4.5.1", "devDependencies": { "@babel/preset-typescript": "7.25.9", - "@jest/test-sequencer": "^30.2.0", "@playwright/test": "^1.51.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^6.0.3", diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md b/packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md deleted file mode 100644 index eef457dae..000000000 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/AGENTS.md +++ /dev/null @@ -1,320 +0,0 @@ -# OutdialCall Component - AI Agent Guide - -## Overview - -The OutdialCall component is part of the `@webex/cc-components` package (v1.28.0-ccwidgets.126) and provides a dialpad UI for agents to initiate outbound calls in Webex Contact Center solutions. It supports both manual dialing and address book functionality. - -## Package Information - -- **Package Name**: `@webex/cc-components` -- **Description**: Webex Contact Center UI Components Library for your custom contact center solutions -- **License**: Cisco's General Terms -- **Main Entry**: `dist/index.js` -- **Types**: `dist/types/index.d.ts` - -## Component Location - -``` -packages/contact-center/cc-components/src/components/task/OutdialCall/ -├── outdial-call.tsx # Main component implementation -├── outdial-call.style.scss # Component styles -├── constants.ts # String constants and utilities -└── AGENTS.md # This file -``` - -## Available Commands - -### From Repository Root (`/Volumes/data/repo/cisco/widgets-repos/cc-widgets`) - -#### Run Tests - -```bash -# Run all unit tests for cc-components package -yarn workspace @webex/cc-components run test:unit - -# Run specific test file -yarn workspace @webex/cc-components run test:unit packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx - -# Run snapshot tests -yarn workspace @webex/cc-components run test:unit packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.snapshot.tsx -``` - -#### Build Commands - -```bash -# Build TypeScript types -yarn workspace @webex/cc-components run build - -# Build source with webpack -yarn workspace @webex/cc-components run build:src - -# Watch mode for development -yarn workspace @webex/cc-components run build:watch -``` - -#### Code Quality - -```bash -# Run ESLint for style checking -yarn workspace @webex/cc-components run test:styles -``` - -#### Clean Commands - -```bash -# Clean dist and node_modules -yarn workspace @webex/cc-components run clean - -# Clean only dist folder -yarn workspace @webex/cc-components run clean:dist -``` - -### From Package Directory (`packages/contact-center/cc-components`) - -```bash -# Run tests -yarn test:unit - -# Build -yarn build -yarn build:src -yarn build:watch - -# Linting -yarn test:styles - -# Clean -yarn clean -yarn clean:dist -``` - -## Component Features - -### Core Functionality - -1. **Dial Pad**: Manual number entry using keypad or direct input -2. **Address Book**: Search and select contacts (when enabled) -3. **ANI Selection**: Choose outbound calling line identity -4. **Input Validation**: Validates phone number format using regex -5. **Tab Navigation**: Switch between Dial Pad and Address Book views - -### Key Props (OutdialCallComponentProps) - -- `logger`: Logger instance for tracking -- `startOutdial`: Function to initiate outbound call -- `getOutdialANIEntries`: Fetch available ANI entries -- `isTelephonyTaskActive`: Disable calling when telephony task is active -- `getAddressBookEntries`: Fetch address book contacts (optional) -- `isAddressBookEnabled`: Enable/disable address book feature (optional) - -### Constants - -Located in `constants.ts`: - -- **OutdialStrings**: UI labels and messages - - `ANI_SELECT_LABEL`, `ANI_SELECT_PLACEHOLDER` - - `CALL_BUTTON_ARIA_LABEL` - - `DN_PLACEHOLDER`, `INCORRECT_DN_FORMAT` - - `OUTDIAL_CALL` - - `ADDRESS_BOOK_SEARCH_PLACEHOLDER` -- **KEY_LIST**: Dialpad keys `['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']` - -## Testing - -### Test Files Location - -``` -packages/contact-center/cc-components/tests/components/task/OutdialCall/ -├── out-dial-call.tsx # Functional tests -└── out-dial-call.snapshot.tsx # Snapshot tests -``` - -### Test Requirements - -When creating or modifying tests, always include: - -1. **Required Props**: All test components must have: - - - `logger` - - `startOutdial` - - `getOutdialANIEntries` - - `isTelephonyTaskActive` - - `getAddressBookEntries` - - `isAddressBookEnabled` - -2. **Mock Setup**: Use `@webex/test-fixtures` for mocking - - ```typescript - import {mockCC} from '@webex/test-fixtures'; - store.store.logger = mockCC.LoggerProxy; - ``` - -3. **Snapshot Testing**: Remove dynamic IDs before snapshots - ```typescript - container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); - ``` - -### Test Coverage Areas - -- ✅ Basic rendering and layout -- ✅ Keypad input and validation -- ✅ Direct number input -- ✅ ANI selection -- ✅ Call button functionality -- ✅ Invalid input handling -- ✅ Address book tab switching -- ✅ Address book entry selection -- ✅ Address book search -- ✅ Empty states - -## Development Workflow - -### 1. Type Checking - -Before running tests, TypeScript compilation must pass: - -```bash -cd packages/contact-center/cc-components -yarn run -T tsc --project tsconfig.test.json -``` - -### 2. Making Changes - -1. Edit component files in `src/components/task/OutdialCall/` -2. Update tests in `tests/components/task/OutdialCall/` -3. Run type checking -4. Run tests -5. Fix any failing tests or type errors - -### 3. Common Issues - -#### Missing Props Error - -``` -Type '{ ... }' is missing the following properties from type 'OutdialCallComponentProps': getAddressBookEntries, isAddressBookEnabled -``` - -**Solution**: Add missing props to test component instantiation: - -```typescript -const props: OutdialCallComponentProps = { - logger: mockCC.LoggerProxy, - startOutdial: jest.fn(), - getOutdialANIEntries: jest.fn().mockResolvedValue([]), - isTelephonyTaskActive: false, - getAddressBookEntries: jest.fn().mockResolvedValue([]), - isAddressBookEnabled: false, -}; -``` - -#### Jest Module Errors - -If you encounter `Cannot find module '@jest/test-sequencer'` or similar: - -```bash -# From repository root -yarn install -``` - -## Dependencies - -### Runtime Dependencies - -- `@momentum-ui/illustrations`: ^1.24.0 -- `@r2wc/react-to-web-component`: 2.0.3 -- `@webex/cc-store`: workspace package -- `@webex/cc-ui-logging`: workspace package - -### Peer Dependencies (Required by consumer) - -- `@momentum-ui/react-collaboration`: >=26.197.0 -- `react`: >=18.3.1 -- `react-dom`: >=18.3.1 - -### Dev Dependencies - -- Testing: Jest 29.7.0, @testing-library/react 16.0.1 -- Build: TypeScript 5.6.3, Webpack 5.94.0 -- Linting: ESLint 9.20.1, Prettier 3.5.1 - -## Type Definitions - -Component types are defined in: - -``` -packages/contact-center/cc-components/src/components/task/task.types.ts -``` - -Look for: - -- `OutdialCallComponentProps` -- `OutdialAniEntry` -- `AddressBookEntry` (from `@webex/contact-center`) - -## Best Practices - -### For Component Development - -1. Always validate phone number input using the regex pattern -2. Handle async operations with proper error logging -3. Use debounce for search inputs (500ms) -4. Implement infinite scroll for large address books -5. Clear states when switching between tabs - -### For Testing - -1. Always compile TypeScript before running tests -2. Mock all async operations -3. Test both enabled and disabled address book scenarios -4. Verify accessibility attributes (aria-label, role, tabindex) -5. Test error states and edge cases - -### For Code Review - -1. Ensure all new props are added to type definitions -2. Update tests when adding new features -3. Verify snapshot tests capture new UI elements -4. Check that error messages are logged appropriately - -## Related Files - -- Type definitions: `../task.types.ts` -- Helper utilities: `../CallControl/CallControlCustom/call-control-custom.utils.ts` -- Constants: `../constants.ts` (DEFAULT_PAGE_SIZE) -- Store integration: `@webex/cc-store` - -## Troubleshooting - -### Tests Not Running - -1. Check TypeScript compilation: `yarn run -T tsc --project tsconfig.test.json` -2. Verify all dependencies installed: `yarn install` -3. Check Jest configuration: `jest.config.js` in package root - -### Type Errors in Tests - -1. Ensure test file imports correct types from `task.types.ts` -2. Verify all required props are provided -3. Check that mock return values match expected types - -### Snapshot Mismatches - -1. Review changes carefully -2. Update snapshots if changes are intentional: `jest --updateSnapshot` -3. Remember to remove dynamic IDs before creating snapshots - -## Quick Reference - -| Action | Command | -| ----------------- | ------------------------------------------------------------------------------------------ | -| Run all tests | `yarn workspace @webex/cc-components run test:unit` | -| Run specific test | `yarn workspace @webex/cc-components run test:unit ` | -| Build component | `yarn workspace @webex/cc-components run build:src` | -| Watch mode | `yarn workspace @webex/cc-components run build:watch` | -| Type check | `cd packages/contact-center/cc-components && yarn run -T tsc --project tsconfig.test.json` | -| Lint code | `yarn workspace @webex/cc-components run test:styles` | -| Clean build | `yarn workspace @webex/cc-components run clean:dist` | - -## Support - -For questions about Webex Contact Center components, refer to the main package documentation or contact the development team. diff --git a/yarn.lock b/yarn.lock index 43b5e1e0d..bd38a42df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,17 +77,6 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/code-frame@npm:7.27.1" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.27.1" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10c0/5dd9a18baa5fce4741ba729acc3a3272c49c25cb8736c4b18e113099520e7ef7b545a4096a26d600e4416157e63e87d66db46aa3fbf0a5f2286da2705c12da00 - languageName: node - linkType: hard - "@babel/compat-data@npm:^7.20.5, @babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.25.9": version: 7.25.9 resolution: "@babel/compat-data@npm:7.25.9" @@ -538,13 +527,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.27.1": - version: 7.28.5 - resolution: "@babel/helper-validator-identifier@npm:7.28.5" - checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 - languageName: node - linkType: hard - "@babel/helper-validator-option@npm:^7.24.7, @babel/helper-validator-option@npm:^7.24.8, @babel/helper-validator-option@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-validator-option@npm:7.25.9" @@ -3028,20 +3010,6 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/console@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - jest-message-util: "npm:30.2.0" - jest-util: "npm:30.2.0" - slash: "npm:^3.0.0" - checksum: 10c0/ecf7ca43698863095500710a5aa08c38b1731c9d89ba32f4d9da7424b53ce1e86b3db8ccbbb27b695f49b4f94bc1d7d0c63c751d73c83d59488a682bc98b7e70 - languageName: node - linkType: hard - "@jest/console@npm:^29.7.0": version: 29.7.0 resolution: "@jest/console@npm:29.7.0" @@ -3163,16 +3131,6 @@ __metadata: languageName: node linkType: hard -"@jest/pattern@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/pattern@npm:30.0.1" - dependencies: - "@types/node": "npm:*" - jest-regex-util: "npm:30.0.1" - checksum: 10c0/32c5a7bfb6c591f004dac0ed36d645002ed168971e4c89bd915d1577031672870032594767557b855c5bc330aa1e39a2f54bf150d2ee88a7a0886e9cb65318bc - languageName: node - linkType: hard - "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -3210,15 +3168,6 @@ __metadata: languageName: node linkType: hard -"@jest/schemas@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/schemas@npm:30.0.5" - dependencies: - "@sinclair/typebox": "npm:^0.34.0" - checksum: 10c0/449dcd7ec5c6505e9ac3169d1143937e67044ae3e66a729ce4baf31812dfd30535f2b3b2934393c97cfdf5984ff581120e6b38f62b8560c8b5b7cc07f4175f65 - languageName: node - linkType: hard - "@jest/schemas@npm:^28.1.3": version: 28.1.3 resolution: "@jest/schemas@npm:28.1.3" @@ -3248,18 +3197,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-result@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/test-result@npm:30.2.0" - dependencies: - "@jest/console": "npm:30.2.0" - "@jest/types": "npm:30.2.0" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - collect-v8-coverage: "npm:^1.0.2" - checksum: 10c0/87566d56b4f90630282c103f41ea9031f4647902f2cd9839bc49af6248301c1a95cbc4432a9512e61f6c6d778e8b925d0573588b26a211d3198c62471ba08c81 - languageName: node - linkType: hard - "@jest/test-result@npm:^29.7.0": version: 29.7.0 resolution: "@jest/test-result@npm:29.7.0" @@ -3284,18 +3221,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-sequencer@npm:^30.2.0": - version: 30.2.0 - resolution: "@jest/test-sequencer@npm:30.2.0" - dependencies: - "@jest/test-result": "npm:30.2.0" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.2.0" - slash: "npm:^3.0.0" - checksum: 10c0/b8366e629b885bfc4b2b95f34f47405e70120eb8601f42de20ea4de308a5088d7bd9f535abf67a2a0d083a2b49864176e1333e036426a5d6b6bd02c1c4dda40b - languageName: node - linkType: hard - "@jest/transform@npm:^26.6.2": version: 26.6.2 resolution: "@jest/transform@npm:26.6.2" @@ -3342,21 +3267,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:30.2.0": - version: 30.2.0 - resolution: "@jest/types@npm:30.2.0" - dependencies: - "@jest/pattern": "npm:30.0.1" - "@jest/schemas": "npm:30.0.5" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10c0/ae121f6963bd9ed1cd9651db7be91bf14c05bff0d0eec4fca9fecf586bea4005e8f1de8cc9b8ef72e424ea96a309d123bef510b55a6a17a3b4b91a39d775e5cd - languageName: node - linkType: hard - "@jest/types@npm:^26.6.2": version: 26.6.2 resolution: "@jest/types@npm:26.6.2" @@ -6824,13 +6734,6 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.34.0": - version: 0.34.41 - resolution: "@sinclair/typebox@npm:0.34.41" - checksum: 10c0/0fb61fc2f90c25e30b19b0096eb8ab3ccef401d3e2acfce42168ff0ee877ba5981c8243fa6b1035ac756cde95316724e978b2837dd642d7e4e095de03a999c90 - languageName: node - linkType: hard - "@sindresorhus/is@npm:^4.0.0, @sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -7921,7 +7824,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 @@ -7937,7 +7840,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4": +"@types/istanbul-reports@npm:^3.0.0": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -8385,7 +8288,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c @@ -8553,15 +8456,6 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.33": - version: 17.0.35 - resolution: "@types/yargs@npm:17.0.35" - dependencies: - "@types/yargs-parser": "npm:*" - checksum: 10c0/609557826a6b85e73ccf587923f6429850d6dc70e420b455bab4601b670bfadf684b09ae288bccedab042c48ba65f1666133cf375814204b544009f57d6eef63 - languageName: node - linkType: hard - "@types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" @@ -8724,13 +8618,6 @@ __metadata: languageName: node linkType: hard -"@ungap/structured-clone@npm:^1.3.0": - version: 1.3.0 - resolution: "@ungap/structured-clone@npm:1.3.0" - checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a - languageName: node - linkType: hard - "@unimodules/core@npm:*": version: 7.2.0 resolution: "@unimodules/core@npm:7.2.0" @@ -13028,7 +12915,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -13091,7 +12978,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.0, anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.0, anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -15138,13 +15025,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.2.0": - version: 4.3.1 - resolution: "ci-info@npm:4.3.1" - checksum: 10c0/7dd82000f514d76ddfe7775e4cb0d66e5c638f5fa0e2a3be29557e898da0d32ac04f231217d414d07fb968b1fbc6d980ee17ddde0d2c516f23da9cfff608f6c1 - languageName: node - linkType: hard - "cidr-regex@npm:^4.1.1": version: 4.1.1 resolution: "cidr-regex@npm:4.1.1" @@ -15424,13 +15304,6 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.2": - version: 1.0.3 - resolution: "collect-v8-coverage@npm:1.0.3" - checksum: 10c0/bc62ba251bcce5e3354a8f88fa6442bee56e3e612fec08d4dfcf66179b41ea0bf544b0f78c4ebc0f8050871220af95bb5c5578a6aef346feea155640582f09dc - languageName: node - linkType: hard - "collection-visit@npm:^1.0.0": version: 1.0.0 resolution: "collection-visit@npm:1.0.0" @@ -19315,7 +19188,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.0, fb-watchman@npm:^2.0.2": +"fb-watchman@npm:^2.0.0": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -20005,7 +19878,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.1.2, fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2": +"fsevents@npm:^2.1.2, fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -20034,7 +19907,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.1.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.1.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -23225,28 +23098,6 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:30.2.0": - version: 30.2.0 - resolution: "jest-haste-map@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - anymatch: "npm:^3.1.3" - fb-watchman: "npm:^2.0.2" - fsevents: "npm:^2.3.3" - graceful-fs: "npm:^4.2.11" - jest-regex-util: "npm:30.0.1" - jest-util: "npm:30.2.0" - jest-worker: "npm:30.2.0" - micromatch: "npm:^4.0.8" - walker: "npm:^1.0.8" - dependenciesMeta: - fsevents: - optional: true - checksum: 10c0/61b4ad5a59b4dfadac2f903f3d723d9017aada268c49b9222ec1e15c4892fd4c36af59b65f37f026d747d829672ab9679509fea5d4248d07a93b892963e1bb4e - languageName: node - linkType: hard - "jest-haste-map@npm:^26.6.2": version: 26.6.2 resolution: "jest-haste-map@npm:26.6.2" @@ -23352,23 +23203,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:30.2.0": - version: 30.2.0 - resolution: "jest-message-util@npm:30.2.0" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@jest/types": "npm:30.2.0" - "@types/stack-utils": "npm:^2.0.3" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - micromatch: "npm:^4.0.8" - pretty-format: "npm:30.2.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/9c4aae95f9e73a754e5ecababa06e5c00cf549ff1651bbbf9aadc671ee57e688b01606ef0e9932d9dfe3d4b8f4511b6e8d01e131a49d2f82761c820ab93ae519 - languageName: node - linkType: hard - "jest-message-util@npm:^28.1.3": version: 28.1.3 resolution: "jest-message-util@npm:28.1.3" @@ -23426,13 +23260,6 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:30.0.1": - version: 30.0.1 - resolution: "jest-regex-util@npm:30.0.1" - checksum: 10c0/f30c70524ebde2d1012afe5ffa5691d5d00f7d5ba9e43d588f6460ac6fe96f9e620f2f9b36a02d0d3e7e77bc8efb8b3450ae3b80ac53c8be5099e01bf54f6728 - languageName: node - linkType: hard - "jest-regex-util@npm:^26.0.0": version: 26.0.0 resolution: "jest-regex-util@npm:26.0.0" @@ -23571,20 +23398,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:30.2.0": - version: 30.2.0 - resolution: "jest-util@npm:30.2.0" - dependencies: - "@jest/types": "npm:30.2.0" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.2" - checksum: 10c0/896d663554b35258a87ec1a0a0fdd8741fdf4f3239d09fc52fdd88fa5c411a5ece7903bbbbd7d5194743fcb69f62afc3287e90f57736a91e7df95ad421937936 - languageName: node - linkType: hard - "jest-util@npm:^26.6.2": version: 26.6.2 resolution: "jest-util@npm:26.6.2" @@ -23657,19 +23470,6 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:30.2.0": - version: 30.2.0 - resolution: "jest-worker@npm:30.2.0" - dependencies: - "@types/node": "npm:*" - "@ungap/structured-clone": "npm:^1.3.0" - jest-util: "npm:30.2.0" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.1.1" - checksum: 10c0/1ea47f6c682ba6cdbd50630544236aabccacf1d88335607206c10871a9777a45b0fc6336c8eb6344e32e69dd7681de17b2199b4d4552b00d48aade303627125c - languageName: node - linkType: hard - "jest-worker@npm:^26.6.2": version: 26.6.2 resolution: "jest-worker@npm:26.6.2" @@ -25562,7 +25362,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.0, micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.0, micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -28073,13 +27873,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.2": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 - languageName: node - linkType: hard - "pid-port@npm:^0.1.0": version: 0.1.1 resolution: "pid-port@npm:0.1.1" @@ -28826,17 +28619,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.2.0": - version: 30.2.0 - resolution: "pretty-format@npm:30.2.0" - dependencies: - "@jest/schemas": "npm:30.0.5" - ansi-styles: "npm:^5.2.0" - react-is: "npm:^18.3.1" - checksum: 10c0/8fdacfd281aa98124e5df80b2c17223fdcb84433876422b54863a6849381b3059eb42b9806d92d2853826bcb966bcb98d499bea5b1e912d869a3c3107fd38d35 - languageName: node - linkType: hard - "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -29581,7 +29363,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.3.1": +"react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 @@ -32059,7 +31841,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -32582,7 +32364,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:8.1.1, supports-color@npm:^8.0.0, supports-color@npm:^8.1.1": +"supports-color@npm:8.1.1, supports-color@npm:^8.0.0": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -34721,7 +34503,6 @@ __metadata: resolution: "webex-widgets@workspace:." dependencies: "@babel/preset-typescript": "npm:7.25.9" - "@jest/test-sequencer": "npm:^30.2.0" "@playwright/test": "npm:^1.51.1" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/exec": "npm:^6.0.3" From 1298332333f73a457e304852f8c8664dc0219141 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar Date: Fri, 12 Dec 2025 15:51:13 +0530 Subject: [PATCH 8/8] feat(contact-center): address review comments --- .../task/OutdialCall/outdial-call.tsx | 38 +- .../cc-components/src/hooks/index.ts | 1 + .../src/hooks/useIntersectionObserver.ts | 66 + .../out-dial-call.snapshot.tsx.snap | 1590 +++++++++++++++++ .../OutdialCall/out-dial-call.snapshot.tsx | 144 ++ .../task/OutdialCall/out-dial-call.tsx | 472 +++++ .../hooks/useIntersectionObserver.test.ts | 304 ++++ 7 files changed, 2590 insertions(+), 25 deletions(-) create mode 100644 packages/contact-center/cc-components/src/hooks/index.ts create mode 100644 packages/contact-center/cc-components/src/hooks/useIntersectionObserver.ts create mode 100644 packages/contact-center/cc-components/tests/hooks/useIntersectionObserver.test.ts diff --git a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx index 2673831db..b3a9db796 100644 --- a/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx +++ b/packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState, useRef, useCallback} from 'react'; +import React, {useEffect, useMemo, useState, useCallback} from 'react'; import {withMetrics} from '@webex/cc-ui-logging'; import {Input, Button, Icon, Tab, TabList, Avatar, Spinner} from '@momentum-design/components/dist/react'; import {AddressBookEntry} from '@webex/contact-center'; @@ -12,6 +12,7 @@ import {OutdialAniEntry, OutdialCallComponentProps} from '../task.types'; import {OutdialStrings, KEY_LIST} from './constants'; import {DEFAULT_PAGE_SIZE} from '../constants'; import {createInitials, debounce} from '../CallControl/CallControlCustom/call-control-custom.utils'; +import {useIntersectionObserver} from '../../../hooks'; import './outdial-call.style.scss'; @@ -55,9 +56,6 @@ const OutdialCallComponent: React.FunctionComponent = const [hasMoreAddressBookEntries, setHasMoreAddressBookEntries] = useState(true); const [isSelectOpen, setIsSelectOpen] = useState(false); - // Ref for infinite scroll observer - const observerTarget = useRef(null); - // Validate the input format using regex from agent desktop const regExForDnSpecialChars = useMemo( () => new RegExp('^[+1][0-9]{3,18}$|^[*#][+1][0-9*#:]{3,18}$|^[0-9*#]{3,18}$'), @@ -85,6 +83,12 @@ const OutdialCallComponent: React.FunctionComponent = const fetchAddressBookEntries = async (page = 0, search = '') => { try { const result = await getAddressBookEntries({page, pageSize: DEFAULT_PAGE_SIZE, search}); + + logger?.log(`CC-Widgets: Task: Address book entries fetched: ${result.data.length}`, { + module: 'OutdialCallComponent', + method: 'fetchAddressBookEntries', + }); + setAddressBookEntries((prevEntries) => [...prevEntries, ...result.data]); // Check if there are more entries to load @@ -189,27 +193,11 @@ const OutdialCallComponent: React.FunctionComponent = }, [addressBookLoading, hasMoreAddressBookEntries, addressBookPage, addressBookSearch]); // Set up IntersectionObserver for infinite scroll - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasMoreAddressBookEntries && !addressBookLoading) { - loadMoreAddressBookEntries(); - } - }, - {threshold: 1.0} - ); - - const currentTarget = observerTarget.current; - if (currentTarget) { - observer.observe(currentTarget); - } - - return () => { - if (currentTarget) { - observer.unobserve(currentTarget); - } - }; - }, [loadMoreAddressBookEntries, hasMoreAddressBookEntries, addressBookLoading]); + const observerTarget = useIntersectionObserver({ + onIntersect: loadMoreAddressBookEntries, + enabled: hasMoreAddressBookEntries && !addressBookLoading, + options: {threshold: 1.0}, + }); const renderAddressBook = () => { return ( diff --git a/packages/contact-center/cc-components/src/hooks/index.ts b/packages/contact-center/cc-components/src/hooks/index.ts new file mode 100644 index 000000000..ef5b4b3c4 --- /dev/null +++ b/packages/contact-center/cc-components/src/hooks/index.ts @@ -0,0 +1 @@ +export {useIntersectionObserver} from './useIntersectionObserver'; diff --git a/packages/contact-center/cc-components/src/hooks/useIntersectionObserver.ts b/packages/contact-center/cc-components/src/hooks/useIntersectionObserver.ts new file mode 100644 index 000000000..f96652b68 --- /dev/null +++ b/packages/contact-center/cc-components/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,66 @@ +import {useEffect, useRef} from 'react'; + +interface UseIntersectionObserverOptions { + /** + * Callback function to be called when the target element intersects + */ + onIntersect: () => void; + /** + * Whether the observer should be active + */ + enabled?: boolean; + /** + * IntersectionObserver options + */ + options?: IntersectionObserverInit; +} + +/** + * Custom hook that sets up an IntersectionObserver for infinite scroll or lazy loading + * + * @param onIntersect - Callback to execute when intersection occurs + * @param enabled - Whether the observer is active (default: true) + * @param options - IntersectionObserver options (default: {threshold: 1.0}) + * @returns ref - React ref to attach to the sentinel element + * + * @example + * const observerRef = useIntersectionObserver({ + * onIntersect: loadMoreItems, + * enabled: hasMore && !loading, + * options: { threshold: 1.0 } + * }); + * + * return ( + *
+ * ); + */ +export const useIntersectionObserver = ({ + onIntersect, + enabled = true, + options = {threshold: 1.0}, +}: UseIntersectionObserverOptions) => { + const observerTarget = useRef(null); + + useEffect(() => { + if (!enabled) return; + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + onIntersect(); + } + }, options); + + const currentTarget = observerTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [onIntersect, enabled, options]); + + return observerTarget; +}; diff --git a/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap index f7f96aebc..ba0b83ff3 100644 --- a/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap @@ -402,6 +402,1596 @@ exports[`Outdial Call Component Address Book functionality renders address book
`; +exports[`Outdial Call Component Address Book functionality renders address book with infinite scroll sentinel 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + Contact 0 +

    +

    + +14690000 +

    +
    +
  • +
  • + +
    +

    + Contact 1 +

    +

    + +14690001 +

    +
    +
  • +
  • + +
    +

    + Contact 2 +

    +

    + +14690002 +

    +
    +
  • +
  • + +
    +

    + Contact 3 +

    +

    + +14690003 +

    +
    +
  • +
  • + +
    +

    + Contact 4 +

    +

    + +14690004 +

    +
    +
  • +
  • + +
    +

    + Contact 5 +

    +

    + +14690005 +

    +
    +
  • +
  • + +
    +

    + Contact 6 +

    +

    + +14690006 +

    +
    +
  • +
  • + +
    +

    + Contact 7 +

    +

    + +14690007 +

    +
    +
  • +
  • + +
    +

    + Contact 8 +

    +

    + +14690008 +

    +
    +
  • +
  • + +
    +

    + Contact 9 +

    +

    + +14690009 +

    +
    +
  • +
  • + +
    +

    + Contact 10 +

    +

    + +146900010 +

    +
    +
  • +
  • + +
    +

    + Contact 11 +

    +

    + +146900011 +

    +
    +
  • +
  • + +
    +

    + Contact 12 +

    +

    + +146900012 +

    +
    +
  • +
  • + +
    +

    + Contact 13 +

    +

    + +146900013 +

    +
    +
  • +
  • + +
    +

    + Contact 14 +

    +

    + +146900014 +

    +
    +
  • +
  • + +
    +

    + Contact 15 +

    +

    + +146900015 +

    +
    +
  • +
  • + +
    +

    + Contact 16 +

    +

    + +146900016 +

    +
    +
  • +
  • + +
    +

    + Contact 17 +

    +

    + +146900017 +

    +
    +
  • +
  • + +
    +

    + Contact 18 +

    +

    + +146900018 +

    +
    +
  • +
  • + +
    +

    + Contact 19 +

    +

    + +146900019 +

    +
    +
  • +
  • + +
    +

    + Contact 20 +

    +

    + +146900020 +

    +
    +
  • +
  • + +
    +

    + Contact 21 +

    +

    + +146900021 +

    +
    +
  • +
  • + +
    +

    + Contact 22 +

    +

    + +146900022 +

    +
    +
  • +
  • + +
    +

    + Contact 23 +

    +

    + +146900023 +

    +
    +
  • +
  • + +
    +

    + Contact 24 +

    +

    + +146900024 +

    +
    +
  • +
    +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality renders address book without sentinel when no more entries 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + Contact 0 +

    +

    + +14690000 +

    +
    +
  • +
  • + +
    +

    + Contact 1 +

    +

    + +14690001 +

    +
    +
  • +
  • + +
    +

    + Contact 2 +

    +

    + +14690002 +

    +
    +
  • +
  • + +
    +

    + Contact 3 +

    +

    + +14690003 +

    +
    +
  • +
  • + +
    +

    + Contact 4 +

    +

    + +14690004 +

    +
    +
  • +
  • + +
    +

    + Contact 5 +

    +

    + +14690005 +

    +
    +
  • +
  • + +
    +

    + Contact 6 +

    +

    + +14690006 +

    +
    +
  • +
  • + +
    +

    + Contact 7 +

    +

    + +14690007 +

    +
    +
  • +
  • + +
    +

    + Contact 8 +

    +

    + +14690008 +

    +
    +
  • +
  • + +
    +

    + Contact 9 +

    +

    + +14690009 +

    +
    +
  • +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + +exports[`Outdial Call Component Address Book functionality renders loading spinner while loading more entries 1`] = ` +
+
+ + + + +
+
+ +
    +
  • + +
    +

    + Contact 0 +

    +

    + +14690000 +

    +
    +
  • +
  • + +
    +

    + Contact 1 +

    +

    + +14690001 +

    +
    +
  • +
  • + +
    +

    + Contact 2 +

    +

    + +14690002 +

    +
    +
  • +
  • + +
    +

    + Contact 3 +

    +

    + +14690003 +

    +
    +
  • +
  • + +
    +

    + Contact 4 +

    +

    + +14690004 +

    +
    +
  • +
  • + +
    +

    + Contact 5 +

    +

    + +14690005 +

    +
    +
  • +
  • + +
    +

    + Contact 6 +

    +

    + +14690006 +

    +
    +
  • +
  • + +
    +

    + Contact 7 +

    +

    + +14690007 +

    +
    +
  • +
  • + +
    +

    + Contact 8 +

    +

    + +14690008 +

    +
    +
  • +
  • + +
    +

    + Contact 9 +

    +

    + +14690009 +

    +
    +
  • +
  • + +
    +

    + Contact 10 +

    +

    + +146900010 +

    +
    +
  • +
  • + +
    +

    + Contact 11 +

    +

    + +146900011 +

    +
    +
  • +
  • + +
    +

    + Contact 12 +

    +

    + +146900012 +

    +
    +
  • +
  • + +
    +

    + Contact 13 +

    +

    + +146900013 +

    +
    +
  • +
  • + +
    +

    + Contact 14 +

    +

    + +146900014 +

    +
    +
  • +
  • + +
    +

    + Contact 15 +

    +

    + +146900015 +

    +
    +
  • +
  • + +
    +

    + Contact 16 +

    +

    + +146900016 +

    +
    +
  • +
  • + +
    +

    + Contact 17 +

    +

    + +146900017 +

    +
    +
  • +
  • + +
    +

    + Contact 18 +

    +

    + +146900018 +

    +
    +
  • +
  • + +
    +

    + Contact 19 +

    +

    + +146900019 +

    +
    +
  • +
  • + +
    +

    + Contact 20 +

    +

    + +146900020 +

    +
    +
  • +
  • + +
    +

    + Contact 21 +

    +

    + +146900021 +

    +
    +
  • +
  • + +
    +

    + Contact 22 +

    +

    + +146900022 +

    +
    +
  • +
  • + +
    +

    + Contact 23 +

    +

    + +146900023 +

    +
    +
  • +
  • + +
    +

    + Contact 24 +

    +

    + +146900024 +

    +
    +
  • +
    +
    + +
    +
    +
+
+
+
+ +
+ + + +
+
+ +
+
+`; + exports[`Outdial Call Component Address Book functionality renders with address book enabled 1`] = `
{ container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); expect(container).toMatchSnapshot(); }); + + it('renders address book with infinite scroll sentinel', async () => { + // Mock IntersectionObserver + const mockIntersectionObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + global.IntersectionObserver = mockIntersectionObserver as unknown as typeof IntersectionObserver; + + const manyEntriesProps = { + ...addressBookProps, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, // More entries available + }), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + await screen.findByText('Contact 0'); + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + + delete (global as {IntersectionObserver?: typeof IntersectionObserver}).IntersectionObserver; + }); + + it('renders loading spinner while loading more entries', async () => { + // Mock IntersectionObserver + let intersectionCallback: IntersectionObserverCallback; + const mockIntersectionObserver = jest.fn().mockImplementation((callback) => { + intersectionCallback = callback; + return { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + }); + global.IntersectionObserver = mockIntersectionObserver as unknown as typeof IntersectionObserver; + + let resolvePromise: (value: {data: Array<{id: string; name: string; number: string}>; total: number}) => void; + const loadingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const loadingProps = { + ...addressBookProps, + getAddressBookEntries: jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }) + .mockImplementationOnce(() => loadingPromise), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + await screen.findByText('Contact 0'); + + // Trigger intersection observer + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + if (intersectionCallback!) { + intersectionCallback!([mockEntry], {} as IntersectionObserver); + } + + // Wait for spinner to appear + await waitFor(() => { + const spinner = container.querySelector('mdc-spinner'); + expect(spinner).toBeInTheDocument(); + }); + + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + + // Clean up + resolvePromise!({ + data: [], + total: 50, + }); + + delete (global as {IntersectionObserver?: typeof IntersectionObserver}).IntersectionObserver; + }); + + it('renders address book without sentinel when no more entries', async () => { + // Mock IntersectionObserver + const mockIntersectionObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + global.IntersectionObserver = mockIntersectionObserver as unknown as typeof IntersectionObserver; + + const completeProps = { + ...addressBookProps, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: Array.from({length: 10}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 10, // Less than page size, no more entries + }), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + await screen.findByText('Contact 0'); + + // Wait for render to complete + await waitFor(() => { + const observerElement = container.querySelector('.address-book-observer'); + expect(observerElement).not.toBeInTheDocument(); + }); + + // Remove IDs to avoid snapshot issues with dynamic IDs + container.querySelectorAll('[id^="mdc-input"]').forEach((el) => el.removeAttribute('id')); + expect(container).toMatchSnapshot(); + + delete (global as {IntersectionObserver?: typeof IntersectionObserver}).IntersectionObserver; + }); }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx b/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx index 1c186983c..09fd33cb5 100644 --- a/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx +++ b/packages/contact-center/cc-components/tests/components/task/OutdialCall/out-dial-call.tsx @@ -671,4 +671,476 @@ describe('OutdialCallComponent', () => { }); }); }); + + describe('Infinite scroll functionality', () => { + // Mock IntersectionObserver + let mockIntersectionObserver: jest.Mock; + let intersectionCallback: IntersectionObserverCallback; + + // Helper function to get the tablist element (web component) + const getTabList = async (container: HTMLElement) => { + const tabList = container.querySelector('mdc-tablist'); + if (!tabList) { + throw new Error('TabList not found'); + } + return tabList; + }; + + beforeEach(() => { + mockIntersectionObserver = jest.fn().mockImplementation((callback) => { + intersectionCallback = callback; + return { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + }); + + global.IntersectionObserver = mockIntersectionObserver as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + delete (global as {IntersectionObserver?: typeof IntersectionObserver}).IntersectionObserver; + }); + + it('renders sentinel element for infinite scroll when there are more entries', async () => { + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: jest.fn().mockResolvedValue({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + const observerElement = container.querySelector('.address-book-observer'); + expect(observerElement).toBeInTheDocument(); + }); + + it('does not render sentinel element when no more entries to load', async () => { + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 10}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 10, + }) + .mockResolvedValueOnce({ + data: [], + total: 10, + }), + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + // Trigger intersection to load more + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + await waitFor(() => { + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + }); + + // Wait for loading to complete + await waitFor(() => { + const observerElement = container.querySelector('.address-book-observer'); + expect(observerElement).not.toBeInTheDocument(); + }); + }); + + it('loads more entries when scrolling to bottom', async () => { + const mockGetAddressBook = jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }) + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i + 25}`, + name: `Contact ${i + 25}`, + number: `+1469000${i + 25}`, + })), + total: 50, + }); + + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: mockGetAddressBook, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + // Wait for initial entries + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + // Verify initial call + expect(mockGetAddressBook).toHaveBeenCalledWith({page: 0, pageSize: 25, search: ''}); + + // Trigger intersection observer + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + + // Wait for second page to load + await waitFor(() => { + expect(mockGetAddressBook).toHaveBeenCalledWith({page: 1, pageSize: 25, search: ''}); + }); + + // Verify new entries are appended + await waitFor(() => { + expect(screen.getByText('Contact 25')).toBeInTheDocument(); + }); + + // Verify old entries are still present + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + it('shows loading spinner while loading more entries', async () => { + let resolvePromise: + | ((value: {data: Array<{id: string; name: string; number: string}>; total: number}) => void) + | undefined; + const loadingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const mockGetAddressBook = jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }) + .mockImplementationOnce(() => loadingPromise); + + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: mockGetAddressBook, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + // Trigger intersection observer + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + + // Check for spinner + await waitFor(() => { + const spinner = container.querySelector('mdc-spinner'); + expect(spinner).toBeInTheDocument(); + }); + + // Resolve the promise + resolvePromise!({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i + 25}`, + name: `Contact ${i + 25}`, + number: `+1469000${i + 25}`, + })), + total: 50, + }); + }); + + it('does not load more entries when already loading', async () => { + const mockGetAddressBook = jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }) + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i + 25}`, + name: `Contact ${i + 25}`, + number: `+1469000${i + 25}`, + })), + total: 50, + }); + + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: mockGetAddressBook, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + // Verify initial call + expect(mockGetAddressBook).toHaveBeenCalledTimes(1); + + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + // Trigger intersection observer once + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + + // Wait for second page to load + await waitFor(() => { + expect(screen.getByText('Contact 25')).toBeInTheDocument(); + }); + + // Should have been called exactly twice (initial + one scroll) + expect(mockGetAddressBook).toHaveBeenCalledTimes(2); + }); + + it('handles error while loading more entries', async () => { + const mockGetAddressBook = jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }) + .mockRejectedValueOnce(new Error('Network error')); + + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: mockGetAddressBook, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + + // Wait for error to be handled + await waitFor(() => { + expect(mockGetAddressBook).toHaveBeenCalledTimes(2); + }); + + // After error, entries are cleared and no more entries flag is set + await waitFor(() => { + expect(screen.queryByText('Contact 0')).not.toBeInTheDocument(); + }); + + // Observer element should not be present anymore (no more entries) + const observerElement = container.querySelector('.address-book-observer'); + expect(observerElement).not.toBeInTheDocument(); + }); + + it('stops loading when returned data is less than page size', async () => { + const mockGetAddressBook = jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `Contact ${i}`, + number: `+1469000${i}`, + })), + total: 30, + }) + .mockResolvedValueOnce({ + data: Array.from({length: 5}, (_, i) => ({ + id: `${i + 25}`, + name: `Contact ${i + 25}`, + number: `+1469000${i + 25}`, + })), + total: 30, + }); + + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: mockGetAddressBook, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('Contact 0')).toBeInTheDocument(); + }); + + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + + // Wait for second page to load + await waitFor(() => { + expect(screen.getByText('Contact 25')).toBeInTheDocument(); + }); + + // Observer element should not be present anymore + await waitFor(() => { + const observerElement = container.querySelector('.address-book-observer'); + expect(observerElement).not.toBeInTheDocument(); + }); + }); + + it('maintains scroll pagination with search', async () => { + const mockGetAddressBook = jest + .fn() + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i}`, + name: `John ${i}`, + number: `+1469000${i}`, + })), + total: 50, + }) + .mockResolvedValueOnce({ + data: Array.from({length: 25}, (_, i) => ({ + id: `${i + 25}`, + name: `John ${i + 25}`, + number: `+1469000${i + 25}`, + })), + total: 50, + }); + + const addressBookProps: OutdialCallComponentProps = { + ...props, + isAddressBookEnabled: true, + getAddressBookEntries: mockGetAddressBook, + }; + + const {container} = render(); + const tabList = await waitFor(() => getTabList(container)); + const tabs = within(tabList as HTMLElement).getAllByRole('tab'); + const addressBookTab = tabs[0]; + fireEvent.click(addressBookTab); + + await waitFor(() => { + expect(screen.getByText('John 0')).toBeInTheDocument(); + }); + + // Search + const searchInput = await screen.findByTestId('outdial-address-book-search-input'); + const searchEvent = new Event('input', {bubbles: true}); + Object.defineProperty(searchEvent, 'target', { + writable: false, + value: {value: 'John'}, + }); + fireEvent(searchInput, searchEvent); + + // Wait for debounced search + await waitFor( + () => { + expect(mockGetAddressBook).toHaveBeenCalledWith({page: 0, pageSize: 25, search: 'John'}); + }, + {timeout: 1000} + ); + + // Trigger load more with search term + const mockEntry = { + isIntersecting: true, + target: container.querySelector('.address-book-observer'), + } as IntersectionObserverEntry; + + if (intersectionCallback) { + intersectionCallback([mockEntry], {} as IntersectionObserver); + } + + await waitFor(() => { + expect(mockGetAddressBook).toHaveBeenCalledWith({page: 1, pageSize: 25, search: 'John'}); + }); + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/hooks/useIntersectionObserver.test.ts b/packages/contact-center/cc-components/tests/hooks/useIntersectionObserver.test.ts new file mode 100644 index 000000000..e02e85749 --- /dev/null +++ b/packages/contact-center/cc-components/tests/hooks/useIntersectionObserver.test.ts @@ -0,0 +1,304 @@ +import {renderHook} from '@testing-library/react'; +import {useIntersectionObserver} from '../../src/hooks/useIntersectionObserver'; + +describe('useIntersectionObserver', () => { + let mockIntersectionObserver: jest.Mock; + let observeCallback: IntersectionObserverCallback; + let mockObserve: jest.Mock; + let mockUnobserve: jest.Mock; + let mockDisconnect: jest.Mock; + + beforeEach(() => { + mockObserve = jest.fn(); + mockUnobserve = jest.fn(); + mockDisconnect = jest.fn(); + + mockIntersectionObserver = jest.fn().mockImplementation((callback) => { + observeCallback = callback; + return { + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, + }; + }); + + global.IntersectionObserver = mockIntersectionObserver as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + jest.clearAllMocks(); + delete (global as {IntersectionObserver?: typeof IntersectionObserver}).IntersectionObserver; + }); + + it('creates an IntersectionObserver on mount', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + options: {threshold: 1.0}, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), {threshold: 1.0}); + }); + + it('does not create observer when enabled is false', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: false, + }) + ); + + expect(mockIntersectionObserver).not.toHaveBeenCalled(); + }); + + it('observes the target element when ref is set', () => { + const onIntersect = jest.fn(); + const {result} = renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + }) + ); + + // Create a mock element + const mockElement = document.createElement('div'); + + // Manually set the ref + if (result.current.current === null) { + (result.current as React.MutableRefObject).current = mockElement; + } + + // Re-render to trigger the effect + renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + }) + ); + + // The observer should be created + expect(mockIntersectionObserver).toHaveBeenCalled(); + }); + + it('calls onIntersect when element intersects', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + }) + ); + + // Simulate intersection + const mockEntry: IntersectionObserverEntry = { + isIntersecting: true, + target: document.createElement('div'), + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 1, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: Date.now(), + }; + + observeCallback([mockEntry], {} as IntersectionObserver); + + expect(onIntersect).toHaveBeenCalledTimes(1); + }); + + it('does not call onIntersect when element is not intersecting', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + }) + ); + + // Simulate non-intersection + const mockEntry: IntersectionObserverEntry = { + isIntersecting: false, + target: document.createElement('div'), + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 0, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: Date.now(), + }; + + observeCallback([mockEntry], {} as IntersectionObserver); + + expect(onIntersect).not.toHaveBeenCalled(); + }); + + it('uses default options when not provided', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), {threshold: 1.0}); + }); + + it('uses custom threshold option', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + options: {threshold: 0.5}, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), {threshold: 0.5}); + }); + + it('recreates observer when enabled changes from false to true', () => { + const onIntersect = jest.fn(); + const {rerender} = renderHook( + ({enabled}) => + useIntersectionObserver({ + onIntersect, + enabled, + }), + {initialProps: {enabled: false}} + ); + + expect(mockIntersectionObserver).not.toHaveBeenCalled(); + + rerender({enabled: true}); + + expect(mockIntersectionObserver).toHaveBeenCalled(); + }); + + it('cleans up observer on unmount', () => { + const onIntersect = jest.fn(); + const {unmount} = renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalled(); + + unmount(); + + // The cleanup should have been called + // Note: We can't directly test unobserve here without a real ref, + // but we can verify the observer was created + expect(mockIntersectionObserver).toHaveBeenCalledTimes(1); + }); + + it('supports root margin option', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + options: { + threshold: 1.0, + rootMargin: '10px', + }, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), { + threshold: 1.0, + rootMargin: '10px', + }); + }); + + it('supports root option', () => { + const onIntersect = jest.fn(); + const rootElement = document.createElement('div'); + + renderHook(() => + useIntersectionObserver({ + onIntersect, + options: { + threshold: 1.0, + root: rootElement, + }, + }) + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), { + threshold: 1.0, + root: rootElement, + }); + }); + + it('handles multiple intersections in sequence', () => { + const onIntersect = jest.fn(); + renderHook(() => + useIntersectionObserver({ + onIntersect, + enabled: true, + }) + ); + + const mockEntry: IntersectionObserverEntry = { + isIntersecting: true, + target: document.createElement('div'), + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 1, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: Date.now(), + }; + + // Trigger multiple times + observeCallback([mockEntry], {} as IntersectionObserver); + observeCallback([mockEntry], {} as IntersectionObserver); + observeCallback([mockEntry], {} as IntersectionObserver); + + expect(onIntersect).toHaveBeenCalledTimes(3); + }); + + it('recreates observer when onIntersect callback changes', () => { + const onIntersect1 = jest.fn(); + const onIntersect2 = jest.fn(); + + const {rerender} = renderHook( + ({callback}) => + useIntersectionObserver({ + onIntersect: callback, + enabled: true, + }), + {initialProps: {callback: onIntersect1}} + ); + + expect(mockIntersectionObserver).toHaveBeenCalledTimes(1); + + // Rerender with a new callback + rerender({callback: onIntersect2}); + + // Observer should be recreated + expect(mockIntersectionObserver).toHaveBeenCalledTimes(2); + }); + + it('recreates observer when options change', () => { + const onIntersect = jest.fn(); + + const {rerender} = renderHook( + ({threshold}) => + useIntersectionObserver({ + onIntersect, + enabled: true, + options: {threshold}, + }), + {initialProps: {threshold: 1.0}} + ); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), {threshold: 1.0}); + + // Rerender with different threshold + rerender({threshold: 0.5}); + + expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), {threshold: 0.5}); + }); +});