diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts index 06d48003d..3c0edccbf 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts @@ -612,6 +612,28 @@ export const filterAvailableAgents = (agents: BuddyDetails[], logger?): BuddyDet } }; +/** + * Filters buddy agents by a free-text query across name, dn and id. + */ +export const filterAgentsByQuery = (agents: BuddyDetails[], query: string): BuddyDetails[] => { + const searchTerm = (query ?? '').trim().toLowerCase(); + if (!searchTerm) return agents ?? []; + return (agents ?? []).filter((agent) => + `${agent.agentName ?? ''}|${(agent as {dn?: string}).dn ?? ''}|${agent.agentId ?? ''}` + .toLowerCase() + .includes(searchTerm) + ); +}; + +/** + * Returns agents to display for current category, applying search only for Agents tab, since other tabs support via the SDK + */ +export const getAgentsForDisplay = ( + selectedCategory: 'Agents' | string, + agents: BuddyDetails[], + query: string +): BuddyDetails[] => (selectedCategory === 'Agents' ? filterAgentsByQuery(agents, query) : agents || []); + /** * Filters available queues */ @@ -655,3 +677,36 @@ export const debounce = unknown>( }; } }; + +/** + * Helpers for Dial Number / Entry Point manual actions + */ +export const shouldAddConsultTransferAction = ( + selectedCategory: string, + isEntryPointTabVisible: boolean, + query: string, + entryPoints: {id: string; name: string}[], + onDialNumberSelect: ((dialNumber: string) => void) | undefined, + onEntryPointSelect: ((entryPointId: string, entryPointName: string) => void) | undefined +): {visible: boolean; onClick?: () => void; title?: string} => { + const DN_REGEX = new RegExp('^[+1][0-9]{3,18}$|^[*#:][+1][0-9*#:]{3,18}$|^[0-9*#:]{3,18}$'); + + const isDial = selectedCategory === 'Dial Number'; + const isEntry = selectedCategory === 'Entry Point' && isEntryPointTabVisible; + const valid = DN_REGEX.test(query || ''); + + if (isDial) { + return valid && onDialNumberSelect + ? {visible: true, onClick: () => onDialNumberSelect(query), title: query} + : {visible: false}; + } + + if (isEntry) { + const match = query ? entryPoints?.find((e) => e.name === query || e.id === query) : null; + return valid && match && onEntryPointSelect + ? {visible: true, onClick: () => onEntryPointSelect(match.id, match.name), title: match.name} + : {visible: false}; + } + + return {visible: false}; +}; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index b17e2158a..dd43968cf 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -1,9 +1,15 @@ import React from 'react'; -import {Text, ListNext, TextInput, Button} from '@momentum-ui/react-collaboration'; +import {Text, ListNext, TextInput, Button, ButtonCircle, TooltipNext} from '@momentum-ui/react-collaboration'; +import {Icon} from '@momentum-design/components/dist/react'; import ConsultTransferListComponent from './consult-transfer-list-item'; import {ConsultTransferPopoverComponentProps} from '../../task.types'; import ConsultTransferEmptyState from './consult-transfer-empty-state'; -import {isAgentsEmpty, handleAgentSelection, handleQueueSelection} from './call-control-custom.utils'; +import { + handleAgentSelection, + handleQueueSelection, + shouldAddConsultTransferAction, + getAgentsForDisplay, +} from './call-control-custom.utils'; import {useConsultTransferPopover} from './consult-transfer-popover-hooks'; import { SEARCH_PLACEHOLDER, @@ -14,6 +20,7 @@ import { LOADING_MORE_ENTRY_POINTS, NO_DATA_AVAILABLE_CONSULT_TRANSFER, } from '../../constants'; +import {CATEGORY_AGENTS, CATEGORY_DIAL_NUMBER, CATEGORY_ENTRY_POINT, CATEGORY_QUEUES} from '../../task.types'; const ConsultTransferPopoverComponent: React.FC = ({ heading, @@ -31,6 +38,7 @@ const ConsultTransferPopoverComponent: React.FC { const {showDialNumberTab = true, showEntryPointTab = true} = consultTransferOptions || {}; + const isEntryPointTabVisible = showEntryPointTab && heading === 'Consult'; const { selectedCategory, searchQuery, @@ -51,15 +59,13 @@ const ConsultTransferPopoverComponent: React.FC( items: T[], onButtonPress: (item: T) => void @@ -84,11 +90,18 @@ const ConsultTransferPopoverComponent: React.FC ); - const noQueues = queuesData.length === 0; + const noQueues = !allowConsultToQueue || queuesData.length === 0; const noDialNumbers = !showDialNumberTab || dialNumbers.length === 0; - const noEntryPoints = !showEntryPointTab || entryPoints.length === 0; + const noEntryPoints = !isEntryPointTabVisible || entryPoints.length === 0; - const hasAnyData = !noAgents || !noQueues || !noDialNumbers || !noEntryPoints; + const consultTransferManualAction = shouldAddConsultTransferAction( + selectedCategory, + isEntryPointTabVisible, + searchQuery, + entryPoints, + onDialNumberSelect, + onEntryPointSelect + ); return (
@@ -96,7 +109,7 @@ const ConsultTransferPopoverComponent: React.FC -
+
+ {consultTransferManualAction.visible && ( + + + + } + color="primary" + delay={[0, 0]} + placement="bottom-start" + type="description" + variant="small" + className="tooltip" + > +

{`${heading} via search`}

+
+ )}
- + {allowConsultToQueue && ( + + )} {showDialNumberTab && ( )} - {showEntryPointTab && ( + {isEntryPointTabVisible && ( )}
- {!hasAnyData && } - {selectedCategory === 'Agents' && - !noAgents && - renderList( - buddyAgents.map((agent) => ({id: agent.agentId, name: agent.agentName})), - (item) => handleAgentSelection(item.id, item.name, onAgentSelect, logger) - )} + (getAgentsForDisplay(selectedCategory, buddyAgents, searchQuery).length === 0 ? ( + + ) : ( + renderList( + getAgentsForDisplay(selectedCategory, buddyAgents, searchQuery).map((agent) => ({ + id: agent.agentId, + name: agent.agentName, + })), + (item) => handleAgentSelection(item.id, item.name, onAgentSelect, logger) + ) + ))} - {selectedCategory === 'Queues' && !noQueues && ( -
- {renderList( - queuesData.map((q) => ({id: q.id, name: q.name})), - (item) => handleQueueSelection(item.id, item.name, onQueueSelect, logger) - )} - {hasMoreQueues && ( -
- {loadingQueues ? ( - - {LOADING_MORE_QUEUES} - - ) : ( - - {SCROLL_TO_LOAD_MORE} - - )} -
- )} -
- )} + {selectedCategory === 'Queues' && + (noQueues ? ( + + ) : ( +
+ {renderList( + queuesData.map((q) => ({id: q.id, name: q.name})), + (item) => handleQueueSelection(item.id, item.name, onQueueSelect, logger) + )} + {hasMoreQueues && ( +
+ {loadingQueues ? ( + + {LOADING_MORE_QUEUES} + + ) : ( + + {SCROLL_TO_LOAD_MORE} + + )} +
+ )} +
+ ))} - {showDialNumberTab && selectedCategory === 'Dial Number' && !noDialNumbers && ( -
- {renderList( - dialNumbers.map((d) => ({id: d.id, name: d.name, number: d.number})), - (item) => { - if (item.number) { - if (onDialNumberSelect) { - onDialNumberSelect(item.number); + {showDialNumberTab && + selectedCategory === CATEGORY_DIAL_NUMBER && + (noDialNumbers ? ( + + ) : ( +
+ {renderList( + dialNumbers.map((d) => ({id: d.id, name: d.name, number: d.number})), + (item) => { + if (item.number) { + if (onDialNumberSelect) { + onDialNumberSelect(item.number); + } } } - } - )} - {hasMoreDialNumbers && ( -
- {loadingDialNumbers ? ( - - {LOADING_MORE_DIAL_NUMBERS} - - ) : ( - - {SCROLL_TO_LOAD_MORE} - - )} -
- )} -
- )} + )} + {hasMoreDialNumbers && ( +
+ {loadingDialNumbers ? ( + + {LOADING_MORE_DIAL_NUMBERS} + + ) : ( + + {SCROLL_TO_LOAD_MORE} + + )} +
+ )} +
+ ))} - {showEntryPointTab && selectedCategory === 'Entry Point' && !noEntryPoints && ( -
- {renderList( - entryPoints.map((e) => ({id: e.id, name: e.name})), - (item) => { - if (onEntryPointSelect) { - onEntryPointSelect(item.id, item.name); + {isEntryPointTabVisible && + selectedCategory === CATEGORY_ENTRY_POINT && + (noEntryPoints ? ( + + ) : ( +
+ {renderList( + entryPoints.map((e) => ({id: e.id, name: e.name})), + (item) => { + if (onEntryPointSelect) { + onEntryPointSelect(item.id, item.name); + } } - } - )} - {hasMoreEntryPoints && ( -
- {loadingEntryPoints ? ( - - {LOADING_MORE_ENTRY_POINTS} - - ) : ( - - {SCROLL_TO_LOAD_MORE} - - )} -
- )} -
- )} + )} + {hasMoreEntryPoints && ( +
+ {loadingEntryPoints ? ( + + {LOADING_MORE_ENTRY_POINTS} + + ) : ( + + {SCROLL_TO_LOAD_MORE} + + )} +
+ )} +
+ ))}
); }; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss index f560902d8..4c362f71f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss @@ -217,8 +217,21 @@ pointer-events: auto; } + .consult-search-row { + display: flex; + align-items: center; + width: 100%; + gap: 0.5rem; + } + .consult-search-input { width: 100%; + flex: 1 1 auto; + min-width: 0; + } + + .consult-quick-action-button { + flex: 0 0 2rem; /* 32px */ } .consult-category-buttons { diff --git a/packages/contact-center/cc-components/src/components/task/constants.ts b/packages/contact-center/cc-components/src/components/task/constants.ts index ff01108b1..633dbff01 100644 --- a/packages/contact-center/cc-components/src/components/task/constants.ts +++ b/packages/contact-center/cc-components/src/components/task/constants.ts @@ -26,6 +26,7 @@ export const LOADING_MORE_QUEUES = 'Loading more queues...'; export const LOADING_MORE_DIAL_NUMBERS = 'Loading more dial numbers...'; export const LOADING_MORE_ENTRY_POINTS = 'Loading more entry points...'; export const NO_DATA_AVAILABLE_CONSULT_TRANSFER = 'No data available for consult transfer.'; +export const VIA_SEARCH_SUFFIX = ' via search'; // Pagination export const DEFAULT_PAGE_SIZE = 25; // CallControlCAD constants diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap index 6b4be453d..bbcdb219c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap @@ -11,7 +11,9 @@ exports[`ConsultTransferPopoverComponent Snapshots Interactions should render co > Select an Agent -
+
-
    Select an Agent -
    +
    -
      Select an Agent -
      +
      -
        - Select an Agent + Consult -
        +
        Select an Agent -
        +
        -
          Select an Agent -
          +
          - -
            Select an Agent -
            +
            -
            Choose Transfer Target -
            +
            -
              Select an Agent -
              +
              -
              Select an Agent -
              +
              - + No data available for consult transfer. +
              `; @@ -2182,7 +2034,9 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem > Select an Agent -
              +
              -
                Select an Agent -
                +
                -
                  Select an Agent -
                  +
                  -
                    Select an Agent -
                    +
                    -
                      New Heading -
                      +
                      -
                        Select an Agent -
                        +
                        -
                          { it('should render the component with heading and category buttons', async () => { let screen; await act(async () => { - screen = render(); + screen = render(); }); - expect(screen.getByText('Select an Agent')).toBeInTheDocument(); + expect(screen.getByText('Consult')).toBeInTheDocument(); const btns = Array.from(screen.container.querySelectorAll('button')).map( (b) => (b as HTMLButtonElement).textContent ); @@ -172,8 +172,7 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const el = btn as HTMLButtonElement; return el && el.textContent === 'Queues'; }) as HTMLButtonElement | undefined; - expect(queuesButton).toBeDefined(); - expect(queuesButton?.disabled).toBe(true); + expect(queuesButton).toBeUndefined(); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index 704c816f7..e55c65850 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -3,8 +3,6 @@ import {render, fireEvent, waitFor, act} from '@testing-library/react'; import '@testing-library/jest-dom'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; import {ContactServiceQueue} from '@webex/cc-store'; -// hooks import no longer needed in this test -import * as utils from '../../../../../src/components/task/CallControl/CallControlCustom/call-control-custom.utils'; import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; const loggerMock = { @@ -68,7 +66,7 @@ describe('ConsultTransferPopoverComponent', () => { }); it('renders heading and tabs when showTabs is true', async () => { - const screen = await render(); + const screen = await render(); // Verify main container expect(screen.container.querySelector('.agent-popover-content')).toBeInTheDocument(); @@ -76,7 +74,7 @@ describe('ConsultTransferPopoverComponent', () => { // Verify heading - it's wrapped in mdc-text component const heading = screen.container.querySelector('.agent-popover-title'); expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent('Select an Agent'); + expect(heading).toHaveTextContent('Consult'); expect(heading?.tagName.toLowerCase()).toBe('mdc-text'); expect(heading).toHaveAttribute('tagname', 'h3'); expect(heading).toHaveAttribute('type', 'body-large-bold'); @@ -201,7 +199,7 @@ describe('ConsultTransferPopoverComponent', () => { buddyAgents: [], }; - const screen = await render(); + const screen = await render(); const buttons = Array.from(screen.container.querySelectorAll('button')).map( (b) => (b as HTMLButtonElement).textContent ); @@ -222,35 +220,27 @@ describe('ConsultTransferPopoverComponent', () => { expect(screen.container.querySelectorAll('.call-control-list-item').length).toBe(0); }); - it('disables queue selection when allowConsultToQueue is false', async () => { + it('hides queue tab when allowConsultToQueue is false', async () => { const propsWithoutQueue = { ...baseProps, allowConsultToQueue: false, }; const screen = await render(); - const queuesButton = screen.getByRole('button', {name: 'Queues'}) as HTMLButtonElement; - expect(queuesButton.disabled).toBe(true); + const maybeQueuesButton = screen.queryByRole('button', {name: 'Queues'}) as HTMLButtonElement | null; + expect(maybeQueuesButton).toBeNull(); }); it('covers edge case for empty items in renderList (line 50)', async () => { - // Mock isAgentsEmpty to return false even with empty array to force renderList call - const mockIsAgentsEmpty = jest.spyOn(utils, 'isAgentsEmpty').mockReturnValue(false); - - try { - const propsWithEmptyAgents = { - ...baseProps, - buddyAgents: [], // Empty array but mocked to return false for isEmpty - }; - - const screen = await render(); - - // Should render the "No agents found" text via empty list state - expect(screen.getByText('No agents found')).toBeInTheDocument(); - } finally { - // Restore original functions - mockIsAgentsEmpty.mockRestore(); - } + const propsWithEmptyAgents = { + ...baseProps, + buddyAgents: [], + }; + + const screen = await render(); + + // With zero agents, the per-tab empty state should render + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); }); describe('Search behavior', () => { diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index a0b07714c..af58008de 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -48,6 +48,7 @@ class Store implements IStore { consultOfferReceived: boolean = false; featureFlags: {[key: string]: boolean} = {}; isEndConsultEnabled: boolean = false; + isAddressBookEnabled: boolean = false; allowConsultToQueue: boolean = false; agentProfile: AgentLoginProfile = {}; isMuted: boolean = false; @@ -109,6 +110,8 @@ class Store implements IStore { this.lastStateChangeTimestamp = response.lastStateChangeTimestamp; this.lastIdleCodeChangeTimestamp = response.lastIdleCodeChangeTimestamp; this.isEndConsultEnabled = response.isEndConsultEnabled; + // TODO: Remove this once SDK performs the validation + this.isAddressBookEnabled = Boolean(response.addressBookId); this.allowConsultToQueue = response.allowConsultToQueue; this.agentProfile.agentName = response.agentName; }) diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 6a4d7409a..56ab781b7 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -127,6 +127,7 @@ interface IStore { allowConsultToQueue: boolean; agentProfile: AgentLoginProfile; isMuted: boolean; + isAddressBookEnabled: boolean; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; } diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 41418f67d..bc661d9fd 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -167,6 +167,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.isMuted; } + get isAddressBookEnabled() { + return this.store.isAddressBookEnabled; + } + setIsMuted = (value: boolean): void => { runInAction(() => { this.store.isMuted = value; @@ -718,6 +722,9 @@ class StoreWrapper implements IStoreWrapper { getAddressBookEntries = async (params?: AddressBookEntrySearchParams): Promise => { try { + if (!this.store.isAddressBookEnabled) { + return {data: [], meta: {page: 0, totalPages: 0}}; + } const response: AddressBookEntriesResponse = await this.store.cc.addressBook.getEntries(params ?? {}); return response; } catch (error) { diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index a5c328a44..8f8e913ce 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -871,6 +871,7 @@ describe('storeEventsWrapper', () => { }); it('should fetch address book entries successfully', async () => { + storeWrapper['store'].isAddressBookEnabled = true; jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockResolvedValue(mockAddressBookEntriesResponse); const result = await storeWrapper.getAddressBookEntries({page: 0, pageSize: 25}); @@ -879,9 +880,18 @@ describe('storeEventsWrapper', () => { }); it('should handle error while fetching address book entries', async () => { + storeWrapper['store'].isAddressBookEnabled = true; jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockRejectedValue(new Error('ab error')); await expect(storeWrapper.getAddressBookEntries({page: 0, pageSize: 25})).rejects.toThrow('ab error'); }); + + it('should return empty list and not call API when address book is disabled', async () => { + storeWrapper['store'].isAddressBookEnabled = false; + const getEntriesSpy = jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries'); + const result = await storeWrapper.getAddressBookEntries({page: 0, pageSize: 25}); + expect(result).toEqual({data: [], meta: {page: 0, totalPages: 0}}); + expect(getEntriesSpy).not.toHaveBeenCalled(); + }); }); describe('storeEventsWrapper events reactions', () => {