From 4b9288473980a45937d0aa592541ee71eb85034f Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Sun, 9 Aug 2026 23:01:01 +0530 Subject: [PATCH 01/10] fix(cc-task): align consult/transfer tabs and lists with agent desktop (CAI-8354) --- .../consult-transfer-popover-hooks.ts | 2 +- .../consult-transfer-popover.tsx | 58 ++- .../consult-transfer-tab.utils.ts | 51 ++ .../task/CallControl/call-control.tsx | 9 + .../src/components/task/task.types.ts | 30 ++ ...consult-transfer-popover.snapshot.tsx.snap | 438 ++++++++++++++---- .../consult-transfer-popover.snapshot.tsx | 4 +- .../consult-transfer-popover.tsx | 30 +- .../consult-transfer-tab.utils.ts | 81 ++++ .../store/ai-docs/store-spec.md | 6 +- packages/contact-center/store/src/store.ts | 11 + .../contact-center/store/src/store.types.ts | 3 + .../store/src/storeEventsWrapper.ts | 26 +- .../store/tests/storeEventsWrapper.ts | 38 +- .../contact-center/task/ai-docs/task-spec.md | 2 +- .../task/src/CallControl/index.tsx | 30 ++ .../task/src/CallControlCAD/index.tsx | 30 ++ 17 files changed, 730 insertions(+), 119 deletions(-) create mode 100644 packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts create mode 100644 packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index c827bc1de..70fc7a691 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -30,7 +30,7 @@ import {DEFAULT_PAGE_SIZE} from '../../constants'; * @param logger - Optional logger instance for diagnostics. * @returns An object containing the transformed data (U[]), pagination state and helpers. */ -export const usePaginatedData = ( +export const usePaginatedData = ( fetchFunction: FetchPaginatedList | undefined, transformFunction: TransformPaginatedData, categoryName: string, 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 32e1ab744..fbd622da5 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 @@ -11,6 +11,12 @@ import { getAgentsForDisplay, } from './call-control-custom.utils'; import {useConsultTransferPopover} from './consult-transfer-popover-hooks'; +import { + isAgentsTabVisible, + isEntryPointTabVisible, + isQueuesTabVisible, + ConsultTransferAction, +} from './consult-transfer-tab.utils'; import { SEARCH_PLACEHOLDER, @@ -34,12 +40,26 @@ const ConsultTransferPopoverComponent: React.FC { const {showDialNumberTab = true, showEntryPointTab = true} = consultTransferOptions || {}; - const isEntryPointTabVisible = showEntryPointTab && heading === 'Consult'; + const action: ConsultTransferAction = heading === 'Transfer' ? 'Transfer' : 'Consult'; + const isQueueTabVisible = isQueuesTabVisible( + action, + allowConsultToQueue, + accessQueue, + interactionContext ?? {}, + isTelephony + ); + const isAgentsTabVisibleFlag = isAgentsTabVisible(accessBuddyTeam); + const isEntryPointTabVisibleFlag = isEntryPointTabVisible(showEntryPointTab, accessEntryPoint, isTelephony); const { selectedCategory, searchQuery, @@ -61,7 +81,7 @@ const ConsultTransferPopoverComponent: React.FC ); - const noQueues = !allowConsultToQueue || queuesData.length === 0; + const noQueues = !isQueueTabVisible || queuesData.length === 0; const noDialNumbers = !showDialNumberTab || dialNumbers.length === 0; - const noEntryPoints = !isEntryPointTabVisible || entryPoints.length === 0; + const noEntryPoints = !isEntryPointTabVisibleFlag || entryPoints.length === 0; const consultTransferManualAction = shouldAddConsultTransferAction( selectedCategory, - isEntryPointTabVisible, + isEntryPointTabVisibleFlag, allowParticipantsToInteract, searchQuery, entryPoints, @@ -180,17 +200,19 @@ const ConsultTransferPopoverComponent: React.FC
- - {allowConsultToQueue && ( + {isAgentsTabVisibleFlag && ( + + )} + {isQueueTabVisible && ( +
+
+
+
`; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with allowConsultToQueue false 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with allowConsultToQueue false for Consult 1`] = `
@@ -1411,7 +1483,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="h3" type="body-large-bold" > - Select an Agent + Consult
+
+
+
+
+
+
+
+
+
+
+
{ expect(container).toMatchSnapshot(); }); - it('should render with allowConsultToQueue false', async () => { - const noQueueConsultProps = {...defaultProps, allowConsultToQueue: false}; + it('should render with allowConsultToQueue false for Consult', async () => { + const noQueueConsultProps = {...defaultProps, heading: 'Consult', allowConsultToQueue: false}; let screen; await act(async () => { screen = render(); 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 375b76089..1c4453450 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 @@ -223,9 +223,10 @@ describe('ConsultTransferPopoverComponent', () => { expect(screen.container.querySelectorAll('.call-control-list-item').length).toBe(0); }); - it('hides queue tab when allowConsultToQueue is false', async () => { + it('hides queue tab when allowConsultToQueue is false for Consult', async () => { const propsWithoutQueue = { ...baseProps, + heading: 'Consult', allowConsultToQueue: false, }; @@ -234,6 +235,33 @@ describe('ConsultTransferPopoverComponent', () => { expect(maybeQueuesButton).toBeNull(); }); + it('shows queue tab for Transfer inbound when consultToQueue is off and accessQueue is SPECIFIC (AVERA)', async () => { + const averaProps = { + ...baseProps, + heading: 'Transfer', + allowConsultToQueue: false, + accessQueue: 'SPECIFIC', + interactionContext: {contactDirectionType: 'INBOUND', mediaType: 'telephony'}, + isTelephony: true, + }; + + const screen = await render(); + expect(screen.getByRole('button', {name: 'Queues'})).toBeInTheDocument(); + }); + + it('shows entry point tab on Transfer when accessEntryPoint allows it', async () => { + const transferProps = { + ...baseProps, + heading: 'Transfer', + accessEntryPoint: 'SPECIFIC', + isTelephony: true, + consultTransferOptions: {showEntryPointTab: true}, + }; + + const screen = await render(); + expect(screen.getByRole('button', {name: 'Entry Point'})).toBeInTheDocument(); + }); + it('covers edge case for empty items in renderList (line 50)', async () => { const propsWithEmptyAgents = { ...baseProps, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts new file mode 100644 index 000000000..dd38f1256 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts @@ -0,0 +1,81 @@ +import { + ConsultTransferInteractionContext, + isAgentsTabVisible, + isCollaborationAccessEnabled, + isEntryPointTabVisible, + isQueueEnabled, + isQueuesTabVisible, +} from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils'; + +describe('consult-transfer-tab.utils', () => { + const inboundVoice: ConsultTransferInteractionContext = { + contactDirectionType: 'INBOUND', + mediaType: 'telephony', + }; + + const outboundVoiceTransferDisabled: ConsultTransferInteractionContext = { + contactDirectionType: 'OUTBOUND', + outdialTransferToQueueEnabled: false, + mediaType: 'telephony', + }; + + const outboundVoiceTransferEnabled: ConsultTransferInteractionContext = { + contactDirectionType: 'OUTBOUND', + outdialTransferToQueueEnabled: true, + mediaType: 'telephony', + }; + + describe('isCollaborationAccessEnabled', () => { + it('returns false when access is NONE (case-insensitive)', () => { + expect(isCollaborationAccessEnabled('NONE')).toBe(false); + expect(isCollaborationAccessEnabled('none')).toBe(false); + }); + + it('returns true for ALL, SPECIFIC, or undefined', () => { + expect(isCollaborationAccessEnabled('ALL')).toBe(true); + expect(isCollaborationAccessEnabled('SPECIFIC')).toBe(true); + expect(isCollaborationAccessEnabled(undefined)).toBe(true); + }); + }); + + describe('isQueueEnabled', () => { + it('requires allowConsultToQueue for Consult on voice', () => { + expect(isQueueEnabled('Consult', false, inboundVoice, true)).toBe(false); + expect(isQueueEnabled('Consult', true, inboundVoice, true)).toBe(true); + }); + + it('shows queues for Transfer on inbound voice even when consultToQueue is off (AVERA case)', () => { + expect(isQueueEnabled('Transfer', false, inboundVoice, true)).toBe(true); + }); + + it('gates Transfer outbound voice on outdialTransferToQueueEnabled', () => { + expect(isQueueEnabled('Transfer', false, outboundVoiceTransferDisabled, true)).toBe(false); + expect(isQueueEnabled('Transfer', false, outboundVoiceTransferEnabled, true)).toBe(true); + }); + + it('returns true for non-voice media', () => { + expect(isQueueEnabled('Consult', false, {mediaType: 'chat'}, false)).toBe(true); + }); + }); + + describe('tab visibility helpers', () => { + it('hides agents tab when accessBuddyTeam is NONE', () => { + expect(isAgentsTabVisible('NONE')).toBe(false); + expect(isAgentsTabVisible('SPECIFIC')).toBe(true); + }); + + it('shows queue tab for Transfer inbound when accessQueue is SPECIFIC and consultToQueue is off', () => { + expect(isQueuesTabVisible('Transfer', false, 'SPECIFIC', inboundVoice, true)).toBe(true); + }); + + it('hides queue tab when accessQueue is NONE even if queue transfer is otherwise enabled', () => { + expect(isQueuesTabVisible('Transfer', false, 'NONE', inboundVoice, true)).toBe(false); + }); + + it('shows entry point tab on voice when showEntryPointTab and accessEntryPoint allow it', () => { + expect(isEntryPointTabVisible(true, 'SPECIFIC', true)).toBe(true); + expect(isEntryPointTabVisible(true, 'NONE', true)).toBe(false); + expect(isEntryPointTabVisible(true, 'SPECIFIC', false)).toBe(false); + }); + }); +}); diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index e0a3dbd84..713552968 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -281,14 +281,16 @@ classDiagram - **UC-3 Observe agent/session state in React:** Widget wraps in `observer()` and reads `store.agentId`, `store.isAgentLoggedIn`, `store.deviceType`, `store.currentState` → re-renders on mutation. Evidence: `src/storeEventsWrapper.ts:56-187`, `_archive/.../AGENTS.md` usage. - **UC-4 Handle an incoming task through to wrap-up:** SDK `task:incoming` → listeners registered + `onIncomingTask` fired → `task:assigned` sets ENGAGED/current → `task:end` + `handleTaskRemove` cleans up. Evidence: `src/storeEventsWrapper.ts:585-762`, `tests/storeEventsWrapper.ts` ("events reactions"). - **UC-5 Campaign-preview accept flow:** `task:campaignPreviewReservation` puts a preview in RESERVED; preview stays out of `currentTask` until accepted (`acceptedCampaignIds`), then transitions to ENGAGED. Evidence: `src/storeEventsWrapper.ts:243-283,537-583,772-795`, `tests/storeEventsWrapper.ts` ("campaign preview task lifecycle"). -- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult widget calls `getBuddyAgents()`/`getQueues()`; Outdial calls `getEntryPoints()`/`getAddressBookEntries()` → store proxies the SDK, transforms/filters, returns. Evidence: `src/storeEventsWrapper.ts:924-1001`, `tests/storeEventsWrapper.ts`. +- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult widget calls `getBuddyAgents()`/`getQueues()`; Outdial calls `getEntryPoints()`/`getAddressBookEntries()` → store proxies the SDK, transforms/filters, returns. `getQueues`/`getEntryPoints` merge `desktopProfileFilter: true`. Evidence: `src/storeEventsWrapper.ts:924-1001`, `tests/storeEventsWrapper.ts`. ## State Model The store is a single MobX `makeAutoObservable` instance. Observable slices (all in `src/store.ts:23-56`): - **Session / profile:** `agentId`, `agentProfile`, `isAgentLoggedIn`, `deviceType`, `dialNumber`, `teamId`, `teams`, `loginOptions`, `idleCodes`, `wrapupCodes`, `featureFlags`, `dataCenter`. - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. -- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `isDigitalChannelsInitialized`. +- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `accessQueue`, `accessEntryPoint`, `accessBuddyTeam`, `isDigitalChannelsInitialized`. +- **`getQueues` / `getEntryPoints`:** merge `desktopProfileFilter: true` into SDK search params. List sort deferred pending team decision on CMS/BFF sort param parity. +- **`getBuddyAgents`:** returns agents sorted by `agentName` ascending (client-side; full list, no pagination). - **Misc:** `currentTheme`, `cc` (`observable.ref` — not deeply observed), `isAddressBookEnabled`. Transition triggers: SDK CC/task events drive the session/agent/task slices via the wrapper's handlers (`handleStateChange`, `handleTaskAssigned`, `refreshTaskList`, `cleanUpStore`, campaign-preview handlers). Widget-initiated mutators (`setDeviceType`, `setDialNumber`, `setTeamId`, `setState`, `setCurrentTheme`, etc.) drive UI-local slices. All writes pass through `runInAction`. diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 52654bbd7..6434ec3e5 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -50,6 +50,9 @@ class Store implements IStore { isEndConsultEnabled: boolean = false; isAddressBookEnabled: boolean = false; allowConsultToQueue: boolean = false; + accessQueue?: string; + accessEntryPoint?: string; + accessBuddyTeam?: string; agentProfile: AgentLoginProfile = {}; isMuted: boolean = false; isDigitalChannelsInitialized: boolean = false; @@ -118,6 +121,14 @@ class Store implements IStore { // TODO: Remove this once SDK performs the validation this.isAddressBookEnabled = Boolean(response.addressBookId); this.allowConsultToQueue = response.allowConsultToQueue; + const collaborationProfile = response as Profile & { + accessQueue?: string; + accessEntryPoint?: string; + accessBuddyTeam?: string; + }; + this.accessQueue = collaborationProfile.accessQueue; + this.accessEntryPoint = collaborationProfile.accessEntryPoint; + this.accessBuddyTeam = collaborationProfile.accessBuddyTeam; this.agentProfile.agentName = response.agentName; this.agentProfile.isTimeoutDesktopInactivityEnabled = response.isTimeoutDesktopInactivityEnabled; this.agentProfile.timeoutDesktopInactivityMins = response.timeoutDesktopInactivityMins; diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 61dac9e40..a45aa836b 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -196,6 +196,9 @@ interface IStore { callControlAudio: MediaStream | null; isEndConsultEnabled: boolean; allowConsultToQueue: boolean; + accessQueue?: string; + accessEntryPoint?: string; + accessBuddyTeam?: string; agentProfile: AgentLoginProfile; isMuted: boolean; isAddressBookEnabled: boolean; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index efa3cf9ce..3cffead5f 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -188,6 +188,18 @@ class StoreWrapper implements IStoreWrapper { return this.store.allowConsultToQueue; } + get accessQueue() { + return this.store.accessQueue; + } + + get accessEntryPoint() { + return this.store.accessEntryPoint; + } + + get accessBuddyTeam() { + return this.store.accessBuddyTeam; + } + get agentProfile() { return this.store.agentProfile; } @@ -1095,7 +1107,10 @@ class StoreWrapper implements IStoreWrapper { }> => { try { const upperMediaType = mediaType.toUpperCase(); - const response = await this.store.cc.getQueues(params); + const response = await this.store.cc.getQueues({ + ...(params ?? {}), + desktopProfileFilter: true, + } as ContactServiceQueueSearchParams); const data = Array.isArray(response) ? response : response.data; const filtered = data.filter((queue) => queue.channelType === upperMediaType); const page = Array.isArray(response) ? 0 : (response.meta?.page ?? 0); @@ -1113,8 +1128,10 @@ class StoreWrapper implements IStoreWrapper { getEntryPoints = async (params?: EntryPointSearchParams): Promise => { try { - const response: EntryPointListResponse = await this.store.cc.getEntryPoints(params); - return response; + return await this.store.cc.getEntryPoints({ + ...(params ?? {}), + desktopProfileFilter: true, + } as EntryPointSearchParams); } catch (error) { this.store.logger.error('Error fetching entry points:', error); throw error; @@ -1126,8 +1143,7 @@ class StoreWrapper implements IStoreWrapper { if (!this.store.isAddressBookEnabled) { return {data: [], meta: {page: 0, totalPages: 0}}; } - const response: AddressBookEntriesResponse = await this.store.cc.addressBook.getEntries(params ?? {}); - return response; + return await this.store.cc.addressBook.getEntries(params ?? {}); } catch (error) { this.store.logger.error('Error fetching address book entries:', error); throw error; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 62d624aab..919ab78d4 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -998,7 +998,11 @@ describe('storeEventsWrapper', () => { }); it('should return buddy agents list', async () => { - const buddyAgents = [{name: 'agent1'}, {name: 'agent2'}]; + const buddyAgents = [ + {agentName: 'Zeta Agent', agentId: '3'}, + {agentName: 'Alpha Agent', agentId: '1'}, + {agentName: 'Beta Agent', agentId: '2'}, + ]; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: buddyAgents}}); const result = await storeWrapper.getBuddyAgents('telephony'); expect(result).toEqual(buddyAgents); @@ -1023,7 +1027,22 @@ describe('storeEventsWrapper', () => { {id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}, {id: 'queue2', name: 'Queue 2', channelType: 'TELEPHONY'}, ]); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalled(); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + desktopProfileFilter: true, + }); + }); + + it('should pass desktopProfileFilter when getQueues is called with params', async () => { + const queueList = [{id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}]; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(queueList); + + await storeWrapper.getQueues('telephony', {page: 1, pageSize: 25}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + page: 1, + pageSize: 25, + desktopProfileFilter: true, + }); }); it('should handle error in getQueues and throw error', async () => { @@ -1044,7 +1063,9 @@ describe('storeEventsWrapper', () => { const result = await storeWrapper.getQueues('telephony'); expect(result.data).toEqual([{...mockQueueDetails[0], channelType: 'TELEPHONY'}]); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalled(); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + desktopProfileFilter: true, + }); }); it('should handle consultQueueCancelled event', () => { @@ -1062,7 +1083,11 @@ describe('storeEventsWrapper', () => { storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); - expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({page: 0, pageSize: 25}); + expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ + page: 0, + pageSize: 25, + desktopProfileFilter: true, + }); expect(result).toEqual(mockEntryPointsResponse); }); @@ -1076,7 +1101,10 @@ describe('storeEventsWrapper', () => { jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockResolvedValue(mockAddressBookEntriesResponse); const result = await storeWrapper.getAddressBookEntries({page: 0, pageSize: 25}); - expect(storeWrapper['store'].cc.addressBook.getEntries).toHaveBeenCalledWith({page: 0, pageSize: 25}); + expect(storeWrapper['store'].cc.addressBook.getEntries).toHaveBeenCalledWith({ + page: 0, + pageSize: 25, + }); expect(result).toEqual(mockAddressBookEntriesResponse); }); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..f70160edb 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -392,7 +392,7 @@ The six widget shells are siblings that each bind to exactly one hook and one pr - **UC-3 Hold / resume active call (CallControl):** Agent clicks Hold → `toggleHold(true)` → `currentTask.hold()` → `TASK_HOLD` → hold timer starts via `useHoldTimer`, `onHoldResume({isHeld:true})`. Evidence: `src/helper.ts`, `src/Utils/useHoldTimer.ts`, `tests/helper.ts` (hold/resume cases). UI flow: Hold button toggles to Resume; "Hold" elapsed timer shown. - **UC-4 Toggle recording (CallControl):** Agent clicks record toggle → `toggleRecording()` → `pauseRecording()`/`resumeRecording()` → `TASK_RECORDING_PAUSED/RESUMED` → `isRecording` flips, `onRecordingToggle`. Evidence: `src/helper.ts`, `tests/helper.ts` (recording cases). - **UC-5 Mute / unmute (CallControl):** Agent clicks mute → `toggleMute()` (gated by `controlVisibility.muteUnmute`) → `currentTask.toggleMute()` → `store.setIsMuted`, `onToggleMute`. Evidence: `src/helper.ts`, `tests/helper.ts` ("should successfully toggle mute…", "rapid toggleMute"). UI flow: shows error-safe revert on failure. -- **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Evidence: `src/helper.ts`, `tests/helper.ts` (consult/transfer/conference cases), `tests/utils/task-util.ts` (EP-DN end-button cases). UI flow: consult controls (switch/merge/end) appear; consult timer label `Consult Requested`/`Consulting`/`Consult on Hold`. +- **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Consult/Transfer popover tab visibility follows Agent Desktop: `accessQueue`/`accessEntryPoint`/`accessBuddyTeam` from store plus interaction direction (`contactDirection`, `outdialTransferToQueueEnabled`) for queue tab gating. Evidence: `src/helper.ts`, `packages/contact-center/cc-components/.../consult-transfer-tab.utils.ts`, `task/src/CallControl/index.tsx`. - **UC-7 Blind transfer (CallControl):** Agent picks destination → `transferCall(to,type)` → `currentTask.transfer(...)`; failure re-thrown to the modal. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call transferCall successfully"). - **UC-8 Wrap up a call (CallControl):** Call ends → wrap-up codes shown → Agent selects code → `wrapupCall(reason, auxCodeId)` → `currentTask.wrapup` → next task promoted to `currentTask`, agent → ENGAGED. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call wrapupCall"). Auto-wrap-up: countdown shown with Cancel. UI flow: wrap-up dropdown + optional auto-wrap-up countdown. - **UC-9 Place an outbound call (OutdialCall):** Agent enters number, selects ANI → dial → `startOutdial(destination, origin)`; empty number alerts and aborts; disabled while a telephony task is active. Evidence: `src/helper.ts` (`useOutdialCall`), `tests/OutdialCall/index.tsx`. UI flow: dialpad with validation, ANI dropdown, address book toggled by `isAddressBookEnabled`. diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 443072076..c9ef6870a 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -7,6 +7,29 @@ import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlComponent} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; +import {ITask} from '@webex/contact-center'; + +type ConsultTransferInteractionContext = { + contactDirectionType?: string; + outdialTransferToQueueEnabled?: boolean; + mediaType?: string; +}; + +const buildConsultTransferInteractionContext = (currentTask?: ITask): ConsultTransferInteractionContext => { + const interaction = currentTask?.data?.interaction as + | { + contactDirection?: {type?: string}; + outdialTransferToQueueEnabled?: boolean; + mediaType?: string; + } + | undefined; + + return { + contactDirectionType: interaction?.contactDirection?.type, + outdialTransferToQueueEnabled: interaction?.outdialTransferToQueueEnabled, + mediaType: interaction?.mediaType, + }; +}; const CallControlInternal: React.FunctionComponent = observer( ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute, consultTransferOptions, conferenceEnabled}) => { @@ -17,6 +40,9 @@ const CallControlInternal: React.FunctionComponent = observer( consultStartTimeStamp, callControlAudio, allowConsultToQueue, + accessQueue, + accessEntryPoint, + accessBuddyTeam, isMuted, agentId, acceptedCampaignIds, @@ -48,6 +74,10 @@ const CallControlInternal: React.FunctionComponent = observer( consultStartTimeStamp, callControlAudio, allowConsultToQueue, + accessQueue, + accessEntryPoint, + accessBuddyTeam, + interactionContext: buildConsultTransferInteractionContext(currentTask), logger, consultTransferOptions, }; diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index 1426d19da..ab27c18a4 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -7,6 +7,29 @@ import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlCADComponent} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; +import {ITask} from '@webex/contact-center'; + +type ConsultTransferInteractionContext = { + contactDirectionType?: string; + outdialTransferToQueueEnabled?: boolean; + mediaType?: string; +}; + +const buildConsultTransferInteractionContext = (currentTask?: ITask): ConsultTransferInteractionContext => { + const interaction = currentTask?.data?.interaction as + | { + contactDirection?: {type?: string}; + outdialTransferToQueueEnabled?: boolean; + mediaType?: string; + } + | undefined; + + return { + contactDirectionType: interaction?.contactDirection?.type, + outdialTransferToQueueEnabled: interaction?.outdialTransferToQueueEnabled, + mediaType: interaction?.mediaType, + }; +}; const CallControlCADInternal: React.FunctionComponent = observer( ({ @@ -27,6 +50,9 @@ const CallControlCADInternal: React.FunctionComponent = observ consultStartTimeStamp, callControlAudio, allowConsultToQueue, + accessQueue, + accessEntryPoint, + accessBuddyTeam, isMuted, agentId, acceptedCampaignIds, @@ -57,6 +83,10 @@ const CallControlCADInternal: React.FunctionComponent = observ callControlClassName, callControlConsultClassName, allowConsultToQueue, + accessQueue, + accessEntryPoint, + accessBuddyTeam, + interactionContext: buildConsultTransferInteractionContext(currentTask), logger, consultTransferOptions, }; From 69fdb37cefc249164a918e298a86f24f859251c7 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Wed, 19 Aug 2026 15:15:05 +0530 Subject: [PATCH 02/10] fix(cc-task): delegate consult transfer policy to sdk --- .gitignore | 1 + .../spec/feature-spec.md | 292 ++++++++++++++++++ .../consult-transfer-popover.tsx | 2 +- .../task/CallControl/call-control.tsx | 2 +- .../src/components/task/task.types.ts | 4 +- .../consult-transfer-popover.tsx | 1 + .../task/CallControl/call-control.tsx | 2 + .../contact-center/store/src/store.types.ts | 17 +- .../store/src/storeEventsWrapper.ts | 51 +-- .../store/tests/storeEventsWrapper.ts | 83 +++-- packages/contact-center/task/src/helper.ts | 52 ++-- .../task/tests/call-control-recording.tsx | 7 +- packages/contact-center/task/tests/helper.ts | 17 +- .../test-fixtures/src/fixtures.ts | 3 + 14 files changed, 426 insertions(+), 108 deletions(-) create mode 100644 ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md diff --git a/.gitignore b/.gitignore index 0afe52a0e..563a57023 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ yarn-debug.log* yarn-error.log* .node_repl_history reports/ +.generated/ #IDEs .project diff --git a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md new file mode 100644 index 000000000..040afb0cc --- /dev/null +++ b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md @@ -0,0 +1,292 @@ +--- +type: Feature Spec +title: Agent Desktop consult and transfer list policy +description: Keep consult and transfer destination eligibility and ordering aligned with Agent Desktop while leaving widgets as a thin SDK consumer. +tags: [feature, specification, contact-center, consult-transfer] +--- + +# Agent Desktop consult and transfer list policy + +This document owns the feature's what and why. The paired SDK delta owns the reusable destination-list policy; this repository owns only action context, current-task context, UI loading, and error presentation. + +Related context: [repository architecture](../../../ARCHITECTURE.md) · [specification index](../../../SPEC_INDEX.md) · [repository instructions](../../../../AGENTS.md) + +## Metadata + +| Field | Value | +| --- | --- | +| Feature key | `CAI-8354` | +| Owner | Webex Contact Center widgets maintainers | +| Status | Approved and implemented; generator-side conformance PASS; independent validation pending | +| Work type | Defect | +| Change class | Contract / UI | +| Source/intake | Developer-approved Agent Desktop parity review and current code/tests | +| Last verified | 2026-08-19 in a working tree based on `4b928847` | + +## Applicability + +| Condition ID | Status | Evidence or reason | Owned section | +| --- | --- | --- | --- | +| `feature.feature_nontrivial` | Applicable | `packages/contact-center/store/src/storeEventsWrapper.ts` | Feasibility and risks | +| `feature.feature_interactions` | Applicable | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | Interaction and scenario matrix | +| `feature.touches_data_shapes` | Applicable | `packages/contact-center/store/src/store.types.ts` | Requested data and fields | +| `feature.backward_compat` | Applicable | `packages/contact-center/store/package.json` | Migration expectations | +| `feature.perf_critical` | N/A | This change does not add client-side processing or a new request fan-out. | Scale and performance | +| `feature.security_compliance` | N/A | The store forwards authenticated SDK calls and adds no credential or authorization ownership. | Security and compliance | +| `feature.needs_rollout` | N/A | No widget feature flag or staged runtime path is introduced. | Rollout and feature controls | +| `feature.serviceability` | Applicable | `packages/contact-center/task/src/helper.ts` | Serviceability | +| `feature.doc_obligations` | Applicable | `packages/contact-center/store/ai-docs/store-spec.md` | Documentation obligations | +| `feature.changes_ui` | Applicable | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | UI flow and design | +| `feature.changes_api` | Applicable | `packages/contact-center/store/src/store.types.ts` | API contract delta | +| `feature.changes_events` | N/A | No event name, payload, producer, consumer, or delivery order changes. | Event contract delta | +| `feature.changes_public_api` | Applicable | `packages/contact-center/cc-components/src/components/task/task.types.ts` | Public API and semver impact | +| `feature.cross_package` | Applicable | `packages/contact-center/store/package.json` | Cross-package impact | + +## Problem and goal + +The widgets previously embedded destination policy in the store: buddy agents were always restricted to Available, queues were fetched through the generic SDK API and filtered in memory by channel, and list response metadata was reconstructed. That made widget behavior diverge from Agent Desktop and made list order dependent on widget-side transformation. + +The goal is for widgets to supply only the user's action, pagination/search input, and the current task media needed by the queue policy. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not choose ordering, entry-point media defaults, reusable eligibility, or pagination semantics. + +## Stakeholders and open questions + +| Stakeholder | Need or decision | Status | +| --- | --- | --- | +| Contact Center agents | Consult and transfer destination lists match Agent Desktop eligibility and order. | Decided | +| Widget maintainers | Destination business policy remains outside React and MobX UI code. | Decided | +| SDK maintainers | SDK services own list ordering defaults and specialized methods own consult/transfer eligibility. | Decided in the paired SDK delta | +| Host applications | Existing widget UI behavior remains compatible apart from the corrected destination results. | Decided | + +There are no open product decisions for this delta. + +## Scope + +### In scope + +- Pass `Consult` or `Transfer` from the call-control menu and reload action to the SDK through the task hook and store. +- Forward only pagination, page size, and search text for entry-point and dial-number lists; forward current-task media only for queues, where it selects the task channel. +- Load dial numbers through the generic SDK AddressBook service and rely on its default backend ordering. +- Preserve the SDK's `data` order and pagination metadata without local sorting, channel filtering, or metadata reconstruction. +- Keep loading, empty, and error behavior in the existing widget layers. +- Use the locally linked SDK worktree while verifying the coordinated change. + +### Out of scope + +- Reimplementing queue, entry-point, or buddy-agent eligibility in widgets. +- Sorting any destination array in React, the task hook, or the store. +- Supplying `sortBy` or `sortOrder` from the consult/transfer widget path. +- Changing backend ordering or adding a widget feature flag. +- Committing, publishing, or pushing either repository. + +## Prior work and evidence + +| Source | What it establishes | Decision or disposition | +| --- | --- | --- | +| `packages/contact-center/store/src/storeEventsWrapper.ts` | The old store filtered queue data and rebuilt pagination metadata; the new store delegates to specialized SDK methods. | Used | +| `packages/contact-center/task/src/helper.ts` | The task hook owns UI loading/error state and forwards list input. | Used | +| `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | The selected menu identifies Consult versus Transfer. | Used | +| `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Reload has the same action context as initial list loading. | Used | +| `packages/contact-center/store/tests/storeEventsWrapper.ts` | Store delegation, action forwarding, metadata preservation, and error propagation are asserted. | Used | +| `packages/contact-center/task/tests/helper.ts` | Hook forwarding and safe empty-page fallback are asserted. | Used | +| `packages/contact-center/cc-components/tests/components/task/CallControl` | Initial load and reload preserve action context. | Used | + +## Requirements + +| ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence | +| --- | --- | --- | --- | --- | --- | --- | +| `WIDGET-LIST-R-001` | The store must use the SDK's specialized consult/transfer queue and entry-point methods, use the generic SDK AddressBook service for dial numbers, and must not apply local eligibility filters, sorting, or pagination reconstruction. | SDK-owned defaults prevent drift while avoiding a redundant consult-specific dial-number API. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK delta at runtime. | Present | +| `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. | Agent eligibility differs by action, so losing the action would silently return the wrong population. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts` | None. | Present | +| `WIDGET-LIST-R-003` | Queue requests must forward page, page size, search, and current-task media; entry-point and dial-number requests must forward only pagination/search. | Queue media comes from the active task, while telephony entry-point behavior and both list-order defaults are SDK decisions that widgets must not duplicate. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | When queue media is absent, the SDK default applies. | Present | +| `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | +| `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | +| `WIDGET-LIST-R-006` | Store and component types must expose the action-aware loader and the specialized SDK request/response shapes without `any`. | Compile-time alignment prevents the widgets from recreating SDK policy through loosely typed escape hatches. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the new exports. | Present | + +## Defect context (when applicable) + +- Observed versus expected behavior: widgets could show a different destination population/order because they always requested Available agents, used generic queue/entry-point APIs, locally filtered queues, and rebuilt metadata; expected behavior is the Agent Desktop request policy with no widget-side sorting or filtering. +- Reproduction and environment: open Consult and Transfer destination popovers for the same active task and compare Agents, Queues, and Entry Points with Agent Desktop. +- Regression range or last known good state: unknown; the previous widget implementation predates this coordinated SDK policy. +- Severity, frequency, and workaround: user-visible whenever backend order or action eligibility differs; no reliable host-side workaround. +- Diagnostic evidence: `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. + +## MODIFIED Requirements + +### MOD-001 — Store list delegation (`STORE-R-015`) + +- **WHAT**: Replace widget-owned queue filtering/metadata reconstruction and generic entry-point calls with thin delegation to the SDK's consult/transfer queue/entry-point methods. Keep dial numbers on the generic AddressBook service. Pass current-task media only to queues and pass no ordering inputs; preserve each SDK response as returned. +- **WHY**: Eligibility, backend query flags, and ordering defaults are reusable domain policy and must not diverge across UI clients. +- **Evidence:** `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. +- **Acceptance:** No queue, entry-point, or dial-number `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. + +### MOD-002 — Task consult/transfer orchestration (`TASK-R-011` through `TASK-R-014`) + +- **WHAT**: Extend the existing consult/transfer flow so list loading carries `Consult` or `Transfer`; queue loading no longer derives and passes an independent media argument from the hook. +- **WHY**: The action affects buddy-agent eligibility, while media and request policy must be resolved once at the store/SDK boundary. +- **Evidence:** `packages/contact-center/task/src/helper.ts`, `packages/contact-center/task/tests/helper.ts`. +- **Acceptance:** Transfer loading reaches the store as `Transfer`, Consult remains the default, and paginated queue inputs contain only page, page size, and search. + +### MOD-003 — Call-control action context (`CC-COMPONENTS-R-006`) + +- **WHAT**: Initial menu opening and agent-list reload must call the loader with the active menu action. +- **WHY**: A reload must not silently revert Transfer eligibility to Consult eligibility. +- **Evidence:** `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl`. +- **Acceptance:** Component tests cover both the initial Transfer load and action-preserving reload. + +## Acceptance criteria + +- [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). +- [x] Queue and entry-point fetchers delegate to specialized SDK methods, while dial numbers use AddressBook, without local sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). +- [x] Only queue requests include current-task media when available; entry-point and dial-number requests contain no widget-selected media or sorting (`MOD-001`, `WIDGET-LIST-R-003`). +- [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). +- [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). +- [x] Store, task, and cc-components unit suites pass with the coordinated SDK worktree. + +## Scenarios and applicable change views + +| Scenario | Actor | Preconditions | Expected behavior | Failure or boundary behavior | Requirements | +| --- | --- | --- | --- | --- | --- | +| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged. | SDK failure produces an empty agent list and clears loading. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005` | +| Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; it does not fall back to Consult. | `WIDGET-LIST-R-002` | +| Search Queues | Agent | Active task with media context | Page/search input plus media reach the SDK; response and metadata are preserved. | Missing task media lets the SDK default to telephony. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Search Entry Points | Agent | Entry-point tab visible | Page/search input reaches the SDK and backend order is rendered. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | +| Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook and its SDK-default backend order is rendered. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | + +### Interaction and scenario matrix + +| Context or interacting state | Trigger | Expected result | Invalid or conflicting result | Requirements | +| --- | --- | --- | --- | --- | +| Consult + Agents | Open or reload | `loadBuddyAgents('Consult')` | Applying Transfer-only availability filtering | `WIDGET-LIST-R-002` | +| Transfer + Agents | Open or reload | `loadBuddyAgents('Transfer')` | Reloading with the default Consult action | `WIDGET-LIST-R-002` | +| Queue + current task media | Fetch/search/page | Store forwards media once to the specialized SDK call | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| No current task media | Queue fetch | SDK receives no media override and uses its default | Widget invents a backend channel policy | `WIDGET-LIST-R-003` | +| Entry point or dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | + +### UI flow and design + +The visible popover, tabs, row presentation, pagination, loading indicators, empty states, and accessibility labels do not change. The only UI contract change is that initial and reload actions carry the active Consult/Transfer intent. The rendered list order is exactly the SDK response order. + +### API contract delta + +| API or operation | Change | Consumer impact | Compatibility expectation | Canonical definition | +| --- | --- | --- | --- | --- | +| Store buddy-agent loader | Accepts optional `Consult`/`Transfer` action instead of a media argument. | Task and component layers pass user intent. | Coordinated widgets release required. | `packages/contact-center/store/src/store.types.ts` | +| Store queue loader | Accepts pagination/search only and delegates to the SDK's specialized method. | Callers no longer supply independent media policy. | Coordinated widgets/SDK release required. | `packages/contact-center/store/src/store.types.ts` | +| Store entry-point loader | Delegates pagination/search to the SDK's specialized method. | No caller-owned media, profile, filter, or sort flags. | Additive at the SDK boundary; coordinated store update. | `packages/contact-center/store/src/store.types.ts` | +| Store dial-number loader | Delegates pagination/search to the generic SDK AddressBook service. | The SDK default supplies backend name ordering. | Existing SDK surface with a corrected default. | `packages/contact-center/store/src/store.types.ts` | + +### Public API and semver impact + +| Export or entry point | Change | Affected consumers | Required version change | Deprecation or migration | +| --- | --- | --- | --- | --- | +| `@webex/cc-store` loader types | Action-aware and specialized request shapes | Internal widget packages and any direct store consumer | Semver-sensitive; coordinate under repository release policy | Direct consumers must pass action rather than media to the buddy loader. | +| `@webex/cc-components` call-control loader prop | Optional action parameter | Call-control consumers | Additive callback argument for compatible functions; coordinate typings | Consumers may ignore the argument, but action-aware loaders should use it. | + +### Cross-package impact + +| Package | Change | Dependency direction | Release sequencing | Owner | +| --- | --- | --- | --- | --- | +| `@webex/contact-center` | Supplies specialized queue/entry-point APIs plus AddressBook and EntryPoint ordering defaults. | SDK → store | Build/link first. | SDK maintainers | +| `@webex/cc-store` | Thin delegation and typed boundary. | store → SDK | Release with a compatible SDK version. | Widgets maintainers | +| `@webex/cc-task` | Carries action and pagination/search. | task → store | Release after store types. | Widgets maintainers | +| `@webex/cc-components` | Carries menu action on open/reload. | components → task callback | Release with task package. | Widgets maintainers | + +## Contracts delta + +**Provides — MODIFIED:** The widget packages provide action-aware list loading and preserve SDK result order/metadata without owning eligibility policy. + +**Requires — MODIFIED:** The store requires an SDK that exports `ConsultTransferAction`, `ConsultTransferListSearchParams`, `ConsultTransferQueueSearchParams`, `getConsultTransferQueues`, and `getConsultTransferEntryPoints`. + +No event contract changes. + +## Success and guardrail metrics + +| Metric | Baseline | Target | Measurement source | +| --- | --- | --- | --- | +| Widget-side consult/transfer queue filters or sorts | Present | 0 | `packages/contact-center/store/src/storeEventsWrapper.ts` | +| Action loss on initial/reload agent fetch | Possible | 0 covered paths | `packages/contact-center/cc-components/tests/components/task/CallControl` | +| Touched package unit failures | Unknown before change | 0 | Store, task, and cc-components unit suites | +| Touched package build/style failures | Unknown before change | 0 | Store, task, test-fixtures, and cc-components build/style commands | + +## Requested data and fields + +| Entity or payload | Requested field or shape | Purpose | Ownership | Privacy, retention, or compatibility constraint | +| --- | --- | --- | --- | --- | +| Buddy-agent request | `action`, optional current task `mediaType` | Distinguish Consult and Transfer eligibility. | SDK contract; widgets supply runtime context. | No new retained data or credentials. | +| Queue list request | `page`, `pageSize`, `search`, optional current task `mediaType` | Paginated destination discovery. | SDK owns reusable defaults. | No widget-owned eligibility flags. | +| Entry-point list request | `page`, `pageSize`, `search` | Paginated destination discovery. | SDK owns telephony eligibility and backend name ordering. | No widget-owned media, eligibility, or sort flags. | +| Dial-number list request | `page`, `pageSize`, `search` | Paginated address-book destination discovery. | AddressBook owns backend name ordering. | No widget-owned sort flags. | +| Paginated response | SDK `data` and `meta` unchanged | Preserve backend order and pagination truth. | SDK/backend | No client reconstruction. | + +## Impacted domains + +| Repository or module | Impact | Owner | +| --- | --- | --- | +| `packages/contact-center/store` | SDK boundary and response preservation | Widgets maintainers | +| `packages/contact-center/task` | Action and list-input forwarding | Widgets maintainers | +| `packages/contact-center/cc-components` | Initial/reload action propagation | Widgets maintainers | +| `packages/contact-center/test-fixtures` | Type-compatible fixtures | Widgets maintainers | + +## Feasibility and risks + +| Risk or assumption | Evidence | Mitigation or decision owner | +| --- | --- | --- | +| Widgets are run with an SDK lacking the new methods. | `packages/contact-center/store/package.json` | Coordinate the SDK dependency/release; use the approved local worktree link for testing. | +| A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | +| Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | + +## Error Matrix + +| Failure | Store behavior | Task/UI behavior | Evidence | +| --- | --- | --- | --- | +| Buddy-agent SDK rejection | Log and rethrow. | Log, clear agents, end loading. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | +| Queue SDK rejection | Log and rethrow. | Log and return an empty paginated result. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | +| Entry-point SDK rejection | Log and rethrow. | Log and return an empty paginated result. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | +| Missing current task media | Omit media from the SDK request. | Existing UI flow continues. | `packages/contact-center/store/src/storeEventsWrapper.ts` | + +## Resilience + +- The change adds no retry or duplicate request loop; existing component reload remains the explicit retry mechanism. +- Empty fallbacks remain confined to the task/UI boundary, while the store preserves rejection semantics for other consumers. +- Response order and pagination are not cached or reconstructed by widgets. + +## Observability + +- Existing store and task error logs remain the diagnostic surface. +- Successful buddy-agent loading retains the count-only informational log; no agent identities or list contents are logged. +- No new metric, trace, alert, or PII-bearing log is introduced. + +## Operations + +- Build/link the paired SDK before widgets so the new exports resolve. +- Run the store, task, and cc-components unit suites plus touched package builds/styles before release. +- Roll back widgets and SDK together if the specialized methods are unavailable; no data migration or cleanup is required. + +## Migration expectations + +- Compatibility: the SDK additions are additive, but direct `@webex/cc-store` callers of the changed loader signatures must migrate with the widgets release. +- Data or consumer transition: release a compatible SDK before or with the store, then task and component packages. +- Coexistence period: local development uses the approved SDK worktree link; published packages must use a released compatible SDK. +- Completion and rollback outcome: all packages resolve the same types and list methods; rollback is a coordinated dependency/code rollback with no persisted state. + +## Serviceability + +| Signal or support surface | Required change | Consumer or operator | Acceptance evidence | +| --- | --- | --- | --- | +| Store error log | Preserve SDK rejection context without list contents. | Widget maintainers | `packages/contact-center/store/tests/storeEventsWrapper.ts` | +| Task error log | Preserve list kind and operation context. | Widget maintainers | `packages/contact-center/task/tests/helper.ts` | +| Buddy load info log | Record count only. | Widget maintainers | `packages/contact-center/task/src/helper.ts` | + +## Documentation obligations + +- This approved delta modifies `STORE-R-015`, the task consult/transfer requirement family, and `CC-COMPONENTS-R-006` without overwriting the draft canonical module specs. +- The paired SDK feature spec remains the canonical owner for filter, projection, ordering, profile-view, media mapping, and cache-bypass decisions. +- A future canonical-spec promotion must fold this delta into the routed module specs and reconcile the delta path rather than duplicate the requirements. + +## Decision and change log + +| Date | Decision or change | Rationale | Owner | +| --- | --- | --- | --- | +| 2026-08-19 | Approved this exact MODIFIED delta path. | Avoid overwriting draft canonical module specs while keeping spec-currency with the implementation. | Developer | +| 2026-08-19 | Assigned reusable list policy to the SDK and retained only UI/runtime context in widgets. | Prevent policy duplication and ordering drift. | Developer + Codex | +| 2026-08-19 | Explicitly prohibited widget-side sorting/filtering of SDK destination results. | Preserve the backend order selected by the SDK request. | Developer + Codex | +| 2026-08-19 | Removed widget selection of entry-point media and the consult-specific dial-number helper. | EntryPoint and AddressBook defaults must work for widgets out of the box; other SDK consumers can pass explicit overrides. | Developer + Codex | 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 fbd622da5..e9abfd649 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 @@ -153,7 +153,7 @@ const ConsultTransferPopoverComponent: React.FC { if (selectedCategory === CATEGORY_AGENTS && loadBuddyAgents) { - loadBuddyAgents(); + loadBuddyAgents(action); } else { handleReload(); } diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index 0713bfe0b..d93b9942d 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -178,7 +178,7 @@ function CallControlComponent(props: CallControlComponentProps) { }); setShowAgentMenu(true); setAgentMenuType(button.menuType as CallControlMenuType); - loadBuddyAgents(); + loadBuddyAgents(button.menuType === 'Transfer' ? 'Transfer' : 'Consult'); }} onHide={() => { setShowAgentMenu(false); diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index e31f8b7f1..697731adc 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -340,7 +340,7 @@ export interface ControlProps { /** * Function to load buddy agents */ - loadBuddyAgents: () => Promise; + loadBuddyAgents: (action?: 'Consult' | 'Transfer') => Promise; /** * Function to transfer the call to a destination. @@ -698,7 +698,7 @@ export interface ConsultTransferPopoverComponentProps { buttonIcon: string; buddyAgents: BuddyDetails[]; loadingBuddyAgents: boolean; - loadBuddyAgents?: () => Promise; + loadBuddyAgents?: (action?: 'Consult' | 'Transfer') => Promise; getAddressBookEntries?: FetchPaginatedList; getEntryPoints?: FetchPaginatedList; getQueues?: FetchPaginatedList; 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 1c4453450..b1fec9365 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 @@ -361,6 +361,7 @@ describe('ConsultTransferPopoverComponent', () => { fireEvent.click(reloadButton); expect(mockLoadBuddyAgents).toHaveBeenCalledTimes(1); + expect(mockLoadBuddyAgents).toHaveBeenCalledWith('Consult'); }); it('reloads queues when reload button clicked on Queues tab', async () => { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8363fb2ec..01272d0ae 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -338,6 +338,7 @@ describe('CallControlComponent', () => { // After clicking, the popover should be expanded expect(transferButton).toHaveAttribute('aria-expanded', 'true'); + expect(modifiedProps.loadBuddyAgents).toHaveBeenCalledWith('Transfer'); // Verify buttons maintain their CSS classes after interactions expect(transferButton).toHaveClass('call-control-button'); @@ -476,6 +477,7 @@ describe('CallControlComponent', () => { fireEvent.click(consultButton); await screen.findByRole('button', {name: 'Agents'}); + expect(defaultProps.loadBuddyAgents).toHaveBeenCalledWith('Consult'); expect(screen.getByRole('button', {name: 'Queues'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Entry Point'})).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Dial Number'})).not.toBeInTheDocument(); diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index a45aa836b..f7325c363 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -11,6 +11,10 @@ import { EntryPointRecord, EntryPointListResponse, EntryPointSearchParams, + ConsultTransferAction, + ConsultTransferListSearchParams, + ConsultTransferQueueSearchParams, + ConsultTransferEntryPointSearchParams, AddressBookEntry, AddressBookEntriesResponse, AddressBookEntrySearchParams, @@ -62,6 +66,8 @@ interface IContactCenter { getBuddyAgents(data: BuddyAgents): Promise; getQueues(params?: ContactServiceQueueSearchParams): Promise; getEntryPoints(params?: EntryPointSearchParams): Promise; + getConsultTransferQueues(params?: ConsultTransferQueueSearchParams): Promise; + getConsultTransferEntryPoints(params?: ConsultTransferEntryPointSearchParams): Promise; addressBook: AddressBook; agentConfig?: { regexUS: RegExp | string; @@ -71,6 +77,7 @@ interface IContactCenter { setAgentState(data: StateChange): Promise; getOutdialAniEntries(params: OutdialAniParams): Promise; getAccessToken(): Promise; + startOutdial(destination: string, origin?: string): Promise; acceptPreviewContact(payload: PreviewContactPayload): Promise; skipPreviewContact(payload: PreviewContactPayload): Promise; removePreviewContact(payload: PreviewContactPayload): Promise; @@ -216,9 +223,9 @@ interface IStoreWrapper extends IStore { onErrorCallback?: (widgetName: string, error: Error) => void; setCurrentTask(task: ITask): void; refreshTaskList(): void; - getBuddyAgents(mediaType?: string): Promise; - getQueues(mediaType?: string, params?: ContactServiceQueueSearchParams): Promise; - getEntryPoints(params?: EntryPointSearchParams): Promise; + getBuddyAgents(action?: ConsultTransferAction): Promise; + getQueues(params?: ConsultTransferListSearchParams): Promise; + getEntryPoints(params?: ConsultTransferListSearchParams): Promise; getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; setDeviceType(option: string): void; setDialNumber(input: string): void; @@ -359,6 +366,10 @@ export type { EntryPointRecord, EntryPointListResponse, EntryPointSearchParams, + ConsultTransferAction, + ConsultTransferListSearchParams, + ConsultTransferQueueSearchParams, + ConsultTransferEntryPointSearchParams, AddressBookEntry, AddressBookEntriesResponse, AddressBookEntrySearchParams, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 3cffead5f..7aad5d2a6 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -15,10 +15,10 @@ import { ENGAGED_USERNAME, RESERVED_LABEL, RESERVED_USERNAME, - ContactServiceQueue, - ContactServiceQueueSearchParams, + ContactServiceQueuesResponse, EntryPointListResponse, - EntryPointSearchParams, + ConsultTransferAction, + ConsultTransferListSearchParams, AddressBookEntriesResponse, AddressBookEntrySearchParams, Profile, @@ -31,8 +31,6 @@ import Store from './store'; import { DEVICE_TYPE_BROWSER, MEDIA_TYPE_TELEPHONY_LOWER, - MEDIA_TYPE_TELEPHONY_UPPER, - AGENT_STATE_AVAILABLE, CAMPAIGN_PREVIEW_OUTBOUND_TYPES, CAMPAIGN_PREVIEW_CAMPAIGN_TYPES, } from './store.types'; @@ -1082,14 +1080,12 @@ class StoreWrapper implements IStoreWrapper { }); }; - getBuddyAgents = async ( - mediaType: string = this.currentTask.data.interaction.mediaType - ): Promise> => { + getBuddyAgents = async (action: ConsultTransferAction = 'Consult'): Promise> => { try { + const mediaType = this.currentTask?.data?.interaction?.mediaType; const response = await this.store.cc.getBuddyAgents({ - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - mediaType: mediaType ?? MEDIA_TYPE_TELEPHONY_LOWER, - state: AGENT_STATE_AVAILABLE, + action, + ...(mediaType ? {mediaType} : {}), }); return 'data' in response ? response.data.agentList : []; } catch (error) { @@ -1098,40 +1094,23 @@ class StoreWrapper implements IStoreWrapper { } }; - getQueues = async ( - mediaType: string = this.currentTask.data.interaction.mediaType ?? MEDIA_TYPE_TELEPHONY_UPPER, - params?: ContactServiceQueueSearchParams - ): Promise<{ - data: ContactServiceQueue[]; - meta: {page: number; pageSize: number; total: number; totalPages: number}; - }> => { + getQueues = async (params?: ConsultTransferListSearchParams): Promise => { try { - const upperMediaType = mediaType.toUpperCase(); - const response = await this.store.cc.getQueues({ + const mediaType = this.currentTask?.data?.interaction?.mediaType; + + return await this.store.cc.getConsultTransferQueues({ ...(params ?? {}), - desktopProfileFilter: true, - } as ContactServiceQueueSearchParams); - const data = Array.isArray(response) ? response : response.data; - const filtered = data.filter((queue) => queue.channelType === upperMediaType); - const page = Array.isArray(response) ? 0 : (response.meta?.page ?? 0); - const totalPages = Array.isArray(response) ? 1 : (response.meta?.totalPages ?? 1); - const pageSize = Array.isArray(response) ? filtered.length : (response.meta?.pageSize ?? filtered.length); - const total = Array.isArray(response) - ? filtered.length - : ((response as {meta?: {total?: number}}).meta?.total ?? filtered.length); - return {data: filtered, meta: {page, pageSize, total, totalPages}}; + ...(mediaType ? {mediaType} : {}), + }); } catch (error) { this.store.logger.error('Error fetching queues:', error); throw error; } }; - getEntryPoints = async (params?: EntryPointSearchParams): Promise => { + getEntryPoints = async (params?: ConsultTransferListSearchParams): Promise => { try { - return await this.store.cc.getEntryPoints({ - ...(params ?? {}), - desktopProfileFilter: true, - } as EntryPointSearchParams); + return await this.store.cc.getConsultTransferEntryPoints(params); } catch (error) { this.store.logger.error('Error fetching entry points:', error); throw error; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 919ab78d4..39e6d87ed 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1003,52 +1003,73 @@ describe('storeEventsWrapper', () => { {agentName: 'Alpha Agent', agentId: '1'}, {agentName: 'Beta Agent', agentId: '2'}, ]; + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: buddyAgents}}); - const result = await storeWrapper.getBuddyAgents('telephony'); + const result = await storeWrapper.getBuddyAgents('Consult'); expect(result).toEqual(buddyAgents); + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({ + action: 'Consult', + mediaType: 'telephony', + }); + }); + + it('should pass the transfer intent to the SDK', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); + + await storeWrapper.getBuddyAgents('Transfer'); + + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({ + action: 'Transfer', + mediaType: 'telephony', + }); }); it('should handle error in getBuddyAgents and throw error', async () => { + storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockRejectedValue(new Error('error')); - await expect(storeWrapper.getBuddyAgents('telephony')).rejects.toThrow('error'); + await expect(storeWrapper.getBuddyAgents('Consult')).rejects.toThrow('error'); }); it('should return contact service queues list', async () => { const queueList = [ {id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}, {id: 'queue2', name: 'Queue 2', channelType: 'TELEPHONY'}, - {id: 'queue3', name: 'Queue 3', channelType: 'CHAT'}, // This one should be filtered out + {id: 'queue3', name: 'Queue 3', channelType: 'CHAT'}, ]; - storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(queueList); + const response = {data: queueList, meta: {page: 0, totalPages: 1}}; + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getConsultTransferQueues = jest.fn().mockResolvedValue(response); - const result = await storeWrapper.getQueues('telephony'); + const result = await storeWrapper.getQueues(); - expect(result.data).toEqual([ - {id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}, - {id: 'queue2', name: 'Queue 2', channelType: 'TELEPHONY'}, - ]); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ - desktopProfileFilter: true, + expect(result.data).toEqual(queueList); + expect(storeWrapper['store'].cc.getConsultTransferQueues).toHaveBeenCalledWith({ + mediaType: 'telephony', }); }); - it('should pass desktopProfileFilter when getQueues is called with params', async () => { + it('should pass only runtime context and list inputs when getQueues is called with params', async () => { const queueList = [{id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}]; - storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(queueList); + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getConsultTransferQueues = jest + .fn() + .mockResolvedValue({data: queueList, meta: {page: 1, totalPages: 1}}); - await storeWrapper.getQueues('telephony', {page: 1, pageSize: 25}); + await storeWrapper.getQueues({page: 1, pageSize: 25}); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + expect(storeWrapper['store'].cc.getConsultTransferQueues).toHaveBeenCalledWith({ page: 1, pageSize: 25, - desktopProfileFilter: true, + mediaType: 'telephony', }); }); it('should handle error in getQueues and throw error', async () => { - storeWrapper['store'].cc.getQueues = jest.fn().mockRejectedValue(new Error('queue error')); + storeWrapper['store'].currentTask = null; + storeWrapper['store'].cc.getConsultTransferQueues = jest.fn().mockRejectedValue(new Error('queue error')); - await expect(storeWrapper.getQueues('telephony')).rejects.toThrow('queue error'); + await expect(storeWrapper.getQueues()).rejects.toThrow('queue error'); }); it('should return contact service queues list when SDK returns paginated response', async () => { @@ -1056,16 +1077,14 @@ describe('storeEventsWrapper', () => { {...mockQueueDetails[0], channelType: 'TELEPHONY'}, {...mockQueueDetails[1], channelType: 'CHAT'}, ]; - storeWrapper['store'].cc.getQueues = jest - .fn() - .mockResolvedValue({data: queueList, meta: {page: 1, pageSize: 50, total: 2, totalPages: 1}}); + const response = {data: queueList, meta: {page: 1, pageSize: 50, totalRecords: 2, totalPages: 1}}; + storeWrapper['store'].currentTask = null; + storeWrapper['store'].cc.getConsultTransferQueues = jest.fn().mockResolvedValue(response); - const result = await storeWrapper.getQueues('telephony'); + const result = await storeWrapper.getQueues(); - expect(result.data).toEqual([{...mockQueueDetails[0], channelType: 'TELEPHONY'}]); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ - desktopProfileFilter: true, - }); + expect(result).toEqual(response); + expect(storeWrapper['store'].cc.getConsultTransferQueues).toHaveBeenCalledWith({}); }); it('should handle consultQueueCancelled event', () => { @@ -1080,25 +1099,25 @@ describe('storeEventsWrapper', () => { }); it('should fetch entry points successfully', async () => { - storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); + storeWrapper['store'].cc.getConsultTransferEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); - expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ + expect(storeWrapper['store'].cc.getConsultTransferEntryPoints).toHaveBeenCalledWith({ page: 0, pageSize: 25, - desktopProfileFilter: true, }); expect(result).toEqual(mockEntryPointsResponse); }); it('should handle error while fetching entry points', async () => { - storeWrapper['store'].cc.getEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); + storeWrapper['store'].currentTask = null; + storeWrapper['store'].cc.getConsultTransferEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); await expect(storeWrapper.getEntryPoints({page: 0, pageSize: 25})).rejects.toThrow('ep error'); }); it('should fetch address book entries successfully', async () => { storeWrapper['store'].isAddressBookEnabled = true; - jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockResolvedValue(mockAddressBookEntriesResponse); + storeWrapper['store'].cc.addressBook.getEntries = jest.fn().mockResolvedValue(mockAddressBookEntriesResponse); const result = await storeWrapper.getAddressBookEntries({page: 0, pageSize: 25}); expect(storeWrapper['store'].cc.addressBook.getEntries).toHaveBeenCalledWith({ @@ -1110,7 +1129,7 @@ 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')); + storeWrapper['store'].cc.addressBook.getEntries = jest.fn().mockRejectedValue(new Error('ab error')); await expect(storeWrapper.getAddressBookEntries({page: 0, pageSize: 25})).rejects.toThrow('ab error'); }); diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..43ff6057b 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -575,22 +575,25 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, agentId, extractConsultingAgent]); - const loadBuddyAgents = useCallback(async () => { - try { - setLoadingBuddyAgents(true); - const agents = await store.getBuddyAgents(); - logger.info(`Loaded ${agents.length} buddy agents`, {module: 'helper.ts', method: 'loadBuddyAgents'}); - setBuddyAgents(agents); - } catch (error) { - logger?.error(`CC-Widgets: Task: Error loading buddy agents - ${error.message || error}`, { - module: 'useCallControl', - method: 'loadBuddyAgents', - }); - setBuddyAgents([]); - } finally { - setLoadingBuddyAgents(false); - } - }, [logger]); + const loadBuddyAgents = useCallback( + async (action: 'Consult' | 'Transfer' = 'Consult') => { + try { + setLoadingBuddyAgents(true); + const agents = await store.getBuddyAgents(action); + logger.info(`Loaded ${agents.length} buddy agents`, {module: 'helper.ts', method: 'loadBuddyAgents'}); + setBuddyAgents(agents); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error loading buddy agents - ${error.message || error}`, { + module: 'useCallControl', + method: 'loadBuddyAgents', + }); + setBuddyAgents([]); + } finally { + setLoadingBuddyAgents(false); + } + }, + [logger] + ); const getAddressBookEntries = useCallback( async ({page, pageSize, search}: PaginatedListParams) => { @@ -625,8 +628,7 @@ export const useCallControl = (props: useCallControlProps) => { const getQueuesFetcher = useCallback( async ({page, pageSize, search}: PaginatedListParams) => { try { - const mediaType = currentTask?.data?.interaction?.mediaType; - return await store.getQueues(mediaType, {page, pageSize, search}); + return await store.getQueues({page, pageSize, search}); } catch (error) { logger?.error(`CC-Widgets: Task: Error fetching queues (paginated) - ${error.message || error}`, { module: 'useCallControl', @@ -635,7 +637,7 @@ export const useCallControl = (props: useCallControlProps) => { return {data: [], meta: {page: 0, totalPages: 0}}; } }, - [logger, currentTask] + [logger] ); const holdCallback = () => { @@ -1304,12 +1306,14 @@ export const useOutdialCall = (props: useOutdialCallProps) => { return; } - // Only pass origin if it's defined and not empty - const outdialArgs = origin ? [destination, origin] : [destination]; + const outdialPromise = origin ? cc.startOutdial(destination, origin) : cc.startOutdial(destination); - cc.startOutdial(...outdialArgs) - .then((response) => { - logger.info('Outdial call started', response); + outdialPromise + .then(() => { + logger.info('Outdial call started', { + module: 'widget-OutdialCall#helper.ts', + method: 'startOutdial', + }); }) .catch((error: Error) => { logger.error(`${error}`, { diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx index 9b5ea11ee..f27df0810 100644 --- a/packages/contact-center/task/tests/call-control-recording.tsx +++ b/packages/contact-center/task/tests/call-control-recording.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {render, screen, act} from '@testing-library/react'; import '@testing-library/jest-dom'; import {EventEmitter} from 'events'; -import store, {TASK_EVENTS, IContactCenter} from '@webex/cc-store'; +import store, {TASK_EVENTS, IContactCenter, ITask} from '@webex/cc-store'; import {mockTask, mockCC, createEnabledMainTaskUIControls} from '@webex/test-fixtures'; import {CallControl} from '../src/CallControl'; @@ -76,8 +76,8 @@ const promoteTask = (task: FakeTask) => { store.store.agentId = AGENT_ID; // Registers the store's own task listeners (refreshTaskList on recording // pause/resume, etc.) exactly as production does. - store.handleIncomingTask(task); - store.setCurrentTask(task); + store.handleIncomingTask(task as unknown as ITask); + store.setCurrentTask(task as unknown as ITask); }; /** What the SDK does when the ContactRecordingPaused websocket event arrives. */ @@ -104,7 +104,6 @@ describe('CallControl recording pause/resume state', () => { info: jest.fn(), warn: jest.fn(), error: jest.fn(), - debug: jest.fn(), trace: jest.fn(), }; }); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..423c02737 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -2400,9 +2400,10 @@ describe('useCallControl', () => { }) ); await act(async () => { - await result.current.loadBuddyAgents(); + await result.current.loadBuddyAgents('Transfer'); }); expect(result.current.buddyAgents).toEqual(mockAgents); + expect(getBuddyAgentsSpy).toHaveBeenCalledWith('Transfer'); getBuddyAgentsSpy.mockRestore(); }); @@ -3538,7 +3539,7 @@ describe('useCallControl', () => { it('should get queues via getQueuesFetcher', async () => { const getQueuesResponse: Awaited> = { data: mockQueueDetails, - meta: {page: 0, pageSize: mockQueueDetails.length, total: mockQueueDetails.length, totalPages: 1}, + meta: {page: 0, pageSize: mockQueueDetails.length, totalRecords: mockQueueDetails.length, totalPages: 1}, }; const getQueuesSpy = jest.spyOn(store, 'getQueues').mockResolvedValue(getQueuesResponse); @@ -3598,7 +3599,7 @@ describe('useCallControl', () => { it('should get queues via getQueuesFetcher (paginated)', async () => { const mockResponse: Awaited> = { data: [mockQueueDetails[0]], - meta: {page: 0, pageSize: 25, total: 1, totalPages: 1}, + meta: {page: 0, pageSize: 25, totalRecords: 1, totalPages: 1}, }; jest.spyOn(store, 'getQueues').mockResolvedValue(mockResponse); @@ -5696,7 +5697,10 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); + expect(logger.info).toHaveBeenCalledWith('Outdial call started', { + module: 'widget-OutdialCall#helper.ts', + method: 'startOutdial', + }); }); it('should successfully start an outdial call with origin', async () => { @@ -5713,7 +5717,10 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination, origin); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); + expect(logger.info).toHaveBeenCalledWith('Outdial call started', { + module: 'widget-OutdialCall#helper.ts', + method: 'startOutdial', + }); }); it('should show alert when destination is empty or only contains spaces', async () => { diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index e095af87f..8453b0e2a 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -619,10 +619,13 @@ const mockCC: IContactCenter = { getBuddyAgents: jest.fn().mockResolvedValue(mockAgents), getQueues: jest.fn().mockResolvedValue(mockQueuesResponse), getEntryPoints: jest.fn().mockResolvedValue(mockEntryPointsResponse), + getConsultTransferQueues: jest.fn().mockResolvedValue(mockQueuesResponse), + getConsultTransferEntryPoints: jest.fn().mockResolvedValue(mockEntryPointsResponse), addressBook: mockAddressBook, setAgentState: jest.fn().mockResolvedValue({}), getOutdialAniEntries: jest.fn().mockResolvedValue({entries: []}), getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), + startOutdial: jest.fn().mockResolvedValue({}), acceptPreviewContact: jest.fn().mockResolvedValue({}), skipPreviewContact: jest.fn().mockResolvedValue({}), removePreviewContact: jest.fn().mockResolvedValue({}), From 6ae5a6c41450bd41011c13ec3439472ba680e056 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Wed, 19 Aug 2026 19:58:04 +0530 Subject: [PATCH 03/10] fix(contact-center): delegate list policy to sdk --- ai-docs/CONTRACTS.md | 5 +-- .../spec/feature-spec.md | 33 ++++++++++--------- .../ai-docs/cc-components-spec.md | 5 ++- .../consult-transfer-popover-hooks.ts | 9 +++-- .../src/components/task/task.types.ts | 16 ++++----- .../consult-transfer-popover.snapshot.tsx | 25 ++------------ .../consult-transfer-popover.tsx | 20 +++++------ .../store/ai-docs/store-spec.md | 21 ++++++------ .../contact-center/store/src/store.types.ts | 24 ++++++++------ .../store/src/storeEventsWrapper.ts | 23 ++++++++----- .../store/tests/storeEventsWrapper.ts | 6 ++-- 11 files changed, 92 insertions(+), 95 deletions(-) diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 11862cc93..2661ae5eb 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -23,7 +23,8 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | -| store.types | `@webex/cc-store` | Type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `AgentLogin`, `IStore`, `ILogger`, `InitParams`, `IWebex`, `RealTimeTranscriptionData`, plus ~20 more) | TypeScript `type`/`interface` exports describing the SDK-backed domain surface | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:334-366` | +| store.types | `@webex/cc-store` | Type re-exports including `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` | TypeScript exports describing the SDK-backed domain surface; specialized queue/entry-point inputs share one minimal list contract and rows are `{id, name, dbId?}` projections | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | +| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer queue and entry-point fetch props | `FetchPaginatedList`; UI consumes projected `id`/`name` while preserving SDK order | stable semver; destination projection tracks store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | @@ -32,7 +33,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store (`packages/contact-center/store/src/storeEventsWrapper.ts`) | Host establishes the authenticated Webex session; SDK assumed reachable | `Store.init()` rejects after a 6s init timeout; widgets stay inert and surface error UI (`packages/contact-center/store/src/store.ts:140-142`) | Pinned by the SDK dependency in each package's `package.json` | +| `@webex/contact-center` SDK | The CC runtime, including specialized consult/transfer list methods and projected response/media types, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md index 040afb0cc..2700d95f0 100644 --- a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md @@ -17,11 +17,11 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi | --- | --- | | Feature key | `CAI-8354` | | Owner | Webex Contact Center widgets maintainers | -| Status | Approved and implemented; generator-side conformance PASS; independent validation pending | +| Status | Approved and implemented; diff-scoped drift validation PASS; independent validation pending | | Work type | Defect | | Change class | Contract / UI | | Source/intake | Developer-approved Agent Desktop parity review and current code/tests | -| Last verified | 2026-08-19 in a working tree based on `4b928847` | +| Last verified | 2026-08-19 in a working tree based on `69fdb37c` | ## Applicability @@ -96,10 +96,10 @@ There are no open product decisions for this delta. | --- | --- | --- | --- | --- | --- | --- | | `WIDGET-LIST-R-001` | The store must use the SDK's specialized consult/transfer queue and entry-point methods, use the generic SDK AddressBook service for dial numbers, and must not apply local eligibility filters, sorting, or pagination reconstruction. | SDK-owned defaults prevent drift while avoiding a redundant consult-specific dial-number API. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK delta at runtime. | Present | | `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. | Agent eligibility differs by action, so losing the action would silently return the wrong population. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts` | None. | Present | -| `WIDGET-LIST-R-003` | Queue requests must forward page, page size, search, and current-task media; entry-point and dial-number requests must forward only pagination/search. | Queue media comes from the active task, while telephony entry-point behavior and both list-order defaults are SDK decisions that widgets must not duplicate. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | When queue media is absent, the SDK default applies. | Present | +| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, search, and current-task media; dial-number requests forward only pagination/search. | Agent Desktop supplies interaction media for both queues and entry points, while channel mapping, eligibility, and all list-order defaults remain SDK decisions. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | When task media is absent, the SDK telephony default applies. | Present | | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | -| `WIDGET-LIST-R-006` | Store and component types must expose the action-aware loader and the specialized SDK request/response shapes without `any`. | Compile-time alignment prevents the widgets from recreating SDK policy through loosely typed escape hatches. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the new exports. | Present | +| `WIDGET-LIST-R-006` | Store and component types must expose the action-aware loader, shared `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` without `any`; they must not type projected queue/entry-point rows as full CMS records or expose SDK policy flags through widget loaders. | Compile-time alignment gives consumers one minimal list contract, prevents widgets from reading fields omitted by the SDK projection, and avoids loosely typed policy escape hatches. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the new exports. | Present | ## Defect context (when applicable) @@ -113,7 +113,7 @@ There are no open product decisions for this delta. ### MOD-001 — Store list delegation (`STORE-R-015`) -- **WHAT**: Replace widget-owned queue filtering/metadata reconstruction and generic entry-point calls with thin delegation to the SDK's consult/transfer queue/entry-point methods. Keep dial numbers on the generic AddressBook service. Pass current-task media only to queues and pass no ordering inputs; preserve each SDK response as returned. +- **WHAT**: Replace widget-owned queue filtering/metadata reconstruction and generic entry-point calls with thin delegation to the SDK's consult/transfer queue/entry-point methods. Keep dial numbers on the generic AddressBook service. Pass current-task media to both queues and entry points, pass no ordering or policy inputs, and preserve each SDK response as returned. - **WHY**: Eligibility, backend query flags, and ordering defaults are reusable domain policy and must not diverge across UI clients. - **Evidence:** `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. - **Acceptance:** No queue, entry-point, or dial-number `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. @@ -136,10 +136,11 @@ There are no open product decisions for this delta. - [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). - [x] Queue and entry-point fetchers delegate to specialized SDK methods, while dial numbers use AddressBook, without local sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). -- [x] Only queue requests include current-task media when available; entry-point and dial-number requests contain no widget-selected media or sorting (`MOD-001`, `WIDGET-LIST-R-003`). +- [x] Queue and entry-point requests include current-task media when available; dial-number requests contain no widget-selected media, and no list request contains widget-selected sorting or policy flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). - [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). -- [x] Store, task, and cc-components unit suites pass with the coordinated SDK worktree. +- [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with the coordinated SDK worktree. +- [ ] The complete cc-components unit suite is blocked in the local-link setup by the SDK calling package's `uuid` ESM/Jest incompatibility; the changed consult/transfer suites pass independently. ## Scenarios and applicable change views @@ -148,7 +149,7 @@ There are no open product decisions for this delta. | Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged. | SDK failure produces an empty agent list and clears loading. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005` | | Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; it does not fall back to Consult. | `WIDGET-LIST-R-002` | | Search Queues | Agent | Active task with media context | Page/search input plus media reach the SDK; response and metadata are preserved. | Missing task media lets the SDK default to telephony. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Search Entry Points | Agent | Entry-point tab visible | Page/search input reaches the SDK and backend order is rendered. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | +| Search Entry Points | Agent | Entry-point tab visible and active task may carry media | Page/search plus available task media reach the SDK and backend order is rendered. | Missing media uses the SDK default; failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | | Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook and its SDK-default backend order is rendered. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | ### Interaction and scenario matrix @@ -158,8 +159,9 @@ There are no open product decisions for this delta. | Consult + Agents | Open or reload | `loadBuddyAgents('Consult')` | Applying Transfer-only availability filtering | `WIDGET-LIST-R-002` | | Transfer + Agents | Open or reload | `loadBuddyAgents('Transfer')` | Reloading with the default Consult action | `WIDGET-LIST-R-002` | | Queue + current task media | Fetch/search/page | Store forwards media once to the specialized SDK call | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| No current task media | Queue fetch | SDK receives no media override and uses its default | Widget invents a backend channel policy | `WIDGET-LIST-R-003` | -| Entry point or dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| No current task media | Queue or entry-point fetch | SDK receives no media override and uses its default | Widget invents a backend channel policy | `WIDGET-LIST-R-003` | +| Entry point + current task media | Fetch/search/page | Store forwards media once to the specialized SDK call and preserves SDK order | Widget maps the channel or supplies sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | ### UI flow and design @@ -170,8 +172,8 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp | API or operation | Change | Consumer impact | Compatibility expectation | Canonical definition | | --- | --- | --- | --- | --- | | Store buddy-agent loader | Accepts optional `Consult`/`Transfer` action instead of a media argument. | Task and component layers pass user intent. | Coordinated widgets release required. | `packages/contact-center/store/src/store.types.ts` | -| Store queue loader | Accepts pagination/search only and delegates to the SDK's specialized method. | Callers no longer supply independent media policy. | Coordinated widgets/SDK release required. | `packages/contact-center/store/src/store.types.ts` | -| Store entry-point loader | Delegates pagination/search to the SDK's specialized method. | No caller-owned media, profile, filter, or sort flags. | Additive at the SDK boundary; coordinated store update. | `packages/contact-center/store/src/store.types.ts` | +| Store queue loader | Accepts pagination/search only and delegates with current-task media to the SDK's specialized method. | Callers no longer supply independent media or policy controls. | Coordinated widgets/SDK release required. | `packages/contact-center/store/src/store.types.ts` | +| Store entry-point loader | Accepts pagination/search only and delegates with current-task media to the SDK's specialized method. | No caller-owned media, profile, filter, projection, or sort flags. | Additive at the SDK boundary; coordinated store update. | `packages/contact-center/store/src/store.types.ts` | | Store dial-number loader | Delegates pagination/search to the generic SDK AddressBook service. | The SDK default supplies backend name ordering. | Existing SDK surface with a corrected default. | `packages/contact-center/store/src/store.types.ts` | ### Public API and semver impact @@ -194,7 +196,7 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp **Provides — MODIFIED:** The widget packages provide action-aware list loading and preserve SDK result order/metadata without owning eligibility policy. -**Requires — MODIFIED:** The store requires an SDK that exports `ConsultTransferAction`, `ConsultTransferListSearchParams`, `ConsultTransferQueueSearchParams`, `getConsultTransferQueues`, and `getConsultTransferEntryPoints`. +**Requires — MODIFIED:** The store requires an SDK that exports `ConsultTransferAction`, `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, `ConsultTransferListResponse`, `getConsultTransferQueues`, and `getConsultTransferEntryPoints`. No event contract changes. @@ -213,9 +215,9 @@ No event contract changes. | --- | --- | --- | --- | --- | | Buddy-agent request | `action`, optional current task `mediaType` | Distinguish Consult and Transfer eligibility. | SDK contract; widgets supply runtime context. | No new retained data or credentials. | | Queue list request | `page`, `pageSize`, `search`, optional current task `mediaType` | Paginated destination discovery. | SDK owns reusable defaults. | No widget-owned eligibility flags. | -| Entry-point list request | `page`, `pageSize`, `search` | Paginated destination discovery. | SDK owns telephony eligibility and backend name ordering. | No widget-owned media, eligibility, or sort flags. | +| Entry-point list request | `page`, `pageSize`, `search`, optional current task `mediaType` | Paginated destination discovery. | SDK owns media validation/mapping, eligibility, and backend name ordering. | No widget-owned filter, projection, view, or sort flags. | | Dial-number list request | `page`, `pageSize`, `search` | Paginated address-book destination discovery. | AddressBook owns backend name ordering. | No widget-owned sort flags. | -| Paginated response | SDK `data` and `meta` unchanged | Preserve backend order and pagination truth. | SDK/backend | No client reconstruction. | +| Queue/entry-point paginated response | SDK `ConsultTransferListResponse` with `data: {id, name, dbId?}[]` and `meta` unchanged | Preserve backend order, typed projection, and pagination truth. | SDK/backend | Widgets must not assume full queue or entry-point records or reconstruct metadata. | ## Impacted domains @@ -289,4 +291,5 @@ No event contract changes. | 2026-08-19 | Approved this exact MODIFIED delta path. | Avoid overwriting draft canonical module specs while keeping spec-currency with the implementation. | Developer | | 2026-08-19 | Assigned reusable list policy to the SDK and retained only UI/runtime context in widgets. | Prevent policy duplication and ordering drift. | Developer + Codex | | 2026-08-19 | Explicitly prohibited widget-side sorting/filtering of SDK destination results. | Preserve the backend order selected by the SDK request. | Developer + Codex | +| 2026-08-19 | Adopted the SDK's single minimal consult/transfer options type and forward current-task media for both queue and entry-point requests. | Match Agent Desktop inputs while keeping filter, projection, view, channel mapping, ordering, and cache decisions out of widgets. | Developer + Codex | | 2026-08-19 | Removed widget selection of entry-point media and the consult-specific dial-number helper. | EntryPoint and AddressBook defaults must work for widgets out of the box; other SDK consumers can pass explicit overrides. | Developer + Codex | diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 1439d9e0a..b40a064a4 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -93,6 +93,7 @@ Consumed as an imported SDK/code API. The React barrel (`src/index.ts`) is the p | `cc-components.StationLoginComponent` | SDK | `StationLoginComponent` (`StationLoginComponentProps`) | Agent login: device/team selection, login/logout, multiple-login alert, profile mode | semver; props are `Pick`ed — adding optional props = minor, removing/renaming a picked prop = major | `src/components/StationLogin/station-login.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.UserStateComponent` | SDK | `UserStateComponent` (`UserStateComponentsProps`) | Agent state dropdown + idle codes + state timer | semver as above | `src/components/UserState/user-state.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlComponent` | SDK | `CallControlComponent` (`CallControlComponentProps`) | Call control buttons: hold/resume, mute, record, end, wrapup, consult/transfer/conference | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.consult-transfer-lists` | SDK callback props | `getQueuesFetcher`, `getQueues`, and `getEntryPoints` use `FetchPaginatedList` | Render SDK-projected queue and entry-point rows without assuming full CMS record fields | additive alignment with the SDK/store projection; changing required destination fields is semver-sensitive | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -109,7 +110,7 @@ Compatibility notes: - `wc.ts` aliases `CallControlCADComponent` to `../CallControl/call-control` (imports `CallControlComponent` under the `CallControlCADComponent` name); the distinct CAD component is `src/components/task/CallControlCAD/call-control-cad.tsx`. See Pitfalls. ## Requires (dependencies) -- `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. +- `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `ConsultTransferDestination`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. - `@webex/cc-ui-logging` (workspace:\*) — `withMetrics` HOC wrapping each top-level component for mount/metrics tracking. - `@momentum-ui/react-collaboration` (peer `>=26.197.0`) and `@momentum-design/components/dist/react` — UI primitives. - `@r2wc/react-to-web-component` `2.0.3` — custom-element wrapping in `wc.ts`. @@ -135,6 +136,7 @@ Compatibility notes: | `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | +| `CC-COMPONENTS-R-016` | Consult/transfer queue and entry-point fetch props must use `ConsultTransferDestination`, retain only the projected `id` and `name` needed by the UI, and preserve fetch order during pagination/reload. | The component must not read queue/entry-point fields omitted by the SDK projection or introduce a second ordering policy. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | `dbId` remains available on the raw projected row but is not needed for current rendering/selection. | PRESENT | ## Design Overview Every component follows the same shape: a typed function component destructures props, derives display data through pure helpers in a co-located `*.utils.ts(x)`, renders Momentum primitives, and calls back through callback props on user interaction. Local `useState` holds only transient UI (open menus, selected-but-not-yet-submitted values, input text) — never domain state. Top-level components are wrapped in `withMetrics`. This keeps each component unit-testable with plain props and jest mocks and is the reason the archived "presentational pattern" guidance still holds. @@ -313,6 +315,7 @@ Each component is tested in isolation with React Testing Library: render from a | `CC-COMPONENTS-R-013` | `tests/components/task/CallControl/call-control.utils.tsx`, component snapshots | No dedicated `formatTime`/`getMediaTypeInfo` unit test | | `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | | `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | +| `CC-COMPONENTS-R-016` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index 70fc7a691..8884066f6 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -1,8 +1,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import { AddressBookEntry, - ContactServiceQueue, - EntryPointRecord, + ConsultTransferDestination, ILogger, FetchPaginatedList, PaginatedListParams, @@ -170,7 +169,7 @@ export function useConsultTransferPopover({ loading: loadingEntryPoints, loadData: loadEntryPoints, reset: resetEntryPoints, - } = usePaginatedData( + } = usePaginatedData( getEntryPoints, (entry) => ({id: entry.id, name: entry.name}), CATEGORY_ENTRY_POINT, @@ -184,9 +183,9 @@ export function useConsultTransferPopover({ loading: loadingQueues, loadData: loadQueues, reset: resetQueues, - } = usePaginatedData( + } = usePaginatedData( getQueues, - (entry) => ({id: entry.id, name: entry.name, description: entry.description}), + (entry) => ({id: entry.id, name: entry.name}), CATEGORY_QUEUES, logger ); diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 697731adc..52ac8ce9f 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -6,8 +6,8 @@ import { BuddyDetails, DestinationType, ContactServiceQueue, + ConsultTransferDestination, AddressBookEntry, - EntryPointRecord, FetchPaginatedList, Participant, AddressBookEntrySearchParams, @@ -513,10 +513,10 @@ export interface ControlProps { getAddressBookEntries?: FetchPaginatedList; /** Fetch paginated entry points */ - getEntryPoints?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; - /** Fetch paginated queues (filtered by media type in store) */ - getQueuesFetcher?: FetchPaginatedList; + /** Fetch paginated consult/transfer queues from the SDK-owned policy */ + getQueuesFetcher?: FetchPaginatedList; /** * Options to configure consult/transfer popover behavior. @@ -700,8 +700,8 @@ export interface ConsultTransferPopoverComponentProps { loadingBuddyAgents: boolean; loadBuddyAgents?: (action?: 'Consult' | 'Transfer') => Promise; getAddressBookEntries?: FetchPaginatedList; - getEntryPoints?: FetchPaginatedList; - getQueues?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; + getQueues?: FetchPaginatedList; onAgentSelect: (agentId: string, agentName: string, allowParticipantsToInteract: boolean) => void; onQueueSelect: (queueId: string, queueName: string, allowParticipantsToInteract: boolean) => void; onEntryPointSelect: (entryPointId: string, entryPointName: string, allowParticipantsToInteract: boolean) => void; @@ -918,8 +918,8 @@ export type UseConsultTransferParams = { showDialNumberTab: boolean; showEntryPointTab: boolean; getAddressBookEntries?: FetchPaginatedList; - getEntryPoints?: FetchPaginatedList; - getQueues?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; + getQueues?: FetchPaginatedList; logger?: ILogger; }; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx index 05a27ed95..a7e886552 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx @@ -2,7 +2,7 @@ import React from 'react'; import '@testing-library/jest-dom'; import {render, fireEvent, act, waitFor} from '@testing-library/react'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; -import {ContactServiceQueue} from '@webex/cc-store'; +import {ConsultTransferDestination} from '@webex/cc-store'; const mockUIDProps = (container) => { container @@ -29,30 +29,9 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const mockOnAgentSelect = jest.fn(); const mockOnQueueSelect = jest.fn(); - const buildQueue = (id: string, name: string, description: string = 'Queue'): ContactServiceQueue => ({ - organizationId: 'org-test', + const buildQueue = (id: string, name: string): ConsultTransferDestination => ({ id, - version: 1, name, - description, - queueType: 'INBOUND', - checkAgentAvailability: true, - channelType: 'TELEPHONY', - serviceLevelThreshold: 20, - maxActiveContacts: 25, - maxTimeInQueue: 600, - defaultMusicInQueueMediaFileId: 'media-1', - active: true, - monitoringPermitted: true, - parkingPermitted: true, - recordingPermitted: true, - recordingAllCallsPermitted: true, - pauseRecordingPermitted: true, - controlFlowScriptUrl: 'https://example.com/flow', - ivrRequeueUrl: 'https://example.com/requeue', - routingType: 'LONGEST_AVAILABLE_AGENT', - queueRoutingType: 'TEAM_BASED', - callDistributionGroups: [], }); const defaultProps = { 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 b1fec9365..3068f2182 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 @@ -2,7 +2,7 @@ import React from 'react'; 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, EntryPointRecord, AddressBookEntry} from '@webex/cc-store'; +import {ConsultTransferDestination, EntryPointRecord, AddressBookEntry} from '@webex/cc-store'; import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; const loggerMock = { @@ -50,8 +50,8 @@ describe('ConsultTransferPopoverComponent', () => { ], getQueues: async () => ({ data: [ - {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, - {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, + {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, + {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, ], meta: {page: 0, totalPages: 1}, }), @@ -286,8 +286,8 @@ describe('ConsultTransferPopoverComponent', () => { it('debounces and triggers queue search on 2+ chars and on clear', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ data: [ - {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, - {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, + {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, + {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, ], meta: {page: 0, totalPages: 1}, }); @@ -367,8 +367,8 @@ describe('ConsultTransferPopoverComponent', () => { it('reloads queues when reload button clicked on Queues tab', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ data: [ - {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, - {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, + {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, + {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, ], meta: {page: 0, totalPages: 1}, }); @@ -499,8 +499,8 @@ describe('ConsultTransferPopoverComponent', () => { it('shows spinner in load more area when loading more queues', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ data: [ - {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, - {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, + {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, + {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, ], meta: {page: 0, totalPages: 2}, }); @@ -544,7 +544,7 @@ describe('ConsultTransferPopoverComponent', () => { it('reloads with current search query on Queues tab', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ - data: [{id: 'queue1', name: 'Queue One'} as ContactServiceQueue], + data: [{id: 'queue1', name: 'Queue One'} as ConsultTransferDestination], meta: {page: 0, totalPages: 1}, }); diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 713552968..7417cc6de 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -74,7 +74,7 @@ This module is consumed as an imported SDK/code API (the `@webex/cc-store` packa | Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | |---|---|---|---|---|---|---| | `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.types` | SDK | type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `IStore`, `IStoreWrapper`, `InitParams`, `RealTimeTranscriptionData`, ~20 more) | Typed domain surface for widget code | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts:334-366`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.types` | SDK | type re-exports including `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` | Typed SDK-backed domain surface; consult/transfer queue and entry-point inputs share one minimal contract and rows use the exact projected shape | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -104,7 +104,7 @@ Compatibility notes: | `STORE-R-012` | `handleTaskRemove` detaches every task listener, clears `realtimeTranscriptionData` for the removed current task, drops accepted-campaign tracking, resets custom state, and refreshes the list | Prevent listener/audio/state leaks across task lifecycles | `src/storeEventsWrapper.ts:458-521` | `tests/storeEventsWrapper.ts` ("handleTaskRemove — campaign ID cleanup") | Per-listener detach is asserted only partially; full leak audit is a gap | PRESENT | | `STORE-R-013` | `agent:logoutSuccess` triggers `cleanUpStore()` which resets session observables and removes CC SDK listeners; `agent:multiLogin` sets `showMultipleLoginAlert` | Clean session teardown and multi-login warning | `src/storeEventsWrapper.ts:811-819,1003-1024,1029-1066` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | | `STORE-R-014` | `agent:stateChange` (type `AgentStateChangeSuccess`) updates `currentState` (defaulting `auxCodeId` `''`→`'0'`) and both state-change timestamps | Drives the agent-state widget and timers | `src/storeEventsWrapper.ts:797-809` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | -| `STORE-R-015` | List fetchers proxy the SDK and propagate errors after logging; `getQueues` filters by upper-cased channel type; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false | Centralize SDK fetch + transform so widgets stay SDK-agnostic | `src/storeEventsWrapper.ts:924-1001` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper", "getAccessToken") | `getBuddyAgents`/`getQueues` happy-path filtering covered; address-book disabled branch coverage is a gap | PRESENT | +| `STORE-R-015` | Consult/transfer list fetchers thinly delegate action, pagination/search, and SDK-originated task media to the SDK; queue and entry-point methods return `ConsultTransferListResponse` without local filtering, sorting, or metadata reconstruction. Errors are logged and rethrown; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false. | Keep reusable eligibility, projection, media validation, and ordering decisions in the SDK while widgets preserve backend order and pagination truth. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/storeEventsWrapper.ts` | Address-book-disabled branch coverage is a gap. | PRESENT | | `STORE-R-016` | `setOnError` wraps the caller callback to also submit a behavioral metrics event before invoking it | Consistent telemetry on widget errors | `src/storeEventsWrapper.ts:285-301` | None found | Negative/telemetry-path test missing | WEAK | | `STORE-R-017` | `isIncomingTask` returns true only when the task is not wrap-up-required, the agent has not joined, and the interaction state is `new`/`consult`/`connected`/`conference` | Gates whether a task is treated as an unanswered incoming offer | `src/task-utils.ts:26-37` | `tests/task-utils.ts` ("isIncomingTask" — incoming / not incoming / edge cases) | none | PRESENT | | `STORE-R-018` | `getConsultStatus`/`getTaskStatus` map participant `consultState` + interaction state to a `ConsultStatus`, with special handling for secondary EP-DN agents | Consult/conference UI relies on a single derived status | `src/task-utils.ts:39-146` | None found (direct `getConsultStatus` test) | Only `isIncomingTask`, conference, and hold helpers are directly tested; consult-status helper is a gap | WEAK | @@ -244,12 +244,11 @@ sequenceDiagram participant W as StoreWrapper participant SDK as "@webex/contact-center" - Widget->>W: getQueues(mediaType, params) - W->>SDK: cc.getQueues(params) + Widget->>W: getQueues(params) / getEntryPoints(params) + W->>SDK: specialized method({params, currentTaskMedia}) alt resolves - SDK-->>W: queues - W->>W: filter by channelType == mediaType.toUpperCase() - W-->>Widget: {data, meta} + SDK-->>W: ConsultTransferListResponse + W-->>Widget: unchanged {data, meta} else rejects SDK-->>W: error W->>W: logger.error(...) @@ -281,7 +280,7 @@ classDiagram - **UC-3 Observe agent/session state in React:** Widget wraps in `observer()` and reads `store.agentId`, `store.isAgentLoggedIn`, `store.deviceType`, `store.currentState` → re-renders on mutation. Evidence: `src/storeEventsWrapper.ts:56-187`, `_archive/.../AGENTS.md` usage. - **UC-4 Handle an incoming task through to wrap-up:** SDK `task:incoming` → listeners registered + `onIncomingTask` fired → `task:assigned` sets ENGAGED/current → `task:end` + `handleTaskRemove` cleans up. Evidence: `src/storeEventsWrapper.ts:585-762`, `tests/storeEventsWrapper.ts` ("events reactions"). - **UC-5 Campaign-preview accept flow:** `task:campaignPreviewReservation` puts a preview in RESERVED; preview stays out of `currentTask` until accepted (`acceptedCampaignIds`), then transitions to ENGAGED. Evidence: `src/storeEventsWrapper.ts:243-283,537-583,772-795`, `tests/storeEventsWrapper.ts` ("campaign preview task lifecycle"). -- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult widget calls `getBuddyAgents()`/`getQueues()`; Outdial calls `getEntryPoints()`/`getAddressBookEntries()` → store proxies the SDK, transforms/filters, returns. `getQueues`/`getEntryPoints` merge `desktopProfileFilter: true`. Evidence: `src/storeEventsWrapper.ts:924-1001`, `tests/storeEventsWrapper.ts`. +- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult calls `getBuddyAgents()`/`getQueues()`/`getEntryPoints()` and Outdial calls `getAddressBookEntries()`. The store delegates consult/transfer lists to the specialized SDK methods, forwards only runtime context and list inputs, and returns projected data/metadata unchanged. Evidence: `src/storeEventsWrapper.ts`, `tests/storeEventsWrapper.ts`. ## State Model The store is a single MobX `makeAutoObservable` instance. Observable slices (all in `src/store.ts:23-56`): @@ -289,7 +288,7 @@ The store is a single MobX `makeAutoObservable` instance. Observable slices (all - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. - **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `accessQueue`, `accessEntryPoint`, `accessBuddyTeam`, `isDigitalChannelsInitialized`. -- **`getQueues` / `getEntryPoints`:** merge `desktopProfileFilter: true` into SDK search params. List sort deferred pending team decision on CMS/BFF sort param parity. +- **`getQueues` / `getEntryPoints`:** forward current-task media plus pagination/search and return `ConsultTransferListResponse` from the SDK specialized methods. They do not add eligibility/view/sort flags or reinterpret the projected `id`/`name`/optional-`dbId` rows. - **`getBuddyAgents`:** returns agents sorted by `agentName` ascending (client-side; full list, no pagination). - **Misc:** `currentTheme`, `cc` (`observable.ref` — not deeply observed), `isAddressBookEnabled`. @@ -307,7 +306,7 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via - **Event enums are local copies (`store.types.ts:204-259`):** `CC_EVENTS`/`TASK_EVENTS` string values must match the SDK exactly; an SDK rename will silently stop a handler from firing. - **Pending campaign previews must not become `currentTask`:** `setCurrentTask` clears `currentTask` for a preview in state `new` that is not in `acceptedCampaignIds` (`storeEventsWrapper.ts:255-267`). Bypassing this (e.g. calling SDK methods directly) re-introduces the bug where CallControl renders for an unaccepted preview. - **Listener leaks:** every `task.on(...)` in `registerTaskEventListeners` has a matching `task.off(...)` in `handleTaskRemove`. Adding a listener in one without the other leaks handlers and can double-fire `refreshTaskList`. -- **`getBuddyAgents`/`getQueues` default args dereference `this.currentTask.data.interaction.mediaType` (`storeEventsWrapper.ts:925,941`):** calling them with no `currentTask` set throws. Callers should pass an explicit `mediaType` when no task is active. +- **Task media is SDK-originated but broadly typed on `ITask`:** the store forwards it as `ConsultTransferMediaType`; runtime validation remains SDK-owned, and absent media uses the SDK telephony default. - **`@ts-expect-error` markers tie to SDK gaps:** several casts (e.g. `response.teams`, credentials API) are pinned to `CAI-6762`; removing the workaround before the SDK fix breaks the build. ## Module Do's / Don'ts @@ -339,7 +338,7 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-012` | `tests/storeEventsWrapper.ts` (handleTaskRemove cleanup) | full per-listener detach not exhaustively asserted | | `STORE-R-013` | `tests/storeEventsWrapper.ts` (events reactions) | none | | `STORE-R-014` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-015` | `tests/storeEventsWrapper.ts` (list fetchers, getAccessToken) | address-book-disabled branch not directly asserted | +| `STORE-R-015` | `tests/storeEventsWrapper.ts` (specialized list delegation, unchanged order/metadata, errors) | address-book-disabled branch not directly asserted | | `STORE-R-016` | None found | missing telemetry-path test | | `STORE-R-017` | `tests/task-utils.ts` (isIncomingTask) | none | | `STORE-R-018` | None found | `getConsultStatus`/`getTaskStatus` untested | diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index f7325c363..04c51fda3 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -12,9 +12,10 @@ import { EntryPointListResponse, EntryPointSearchParams, ConsultTransferAction, - ConsultTransferListSearchParams, - ConsultTransferQueueSearchParams, - ConsultTransferEntryPointSearchParams, + ConsultTransferDestination, + ConsultTransferListResponse, + ConsultTransferListOptions, + ConsultTransferMediaType, AddressBookEntry, AddressBookEntriesResponse, AddressBookEntrySearchParams, @@ -66,8 +67,8 @@ interface IContactCenter { getBuddyAgents(data: BuddyAgents): Promise; getQueues(params?: ContactServiceQueueSearchParams): Promise; getEntryPoints(params?: EntryPointSearchParams): Promise; - getConsultTransferQueues(params?: ConsultTransferQueueSearchParams): Promise; - getConsultTransferEntryPoints(params?: ConsultTransferEntryPointSearchParams): Promise; + getConsultTransferQueues(params?: ConsultTransferListOptions): Promise; + getConsultTransferEntryPoints(params?: ConsultTransferListOptions): Promise; addressBook: AddressBook; agentConfig?: { regexUS: RegExp | string; @@ -224,8 +225,8 @@ interface IStoreWrapper extends IStore { setCurrentTask(task: ITask): void; refreshTaskList(): void; getBuddyAgents(action?: ConsultTransferAction): Promise; - getQueues(params?: ConsultTransferListSearchParams): Promise; - getEntryPoints(params?: ConsultTransferListSearchParams): Promise; + getQueues(params?: ConsultTransferListSearchOptions): Promise; + getEntryPoints(params?: ConsultTransferListSearchOptions): Promise; getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; setDeviceType(option: string): void; setDialNumber(input: string): void; @@ -251,6 +252,8 @@ interface IStoreWrapper extends IStore { clearRealTimeAssist(interactionId: string): void; } +type ConsultTransferListSearchOptions = Omit; + interface IWrapupCode { id: string; name: string; @@ -367,9 +370,10 @@ export type { EntryPointListResponse, EntryPointSearchParams, ConsultTransferAction, - ConsultTransferListSearchParams, - ConsultTransferQueueSearchParams, - ConsultTransferEntryPointSearchParams, + ConsultTransferDestination, + ConsultTransferListResponse, + ConsultTransferListOptions, + ConsultTransferMediaType, AddressBookEntry, AddressBookEntriesResponse, AddressBookEntrySearchParams, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 7aad5d2a6..ef5593dbc 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -15,10 +15,10 @@ import { ENGAGED_USERNAME, RESERVED_LABEL, RESERVED_USERNAME, - ContactServiceQueuesResponse, - EntryPointListResponse, ConsultTransferAction, - ConsultTransferListSearchParams, + ConsultTransferListResponse, + ConsultTransferListOptions, + ConsultTransferMediaType, AddressBookEntriesResponse, AddressBookEntrySearchParams, Profile, @@ -1085,7 +1085,7 @@ class StoreWrapper implements IStoreWrapper { const mediaType = this.currentTask?.data?.interaction?.mediaType; const response = await this.store.cc.getBuddyAgents({ action, - ...(mediaType ? {mediaType} : {}), + ...(mediaType ? {mediaType: mediaType as ConsultTransferMediaType} : {}), }); return 'data' in response ? response.data.agentList : []; } catch (error) { @@ -1094,13 +1094,13 @@ class StoreWrapper implements IStoreWrapper { } }; - getQueues = async (params?: ConsultTransferListSearchParams): Promise => { + getQueues = async (params?: Omit): Promise => { try { const mediaType = this.currentTask?.data?.interaction?.mediaType; return await this.store.cc.getConsultTransferQueues({ ...(params ?? {}), - ...(mediaType ? {mediaType} : {}), + ...(mediaType ? {mediaType: mediaType as ConsultTransferMediaType} : {}), }); } catch (error) { this.store.logger.error('Error fetching queues:', error); @@ -1108,9 +1108,16 @@ class StoreWrapper implements IStoreWrapper { } }; - getEntryPoints = async (params?: ConsultTransferListSearchParams): Promise => { + getEntryPoints = async ( + params?: Omit + ): Promise => { try { - return await this.store.cc.getConsultTransferEntryPoints(params); + const mediaType = this.currentTask?.data?.interaction?.mediaType; + + return await this.store.cc.getConsultTransferEntryPoints({ + ...(params ?? {}), + ...(mediaType ? {mediaType: mediaType as ConsultTransferMediaType} : {}), + }); } catch (error) { this.store.logger.error('Error fetching entry points:', error); throw error; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 39e6d87ed..6494b623f 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1074,8 +1074,8 @@ describe('storeEventsWrapper', () => { it('should return contact service queues list when SDK returns paginated response', async () => { const queueList = [ - {...mockQueueDetails[0], channelType: 'TELEPHONY'}, - {...mockQueueDetails[1], channelType: 'CHAT'}, + {id: mockQueueDetails[0].id, name: mockQueueDetails[0].name, dbId: 'queue-db-1'}, + {id: mockQueueDetails[1].id, name: mockQueueDetails[1].name, dbId: 'queue-db-2'}, ]; const response = {data: queueList, meta: {page: 1, pageSize: 50, totalRecords: 2, totalPages: 1}}; storeWrapper['store'].currentTask = null; @@ -1099,12 +1099,14 @@ describe('storeEventsWrapper', () => { }); it('should fetch entry points successfully', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; storeWrapper['store'].cc.getConsultTransferEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); expect(storeWrapper['store'].cc.getConsultTransferEntryPoints).toHaveBeenCalledWith({ page: 0, pageSize: 25, + mediaType: 'telephony', }); expect(result).toEqual(mockEntryPointsResponse); }); From 7395d5788dafbcefe41da24797dc0549c9148dd1 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Wed, 19 Aug 2026 21:10:44 +0530 Subject: [PATCH 04/10] refactor(contact-center): consume task destination controls --- ai-docs/ARCHITECTURE.md | 2 + ai-docs/CONTRACTS.md | 6 +- ai-docs/GLOSSARY.md | 2 +- ai-docs/SERVICE_STATE.md | 1 - .../spec/feature-spec.md | 11 +- .../ai-docs/cc-components-spec.md | 4 +- .../consult-transfer-popover-hooks.ts | 92 +++---- .../consult-transfer-popover.tsx | 171 +++++------- .../consult-transfer-tab.utils.ts | 51 ---- .../task/CallControl/call-control.tsx | 25 +- .../src/components/task/task.types.ts | 46 +--- ...consult-transfer-popover.snapshot.tsx.snap | 250 +++++++++--------- .../consult-transfer-popover.snapshot.tsx | 13 +- .../consult-transfer-popover.tsx | 48 ++-- .../consult-transfer-tab.utils.ts | 81 ------ .../CallControl/call-control.snapshot.tsx | 1 - .../task/CallControl/call-control.tsx | 1 - .../call-control-cad.snapshot.tsx | 1 - .../task/CallControlCAD/call-control-cad.tsx | 1 - .../store/ai-docs/store-spec.md | 6 +- packages/contact-center/store/src/store.ts | 13 - .../contact-center/store/src/store.types.ts | 14 +- .../store/src/storeEventsWrapper.ts | 16 -- packages/contact-center/store/src/util.ts | 1 - .../store/tests/storeEventsWrapper.ts | 5 - packages/contact-center/store/tests/util.ts | 1 - .../contact-center/task/ai-docs/task-spec.md | 6 +- .../task/src/CallControl/index.tsx | 32 --- .../task/src/CallControlCAD/index.tsx | 32 --- .../ai-docs/test-fixtures-spec.md | 5 + .../src/taskUIControlsFixtures.ts | 17 +- 31 files changed, 329 insertions(+), 626 deletions(-) delete mode 100644 packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts delete mode 100644 packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index ed36076d2..e0d1d6c1a 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -96,6 +96,8 @@ Host ──call──> Widget(observer) ──call──> Hook(helper.ts) ── SDK ──event(CC_EVENTS/TASK_EVENTS)──> Store(runInAction) ──observable change──> Widget(observer) re-render Hook ──call──> cc-components (props) ──call──> ui-logging(withMetrics) ``` +For Consult/Transfer, `Task.uiControls.consultTransferDestinations` is the visibility and ordering source of truth. Task widgets pass the matching action array to `cc-components`; the store does not mirror Desktop Profile collaboration flags, and components do not derive policy from raw task/profile data. + | From | To | Kind | Purpose | |---|---|---|---| | Widget (observer) | Custom hook (`helper.ts`) | call | Read derived state, obtain action callbacks | diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 2661ae5eb..96030fb08 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -23,8 +23,8 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | -| store.types | `@webex/cc-store` | Type re-exports including `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` | TypeScript exports describing the SDK-backed domain surface; specialized queue/entry-point inputs share one minimal list contract and rows are `{id, name, dbId?}` projections | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | -| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer queue and entry-point fetch props | `FetchPaginatedList`; UI consumes projected `id`/`name` while preserving SDK order | stable semver; destination projection tracks store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | +| store.types | `@webex/cc-store` | Type re-exports including `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, `ConsultTransferListResponse`, `ConsultTransferDestinationControls`, and `ConsultTransferDestinationType` | TypeScript exports describing the SDK-backed domain surface; list rows and Task destination controls pass through without widget policy fields | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | +| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action` and `availableDestinations` | `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list and category order and may only apply host hide overrides | stable semver; destination projection and control types track store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | @@ -33,7 +33,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The CC runtime, including specialized consult/transfer list methods and projected response/media types, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | +| `@webex/contact-center` SDK | The CC runtime, including specialized consult/transfer list methods, projected response/media types, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/GLOSSARY.md b/ai-docs/GLOSSARY.md index 500514ba0..b8a19bd02 100644 --- a/ai-docs/GLOSSARY.md +++ b/ai-docs/GLOSSARY.md @@ -27,7 +27,7 @@ | agent state | The agent's current presence/availability, held as `currentState` and changed via the user-state widget through the store. | `packages/contact-center/store/src/store.ts` (`currentState`); `packages/contact-center/user-state/src/helper.ts` | Not "status". | | station login | The agent login flow selecting team and device (dial number / extension / browser); the station-login widget. | `packages/contact-center/station-login/src/station-login/index.tsx` (+ `station-login.types.ts`) | Not "sign-in" in identifiers. | | buddy agents | Other agents available as consult/transfer targets, loaded via `store.getBuddyAgents()` into the task hook as `BuddyDetails[]`. | `packages/contact-center/task/src/helper.ts` (`loadBuddyAgents`, `buddyAgents`) | Not "peers" / "colleagues". | -| queue | A routing destination for tasks; a transfer/consult target type and a task metadata field. | `packages/contact-center/task/src/task.types.ts` (`QUEUE: 'queue'`); store `currentConsultQueueId`, `allowConsultToQueue` in `store/src/store.ts` | Not "skill group". | +| queue | A routing destination for tasks; a transfer/consult target type and a task metadata field. Destination availability is supplied by SDK Task UI controls. | `packages/contact-center/task/src/task.types.ts` (`QUEUE: 'queue'`); `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | Not "skill group". | | entry point | A routing entry destination; a transfer/consult target type for tasks. | `packages/contact-center/task/src/task.types.ts` (`ENTRY_POINT: 'entryPoint'`) | One concept; write `entryPoint` in code. | | wrapup code | A configured post-interaction disposition code applied at task end; modeled as `IWrapupCode`. | `packages/contact-center/store/src/store.ts` (`wrapupCodes: IWrapupCode[]`); surfaced in `CallControlCAD` | Not "disposition" in identifiers. | | r2wc / Web Component | The `@r2wc/react-to-web-component` wrapper that turns each React widget into a framework-agnostic custom element registered via `customElements.define`. | `packages/contact-center/cc-widgets/src/wc.ts` | "r2wc" is the library; the output is a custom element / Web Component. | diff --git a/ai-docs/SERVICE_STATE.md b/ai-docs/SERVICE_STATE.md index d4f1b11d6..862757d52 100644 --- a/ai-docs/SERVICE_STATE.md +++ b/ai-docs/SERVICE_STATE.md @@ -37,7 +37,6 @@ Feature flags are not owned or defaulted by this repo — they are read from the | `isAnalyzerEnabled` | Analyzer-backed features | SDK-provided | Webex CC back end | SDK stops emitting it | | `webRtcEnabled` | WebRTC (browser) device option | SDK-provided | Webex CC back end | SDK stops emitting it | | `isRecordingManagementEnabled` | Recording toggle in CallControl | SDK-provided | Webex CC back end | SDK stops emitting it | -| `allowConsultToQueue` | Consult-to-queue option | SDK-provided | Webex CC back end | SDK stops emitting it | ## Compliance / Certifications - FedRAMP: PR template (`.github/PULL_REQUEST_TEMPLATE.md`) compliance is mandatory and must not be regressed (COMPLETES, Change Type, test scenarios, GAI Policy, Checklist sections). diff --git a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md index 2700d95f0..12eecc1fb 100644 --- a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md @@ -7,7 +7,7 @@ tags: [feature, specification, contact-center, consult-transfer] # Agent Desktop consult and transfer list policy -This document owns the feature's what and why. The paired SDK delta owns the reusable destination-list policy; this repository owns only action context, current-task context, UI loading, and error presentation. +This document owns the feature's what and why. The paired SDK delta owns reusable destination-list policy and the ordered destination availability attached to each Task; this repository owns list loading, selection UI, host hide overrides, and error presentation. Related context: [repository architecture](../../../ARCHITECTURE.md) · [specification index](../../../SPEC_INDEX.md) · [repository instructions](../../../../AGENTS.md) @@ -100,6 +100,7 @@ There are no open product decisions for this delta. | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | | `WIDGET-LIST-R-006` | Store and component types must expose the action-aware loader, shared `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` without `any`; they must not type projected queue/entry-point rows as full CMS records or expose SDK policy flags through widget loaders. | Compile-time alignment gives consumers one minimal list contract, prevents widgets from reading fields omitted by the SDK projection, and avoids loosely typed policy escape hatches. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the new exports. | Present | +| `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags or derive visibility from media/direction/task payload fields; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, and makes the SDK-provided first category the default selection. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Consumers cannot enable a category omitted by the SDK. | Present | ## Defect context (when applicable) @@ -127,14 +128,15 @@ There are no open product decisions for this delta. ### MOD-003 — Call-control action context (`CC-COMPONENTS-R-006`) -- **WHAT**: Initial menu opening and agent-list reload must call the loader with the active menu action. -- **WHY**: A reload must not silently revert Transfer eligibility to Consult eligibility. +- **WHAT**: Initial menu opening and agent-list reload must call the loader with the active menu action. Category visibility, order, and initial selection come from the matching SDK Task destination-control array; widget wrappers no longer build interaction context or forward raw profile access flags. +- **WHY**: A reload must not silently revert Transfer eligibility to Consult eligibility, and UI consumers must not duplicate the SDK's Agent Desktop policy. - **Evidence:** `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl`. -- **Acceptance:** Component tests cover both the initial Transfer load and action-preserving reload. +- **Acceptance:** Component tests cover action-preserving reload, SDK category omission, SDK category order, and host-only hide overrides. ## Acceptance criteria - [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). +- [x] Consult/Transfer category visibility, order, and default selection come from `Task.uiControls`; widgets contain no collaboration-profile/direction policy (`MOD-003`, `WIDGET-LIST-R-007`). - [x] Queue and entry-point fetchers delegate to specialized SDK methods, while dial numbers use AddressBook, without local sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). - [x] Queue and entry-point requests include current-task media when available; dial-number requests contain no widget-selected media, and no list request contains widget-selected sorting or policy flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). @@ -293,3 +295,4 @@ No event contract changes. | 2026-08-19 | Explicitly prohibited widget-side sorting/filtering of SDK destination results. | Preserve the backend order selected by the SDK request. | Developer + Codex | | 2026-08-19 | Adopted the SDK's single minimal consult/transfer options type and forward current-task media for both queue and entry-point requests. | Match Agent Desktop inputs while keeping filter, projection, view, channel mapping, ordering, and cache decisions out of widgets. | Developer + Codex | | 2026-08-19 | Removed widget selection of entry-point media and the consult-specific dial-number helper. | EntryPoint and AddressBook defaults must work for widgets out of the box; other SDK consumers can pass explicit overrides. | Developer + Codex | +| 2026-08-19 | Removed the widget/store destination-policy utility and raw profile/context plumbing; CallControl now renders the matching ordered `Task.uiControls.consultTransferDestinations` array. | Task already contains the live interaction and SDK-computed UI decisions, so no extra policy call or duplicated consumer logic is needed. | Developer + Codex | diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index b40a064a4..86e4431f7 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -126,7 +126,7 @@ Compatibility notes: | `CC-COMPONENTS-R-003` | `StationLoginComponent` invokes the supplied callbacks (`login`, `setDeviceType`, `setDialNumber`, `handleContinue`, `saveLoginOptions`) on the matching user action and surfaces `loginFailure`/`saveError` as error UI. | Login intent and errors must propagate to the widget layer without the component owning login logic. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`calls login function...`, `renders login failure when passed`, `renders save error when passed`) | None | PRESENT | | `CC-COMPONENTS-R-004` | `StationLoginComponent` hides the Desktop login option when `hideDesktopLogin` is true (in both login and profile mode) and shows it when false/undefined. | Deployments can disable Desktop login; must be honored consistently across modes. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`hides Desktop login option when hideDesktopLogin is true`, `... when false`, `... when undefined`, `... in profile mode`) | None | PRESENT | | `CC-COMPONENTS-R-005` | `UserStateComponent` renders idle codes sorted/built into the dropdown, reflects `currentState`/`elapsedTime`, and calls `setAgentStatus(auxCodeId)` on selection; error-triggering idle codes are styled distinctly. | Agent must change state and see correct current state + timing; error idle codes need visual emphasis. | `src/components/UserState/user-state.tsx`, `src/components/UserState/user-state.utils.ts` (`buildDropdownItems`, `sortDropdownItems`, `handleSelectionChange`, `getDropdownClass`) | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-006` | `CallControlComponent` builds its button set from `controlVisibility` and current task, and routes button presses to the matching callback (`toggleHold`, `toggleMute`, `toggleRecording`, `endCall`, `wrapupCall`, consult/transfer/conference handlers); wrapup requires selecting a reason. | Call control must reflect the allowed actions for the current interaction state and emit the right intent. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/call-control.utils.ts` (`buildCallControlButtons`, `filterButtonsForConsultation`, `handleWrapupCall`) | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-006` | `CallControlComponent` builds its button set from Task controls, passes the matching SDK-ordered `consultTransferDestinations` action array into the popover, and routes button presses to the matching callback; wrapup requires selecting a reason. The popover renders destination categories in supplied order and may only remove Dial Number/Entry Point through explicit host hide options. | Call control must reflect SDK decisions without reconstructing profile/media/direction policy, while retaining supported host presentation overrides. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `src/components/task/CallControl/call-control.utils.ts` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | Consumers cannot enable a destination omitted by the SDK. | PRESENT | | `CC-COMPONENTS-R-007` | `CallControlCADComponent` renders the customer/queue/caller header and an agent-viewable CAD global variables panel, and renders the campaign call icon + "Campaign call" label when `isCampaignCall` is true. | CAD/header info and campaign branding must be visible to the agent during a call. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/Task/task.utils.ts` (`getAgentViewableGlobalVariables`) | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | PRESENT | | `CC-COMPONENTS-R-008` | `IncomingTaskComponent` renders the standard `Task` with Answer/Decline when an `incomingTask` is present and renders nothing (hidden) when it is absent; Accept/Decline invoke `accept(task)`/`reject(task)`. | Avoids a stray empty notification when no task; routes accept/decline intent up. | `src/components/task/IncomingTask/incoming-task.tsx`, `src/components/task/IncomingTask/incoming-task.utils.tsx` (`extractIncomingTaskData`) | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | PRESENT | | `CC-COMPONENTS-R-009` | `TaskListComponent` renders nothing when the task list is empty, otherwise renders one row per task; campaign preview tasks render `CampaignTask` (instead of `Task`) only when `hasCampaignPreviewEnabled` (default true) and the task is a campaign preview. | List must collapse when empty and switch row UI for campaign previews per the feature flag. | `src/components/task/TaskList/task-list.tsx`, `src/components/task/TaskList/task-list.utils.ts` (`isTaskListEmpty`, `getTasksArray`, `isCampaignPreviewTask`, `getActiveCampaignPreviewId`) | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | PRESENT | @@ -136,7 +136,7 @@ Compatibility notes: | `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | -| `CC-COMPONENTS-R-016` | Consult/transfer queue and entry-point fetch props must use `ConsultTransferDestination`, retain only the projected `id` and `name` needed by the UI, and preserve fetch order during pagination/reload. | The component must not read queue/entry-point fields omitted by the SDK projection or introduce a second ordering policy. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | `dbId` remains available on the raw projected row but is not needed for current rendering/selection. | PRESENT | +| `CC-COMPONENTS-R-016` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render queue/entry-point/dial-number arrays in received order. | The component must preserve backend-selected order and exact projected rows instead of introducing a second list policy. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | ## Design Overview Every component follows the same shape: a typed function component destructures props, derives display data through pure helpers in a co-located `*.utils.ts(x)`, renders Momentum primitives, and calls back through callback props on user interaction. Local `useState` holds only transient UI (open menus, selected-but-not-yet-submitted values, input text) — never domain state. Top-level components are wrapped in `withMetrics`. This keeps each component unit-testable with plain props and jest mocks and is the reason the archived "presentational pattern" guidance still holds. diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index 8884066f6..58f9b633e 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -5,7 +5,6 @@ import { ILogger, FetchPaginatedList, PaginatedListParams, - TransformPaginatedData, } from '@webex/cc-store'; import { CategoryType, @@ -19,24 +18,21 @@ import {debounce} from './call-control-custom.utils'; import {DEFAULT_PAGE_SIZE} from '../../constants'; /** - * React hook to load, transform and manage paginated data with optional search. + * React hook to load and manage paginated SDK data with optional search. * - * @template T - The item type returned by the provided `fetchFunction` (raw API/entity). - * @template U - The transformed item type stored internally and returned to consumers. + * @template T - The item type returned by the provided `fetchFunction`. * @param fetchFunction - Fetcher that returns a paginated list of items of type T. - * @param transformFunction - Mapper that converts each T into U for UI consumption. * @param categoryName - Human-readable name used for logging/telemetry. * @param logger - Optional logger instance for diagnostics. - * @returns An object containing the transformed data (U[]), pagination state and helpers. + * @returns An object containing SDK response data, pagination state and helpers. */ -export const usePaginatedData = ( +export const usePaginatedData = ( fetchFunction: FetchPaginatedList | undefined, - transformFunction: TransformPaginatedData, categoryName: string, logger?: ILogger ) => { const MODULE = 'cc-components#consult-transfer-popover-hooks.ts'; - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); @@ -83,12 +79,10 @@ export const usePaginatedData = ( method: 'usePaginatedData#loadData', }); - const transformedEntries = response.data.map((entry, index) => transformFunction(entry, currentPage, index)); - if (reset || currentPage === 0) { - setData(transformedEntries); + setData(response.data); } else { - setData((prev) => [...prev, ...transformedEntries]); + setData((prev) => [...prev, ...response.data]); } const newPage = response.meta?.page ?? currentPage; @@ -116,7 +110,7 @@ export const usePaginatedData = ( setLoading(false); } }, - [fetchFunction, transformFunction, logger, categoryName] + [fetchFunction, logger, categoryName] ); const reset = useCallback(() => { @@ -129,14 +123,13 @@ export const usePaginatedData = ( }; export function useConsultTransferPopover({ - showDialNumberTab, - showEntryPointTab, + availableCategories, getAddressBookEntries, getEntryPoints, getQueues, logger, }: UseConsultTransferParams) { - const [selectedCategory, setSelectedCategory] = useState(CATEGORY_AGENTS); + const [selectedCategory, setSelectedCategory] = useState(availableCategories[0] ?? CATEGORY_AGENTS); const [searchQuery, setSearchQuery] = useState(''); const loadMoreRef = useRef(null); @@ -147,20 +140,7 @@ export function useConsultTransferPopover({ loading: loadingDialNumbers, loadData: loadDialNumbers, reset: resetDialNumbers, - } = usePaginatedData( - getAddressBookEntries, - (entry) => ({ - id: entry.id, - name: entry.name, - number: entry.number, - organizationId: entry.organizationId, - version: entry.version, - createdTime: entry.createdTime, - lastUpdatedTime: entry.lastUpdatedTime, - }), - CATEGORY_DIAL_NUMBER, - logger - ); + } = usePaginatedData(getAddressBookEntries, CATEGORY_DIAL_NUMBER, logger); const { data: entryPoints, @@ -169,12 +149,7 @@ export function useConsultTransferPopover({ loading: loadingEntryPoints, loadData: loadEntryPoints, reset: resetEntryPoints, - } = usePaginatedData( - getEntryPoints, - (entry) => ({id: entry.id, name: entry.name}), - CATEGORY_ENTRY_POINT, - logger - ); + } = usePaginatedData(getEntryPoints, CATEGORY_ENTRY_POINT, logger); const { data: queuesData, @@ -183,12 +158,7 @@ export function useConsultTransferPopover({ loading: loadingQueues, loadData: loadQueues, reset: resetQueues, - } = usePaginatedData( - getQueues, - (entry) => ({id: entry.id, name: entry.name}), - CATEGORY_QUEUES, - logger - ); + } = usePaginatedData(getQueues, CATEGORY_QUEUES, logger); const loadNextPage = useCallback(() => { if (!canLoadCategory(selectedCategory)) return; @@ -248,11 +218,12 @@ export function useConsultTransferPopover({ [resetDialNumbers, resetEntryPoints, resetQueues] ); - const createCategoryClickHandler = (category: CategoryType) => () => handleCategoryChange(category); - const handleAgentsClick = createCategoryClickHandler(CATEGORY_AGENTS); - const handleQueuesClick = createCategoryClickHandler(CATEGORY_QUEUES); - const handleDialNumberClick = createCategoryClickHandler(CATEGORY_DIAL_NUMBER); - const handleEntryPointClick = createCategoryClickHandler(CATEGORY_ENTRY_POINT); + useEffect(() => { + const firstAvailableCategory = availableCategories[0]; + if (firstAvailableCategory && !availableCategories.includes(selectedCategory)) { + handleCategoryChange(firstAvailableCategory); + } + }, [availableCategories, handleCategoryChange, selectedCategory]); // Helper: determines if the given category can load next page now const canLoadCategory = (category: CategoryType): boolean => { @@ -305,14 +276,26 @@ export function useConsultTransferPopover({ }, [loadNextPage]); useEffect(() => { - if (selectedCategory === CATEGORY_DIAL_NUMBER && showDialNumberTab && dialNumbers.length === 0) { + if ( + selectedCategory === CATEGORY_DIAL_NUMBER && + availableCategories.includes(CATEGORY_DIAL_NUMBER) && + dialNumbers.length === 0 + ) { loadCategory(CATEGORY_DIAL_NUMBER, 0, '', true); - } else if (selectedCategory === CATEGORY_ENTRY_POINT && showEntryPointTab && entryPoints.length === 0) { + } else if ( + selectedCategory === CATEGORY_ENTRY_POINT && + availableCategories.includes(CATEGORY_ENTRY_POINT) && + entryPoints.length === 0 + ) { loadCategory(CATEGORY_ENTRY_POINT, 0, '', true); - } else if (selectedCategory === CATEGORY_QUEUES && queuesData.length === 0) { + } else if ( + selectedCategory === CATEGORY_QUEUES && + availableCategories.includes(CATEGORY_QUEUES) && + queuesData.length === 0 + ) { loadCategory(CATEGORY_QUEUES, 0, '', true); } - }, [selectedCategory]); + }, [availableCategories, selectedCategory]); const handleReload = useCallback(() => { logger?.info(`CC-Components: Reloading ${selectedCategory} data`, { @@ -336,10 +319,7 @@ export function useConsultTransferPopover({ hasMoreQueues, loadingQueues, handleSearchChange, - handleAgentsClick, - handleQueuesClick, - handleDialNumberClick, - handleEntryPointClick, + handleCategoryChange, handleReload, }; } 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 e9abfd649..f283211bd 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,8 +1,15 @@ -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {Text, ListNext, TextInput, Button, ButtonCircle, TooltipNext} from '@momentum-ui/react-collaboration'; import {Icon, Checkbox, Spinner} from '@momentum-design/components/dist/react'; import ConsultTransferListComponent from './consult-transfer-list-item'; -import {ConsultTransferPopoverComponentProps} from '../../task.types'; +import { + CategoryType, + ConsultTransferPopoverComponentProps, + CATEGORY_AGENTS, + CATEGORY_DIAL_NUMBER, + CATEGORY_ENTRY_POINT, + CATEGORY_QUEUES, +} from '../../task.types'; import ConsultTransferEmptyState from './consult-transfer-empty-state'; import { handleAgentSelection, @@ -11,20 +18,19 @@ import { getAgentsForDisplay, } from './call-control-custom.utils'; import {useConsultTransferPopover} from './consult-transfer-popover-hooks'; -import { - isAgentsTabVisible, - isEntryPointTabVisible, - isQueuesTabVisible, - ConsultTransferAction, -} from './consult-transfer-tab.utils'; - import { SEARCH_PLACEHOLDER, CLEAR_SEARCH, SCROLL_TO_LOAD_MORE, NO_DATA_AVAILABLE_CONSULT_TRANSFER, } from '../../constants'; -import {CATEGORY_AGENTS, CATEGORY_DIAL_NUMBER, CATEGORY_ENTRY_POINT, CATEGORY_QUEUES} from '../../task.types'; + +const DESTINATION_CATEGORY = { + agent: CATEGORY_AGENTS, + queue: CATEGORY_QUEUES, + dialNumber: CATEGORY_DIAL_NUMBER, + entryPoint: CATEGORY_ENTRY_POINT, +} as const; const ConsultTransferPopoverComponent: React.FC = ({ heading, @@ -39,27 +45,25 @@ const ConsultTransferPopoverComponent: React.FC { const {showDialNumberTab = true, showEntryPointTab = true} = consultTransferOptions || {}; - const action: ConsultTransferAction = heading === 'Transfer' ? 'Transfer' : 'Consult'; - const isQueueTabVisible = isQueuesTabVisible( - action, - allowConsultToQueue, - accessQueue, - interactionContext ?? {}, - isTelephony + const availableCategories = useMemo( + () => + availableDestinations + .filter((destination) => showDialNumberTab || destination !== 'dialNumber') + .filter((destination) => showEntryPointTab || destination !== 'entryPoint') + .map((destination) => DESTINATION_CATEGORY[destination]), + [availableDestinations, showDialNumberTab, showEntryPointTab] ); - const isAgentsTabVisibleFlag = isAgentsTabVisible(accessBuddyTeam); - const isEntryPointTabVisibleFlag = isEntryPointTabVisible(showEntryPointTab, accessEntryPoint, isTelephony); + const isAgentsTabVisible = availableCategories.includes(CATEGORY_AGENTS); + const isQueueTabVisible = availableCategories.includes(CATEGORY_QUEUES); + const isDialNumberTabVisible = availableCategories.includes(CATEGORY_DIAL_NUMBER); + const isEntryPointTabVisible = availableCategories.includes(CATEGORY_ENTRY_POINT); const { selectedCategory, searchQuery, @@ -74,14 +78,10 @@ const ConsultTransferPopoverComponent: React.FC ); - const noQueues = !isQueueTabVisible || queuesData.length === 0; - const noDialNumbers = !showDialNumberTab || dialNumbers.length === 0; - const noEntryPoints = !isEntryPointTabVisibleFlag || entryPoints.length === 0; + const noQueues = queuesData.length === 0; + const noDialNumbers = dialNumbers.length === 0; + const noEntryPoints = entryPoints.length === 0; const consultTransferManualAction = shouldAddConsultTransferAction( selectedCategory, - isEntryPointTabVisibleFlag, + isEntryPointTabVisible, allowParticipantsToInteract, searchQuery, entryPoints, @@ -200,58 +200,28 @@ const ConsultTransferPopoverComponent: React.FC

- {isAgentsTabVisibleFlag && ( - - )} - {isQueueTabVisible && ( - - )} - {showDialNumberTab && ( - - )} - {isEntryPointTabVisibleFlag && ( - - )} + {availableCategories.map((category: CategoryType) => { + const isWide = category === CATEGORY_DIAL_NUMBER || category === CATEGORY_ENTRY_POINT; + + return ( + + ); + })}
- {selectedCategory === 'Agents' && + {isAgentsTabVisible && + selectedCategory === CATEGORY_AGENTS && (loadingBuddyAgents ? (
@@ -268,7 +238,8 @@ const ConsultTransferPopoverComponent: React.FC @@ -277,9 +248,8 @@ const ConsultTransferPopoverComponent: React.FC ) : (
- {renderList( - queuesData.map((q) => ({id: q.id, name: q.name})), - (item) => handleQueueSelection(item.id, item.name, allowParticipantsToInteract, onQueueSelect, logger) + {renderList(queuesData, (item) => + handleQueueSelection(item.id, item.name, allowParticipantsToInteract, onQueueSelect, logger) )} {hasMoreQueues && (
@@ -297,7 +267,7 @@ const ConsultTransferPopoverComponent: React.FC ))} - {showDialNumberTab && + {isDialNumberTabVisible && selectedCategory === CATEGORY_DIAL_NUMBER && (loadingDialNumbers && dialNumbers.length === 0 ? (
@@ -307,14 +277,11 @@ const ConsultTransferPopoverComponent: React.FC ) : (
- {renderList( - dialNumbers.map((d) => ({id: d.id, name: d.name, number: d.number})), - (item) => { - if (item.number) { - onDialNumberSelect(item.number, allowParticipantsToInteract); - } + {renderList(dialNumbers, (item) => { + if (item.number) { + onDialNumberSelect(item.number, allowParticipantsToInteract); } - )} + })} {hasMoreDialNumbers && (
{loadingDialNumbers ? ( @@ -331,7 +298,7 @@ const ConsultTransferPopoverComponent: React.FC ))} - {isEntryPointTabVisibleFlag && + {isEntryPointTabVisible && selectedCategory === CATEGORY_ENTRY_POINT && (loadingEntryPoints && entryPoints.length === 0 ? (
@@ -341,12 +308,9 @@ const ConsultTransferPopoverComponent: React.FC ) : (
- {renderList( - entryPoints.map((e) => ({id: e.id, name: e.name})), - (item) => { - onEntryPointSelect(item.id, item.name, allowParticipantsToInteract); - } - )} + {renderList(entryPoints, (item) => { + onEntryPointSelect(item.id, item.name, allowParticipantsToInteract); + })} {hasMoreEntryPoints && (
{loadingEntryPoints ? ( @@ -362,6 +326,7 @@ const ConsultTransferPopoverComponent: React.FC ))} + {availableCategories.length === 0 && }
{isConferenceInProgress && (
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts deleted file mode 100644 index e8949e069..000000000 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -export type ConsultTransferAction = 'Consult' | 'Transfer'; - -export type ConsultTransferInteractionContext = { - contactDirectionType?: string; - outdialTransferToQueueEnabled?: boolean; - mediaType?: string; -}; - -export const isCollaborationAccessEnabled = (access?: string): boolean => access?.toLowerCase() !== 'none'; - -export const isQueueEnabled = ( - action: ConsultTransferAction, - allowConsultToQueue: boolean, - interaction: ConsultTransferInteractionContext, - isTelephony: boolean -): boolean => { - if (!isTelephony) { - return true; - } - - if (action === 'Consult') { - return allowConsultToQueue; - } - - const direction = interaction.contactDirectionType?.toUpperCase(); - if (direction === 'INBOUND') { - return true; - } - if (direction === 'OUTBOUND') { - return interaction.outdialTransferToQueueEnabled === true; - } - - return true; -}; - -export const isAgentsTabVisible = (accessBuddyTeam?: string): boolean => isCollaborationAccessEnabled(accessBuddyTeam); - -export const isQueuesTabVisible = ( - action: ConsultTransferAction, - allowConsultToQueue: boolean, - accessQueue: string | undefined, - interaction: ConsultTransferInteractionContext, - isTelephony: boolean -): boolean => - isCollaborationAccessEnabled(accessQueue) && isQueueEnabled(action, allowConsultToQueue, interaction, isTelephony); - -export const isEntryPointTabVisible = ( - showEntryPointTab: boolean, - accessEntryPoint: string | undefined, - isTelephony: boolean -): boolean => showEntryPointTab && isTelephony && isCollaborationAccessEnabled(accessEntryPoint); diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index d93b9942d..e268777bd 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -57,11 +57,6 @@ function CallControlComponent(props: CallControlComponentProps) { consultTransfer, callControlAudio, setConsultAgentName, - allowConsultToQueue, - accessQueue, - accessEntryPoint, - accessBuddyTeam, - interactionContext, setLastTargetType, controls, logger, @@ -245,21 +240,13 @@ function CallControlComponent(props: CallControlComponentProps) { onDialNumberSelect={(dialNumber, allowParticipantsToInteract) => handleTargetSelect(dialNumber, dialNumber, 'dialNumber', allowParticipantsToInteract) } - allowConsultToQueue={allowConsultToQueue} - accessQueue={accessQueue} - accessEntryPoint={accessEntryPoint} - accessBuddyTeam={accessBuddyTeam} - interactionContext={interactionContext} - isTelephony={isTelephony} - consultTransferOptions={ - isTelephony - ? consultTransferOptions - : { - ...consultTransferOptions, - showDialNumberTab: false, - showEntryPointTab: false, - } + action={button.menuType === 'Transfer' ? 'Transfer' : 'Consult'} + availableDestinations={ + button.menuType === 'Transfer' + ? controls.consultTransferDestinations.transfer + : controls.consultTransferDestinations.consult } + consultTransferOptions={consultTransferOptions} isConferenceInProgress={controls?.main?.exitConference?.isVisible ?? false} logger={logger} /> diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 52ac8ce9f..0e8bac8bb 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -6,7 +6,9 @@ import { BuddyDetails, DestinationType, ContactServiceQueue, + ConsultTransferAction, ConsultTransferDestination, + ConsultTransferDestinationType, AddressBookEntry, FetchPaginatedList, Participant, @@ -463,23 +465,6 @@ export interface ControlProps { */ isEndConsultEnabled: boolean; - /** - * Flag to determine if the consulting to queue is enabled for the agent - */ - allowConsultToQueue: boolean; - - /** Desktop Profile collaboration access for queues */ - accessQueue?: string; - - /** Desktop Profile collaboration access for entry points */ - accessEntryPoint?: string; - - /** Desktop Profile collaboration access for buddy teams */ - accessBuddyTeam?: string; - - /** Interaction context for Consult/Transfer tab visibility */ - interactionContext?: ConsultTransferInteractionContext; - /** * Flag to enable or disable conference feature */ @@ -564,11 +549,6 @@ export type CallControlComponentProps = Pick< | 'stateTimerTimestamp' | 'consultTimerLabel' | 'consultTimerTimestamp' - | 'allowConsultToQueue' - | 'accessQueue' - | 'accessEntryPoint' - | 'accessBuddyTeam' - | 'interactionContext' | 'lastTargetType' | 'setLastTargetType' | 'controls' @@ -681,15 +661,6 @@ export interface ConsultTransferDialNumberComponentProps { logger: ILogger; } -/** - * Interaction fields used for Consult/Transfer tab visibility (Agent Desktop parity). - */ -export type ConsultTransferInteractionContext = { - contactDirectionType?: string; - outdialTransferToQueueEnabled?: boolean; - mediaType?: string; -}; - /** * Interface representing the properties for ConsultTransferPopover component. */ @@ -698,7 +669,7 @@ export interface ConsultTransferPopoverComponentProps { buttonIcon: string; buddyAgents: BuddyDetails[]; loadingBuddyAgents: boolean; - loadBuddyAgents?: (action?: 'Consult' | 'Transfer') => Promise; + loadBuddyAgents?: (action?: ConsultTransferAction) => Promise; getAddressBookEntries?: FetchPaginatedList; getEntryPoints?: FetchPaginatedList; getQueues?: FetchPaginatedList; @@ -706,12 +677,8 @@ export interface ConsultTransferPopoverComponentProps { onQueueSelect: (queueId: string, queueName: string, allowParticipantsToInteract: boolean) => void; onEntryPointSelect: (entryPointId: string, entryPointName: string, allowParticipantsToInteract: boolean) => void; onDialNumberSelect: (dialNumber: string, allowParticipantsToInteract: boolean) => void; - allowConsultToQueue: boolean; - accessQueue?: string; - accessEntryPoint?: string; - accessBuddyTeam?: string; - interactionContext?: ConsultTransferInteractionContext; - isTelephony?: boolean; + action: ConsultTransferAction; + availableDestinations: ConsultTransferDestinationType[]; /** Options governing popover visibility/behavior */ consultTransferOptions?: ConsultTransferOptions; isConferenceInProgress?: boolean; @@ -915,8 +882,7 @@ export const CATEGORY_AGENTS: CategoryType = 'Agents'; * Parameters for `useConsultTransferPopover` hook. */ export type UseConsultTransferParams = { - showDialNumberTab: boolean; - showEntryPointTab: boolean; + availableCategories: CategoryType[]; getAddressBookEntries?: FetchPaginatedList; getEntryPoints?: FetchPaginatedList; getQueues?: FetchPaginatedList; 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 89a01eb88..71df6e221 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 @@ -1090,7 +1090,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
`; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with agents having different states 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render when SDK controls omit queues 1`] = `
@@ -1099,7 +1099,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="h3" type="body-large-bold" > - Select an Agent + Consult
- -
-
- -
-
-
@@ -1363,7 +1270,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="div" type="body-large-regular" > - Busy Agent + Agent One
@@ -1438,7 +1345,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="div" type="body-large-regular" > - Idle Agent + Agent Two
`; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with allowConsultToQueue false for Consult 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with agents having different states 1`] = `
@@ -1483,7 +1390,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="h3" type="body-large-bold" > - Consult + Select an Agent
+
@@ -1654,7 +1579,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="div" type="body-large-regular" > - Agent One + Available Agent
@@ -1729,7 +1654,82 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="div" type="body-large-regular" > - Agent Two + Busy Agent + +
+
+
+ +
+
+ +
+
+
  • +
    + +
    +
    + + Idle Agent
    { container @@ -60,7 +60,8 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { onQueueSelect: mockOnQueueSelect, onDialNumberSelect: jest.fn(), onEntryPointSelect: jest.fn(), - allowConsultToQueue: true, + action: 'Consult' as const, + availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], loadingBuddyAgents: false, logger: mockLogger, }; @@ -143,8 +144,12 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { expect(container).toMatchSnapshot(); }); - it('should render with allowConsultToQueue false for Consult', async () => { - const noQueueConsultProps = {...defaultProps, heading: 'Consult', allowConsultToQueue: false}; + it('should render when SDK controls omit queues', async () => { + const noQueueConsultProps = { + ...defaultProps, + heading: 'Consult', + availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], + }; let screen; await act(async () => { screen = render(); 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 3068f2182..8f766d89c 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 @@ -2,7 +2,12 @@ import React from 'react'; 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 {ConsultTransferDestination, EntryPointRecord, AddressBookEntry} from '@webex/cc-store'; +import { + AddressBookEntry, + ConsultTransferDestination, + ConsultTransferDestinationType, + EntryPointRecord, +} from '@webex/cc-store'; import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; const loggerMock = { @@ -59,7 +64,8 @@ describe('ConsultTransferPopoverComponent', () => { onQueueSelect: mockOnQueueSelect, onDialNumberSelect: jest.fn(), onEntryPointSelect: jest.fn(), - allowConsultToQueue: true, + action: 'Consult' as const, + availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], loadingBuddyAgents: false, logger: loggerMock, }; @@ -223,11 +229,10 @@ describe('ConsultTransferPopoverComponent', () => { expect(screen.container.querySelectorAll('.call-control-list-item').length).toBe(0); }); - it('hides queue tab when allowConsultToQueue is false for Consult', async () => { + it('hides a category omitted by the SDK controls', async () => { const propsWithoutQueue = { ...baseProps, - heading: 'Consult', - allowConsultToQueue: false, + availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], }; const screen = await render(); @@ -235,26 +240,33 @@ describe('ConsultTransferPopoverComponent', () => { expect(maybeQueuesButton).toBeNull(); }); - it('shows queue tab for Transfer inbound when consultToQueue is off and accessQueue is SPECIFIC (AVERA)', async () => { - const averaProps = { + it('renders category tabs in the order supplied by the SDK controls', async () => { + const orderedProps = { ...baseProps, - heading: 'Transfer', - allowConsultToQueue: false, - accessQueue: 'SPECIFIC', - interactionContext: {contactDirectionType: 'INBOUND', mediaType: 'telephony'}, - isTelephony: true, + action: 'Transfer' as const, + availableDestinations: ['queue', 'agent', 'entryPoint', 'dialNumber'] as ConsultTransferDestinationType[], }; - const screen = await render(); - expect(screen.getByRole('button', {name: 'Queues'})).toBeInTheDocument(); + const screen = await render(); + const categoryLabels = Array.from(screen.container.querySelectorAll('.consult-category-buttons button')).map( + (button) => button.textContent + ); + + expect(categoryLabels).toEqual(['Queues', 'Agents', 'Entry Point', 'Dial Number']); + expect(screen.getByRole('button', {name: 'Queues'})).toHaveClass('consult-category-button-active'); + }); + + it('renders an empty state without looping when the SDK exposes no destinations', async () => { + const screen = await render(); + + expect(screen.container.querySelectorAll('.consult-category-buttons button')).toHaveLength(0); + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); }); - it('shows entry point tab on Transfer when accessEntryPoint allows it', async () => { + it('shows entry point when it is included in the SDK controls', async () => { const transferProps = { ...baseProps, - heading: 'Transfer', - accessEntryPoint: 'SPECIFIC', - isTelephony: true, + action: 'Transfer' as const, consultTransferOptions: {showEntryPointTab: true}, }; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts deleted file mode 100644 index dd38f1256..000000000 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - ConsultTransferInteractionContext, - isAgentsTabVisible, - isCollaborationAccessEnabled, - isEntryPointTabVisible, - isQueueEnabled, - isQueuesTabVisible, -} from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-tab.utils'; - -describe('consult-transfer-tab.utils', () => { - const inboundVoice: ConsultTransferInteractionContext = { - contactDirectionType: 'INBOUND', - mediaType: 'telephony', - }; - - const outboundVoiceTransferDisabled: ConsultTransferInteractionContext = { - contactDirectionType: 'OUTBOUND', - outdialTransferToQueueEnabled: false, - mediaType: 'telephony', - }; - - const outboundVoiceTransferEnabled: ConsultTransferInteractionContext = { - contactDirectionType: 'OUTBOUND', - outdialTransferToQueueEnabled: true, - mediaType: 'telephony', - }; - - describe('isCollaborationAccessEnabled', () => { - it('returns false when access is NONE (case-insensitive)', () => { - expect(isCollaborationAccessEnabled('NONE')).toBe(false); - expect(isCollaborationAccessEnabled('none')).toBe(false); - }); - - it('returns true for ALL, SPECIFIC, or undefined', () => { - expect(isCollaborationAccessEnabled('ALL')).toBe(true); - expect(isCollaborationAccessEnabled('SPECIFIC')).toBe(true); - expect(isCollaborationAccessEnabled(undefined)).toBe(true); - }); - }); - - describe('isQueueEnabled', () => { - it('requires allowConsultToQueue for Consult on voice', () => { - expect(isQueueEnabled('Consult', false, inboundVoice, true)).toBe(false); - expect(isQueueEnabled('Consult', true, inboundVoice, true)).toBe(true); - }); - - it('shows queues for Transfer on inbound voice even when consultToQueue is off (AVERA case)', () => { - expect(isQueueEnabled('Transfer', false, inboundVoice, true)).toBe(true); - }); - - it('gates Transfer outbound voice on outdialTransferToQueueEnabled', () => { - expect(isQueueEnabled('Transfer', false, outboundVoiceTransferDisabled, true)).toBe(false); - expect(isQueueEnabled('Transfer', false, outboundVoiceTransferEnabled, true)).toBe(true); - }); - - it('returns true for non-voice media', () => { - expect(isQueueEnabled('Consult', false, {mediaType: 'chat'}, false)).toBe(true); - }); - }); - - describe('tab visibility helpers', () => { - it('hides agents tab when accessBuddyTeam is NONE', () => { - expect(isAgentsTabVisible('NONE')).toBe(false); - expect(isAgentsTabVisible('SPECIFIC')).toBe(true); - }); - - it('shows queue tab for Transfer inbound when accessQueue is SPECIFIC and consultToQueue is off', () => { - expect(isQueuesTabVisible('Transfer', false, 'SPECIFIC', inboundVoice, true)).toBe(true); - }); - - it('hides queue tab when accessQueue is NONE even if queue transfer is otherwise enabled', () => { - expect(isQueuesTabVisible('Transfer', false, 'NONE', inboundVoice, true)).toBe(false); - }); - - it('shows entry point tab on voice when showEntryPointTab and accessEntryPoint allow it', () => { - expect(isEntryPointTabVisible(true, 'SPECIFIC', true)).toBe(true); - expect(isEntryPointTabVisible(true, 'NONE', true)).toBe(false); - expect(isEntryPointTabVisible(true, 'SPECIFIC', false)).toBe(false); - }); - }); -}); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx index 2309338ab..84b960453 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx @@ -110,7 +110,6 @@ describe('CallControlComponent Snapshots', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, - allowConsultToQueue: mockProfile.allowConsultToQueue, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 01272d0ae..7f19addcc 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -93,7 +93,6 @@ describe('CallControlComponent', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, - allowConsultToQueue: true, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx index 6d6fc2d42..570aa9ded 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx @@ -150,7 +150,6 @@ describe('CallControlCADComponent Snapshots', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, - allowConsultToQueue: true, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index dd4888597..2b2662660 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -119,7 +119,6 @@ describe('CallControlCADComponent', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, - allowConsultToQueue: true, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 7417cc6de..0e554c49c 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -111,6 +111,7 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | +| `STORE-R-022` | The store must not mirror or project `allowConsultToQueue`, `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; destination visibility/order is consumed from each SDK Task's `uiControls.consultTransferDestinations`. | Raw profile duplication gives widgets a second policy source and can drift from SDK task/media/direction decisions. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest these profile values internally when computing Task controls. | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. @@ -287,9 +288,9 @@ The store is a single MobX `makeAutoObservable` instance. Observable slices (all - **Session / profile:** `agentId`, `agentProfile`, `isAgentLoggedIn`, `deviceType`, `dialNumber`, `teamId`, `teams`, `loginOptions`, `idleCodes`, `wrapupCodes`, `featureFlags`, `dataCenter`. - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. -- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `accessQueue`, `accessEntryPoint`, `accessBuddyTeam`, `isDigitalChannelsInitialized`. +- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `isDigitalChannelsInitialized`. Destination availability/order remains on the SDK Task's `uiControls` rather than duplicated store observables. - **`getQueues` / `getEntryPoints`:** forward current-task media plus pagination/search and return `ConsultTransferListResponse` from the SDK specialized methods. They do not add eligibility/view/sort flags or reinterpret the projected `id`/`name`/optional-`dbId` rows. -- **`getBuddyAgents`:** returns agents sorted by `agentName` ascending (client-side; full list, no pagination). +- **`getBuddyAgents`:** returns the SDK `agentList` unchanged (full list, no pagination). - **Misc:** `currentTheme`, `cc` (`observable.ref` — not deeply observed), `isAddressBookEnabled`. Transition triggers: SDK CC/task events drive the session/agent/task slices via the wrapper's handlers (`handleStateChange`, `handleTaskAssigned`, `refreshTaskList`, `cleanUpStore`, campaign-preview handlers). Widget-initiated mutators (`setDeviceType`, `setDialNumber`, `setTeamId`, `setState`, `setCurrentTheme`, etc.) drive UI-local slices. All writes pass through `runInAction`. @@ -345,6 +346,7 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | | `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | | `STORE-R-021` | None found | `handleRealtimeTranscription` untested | +| `STORE-R-022` | `tests/storeEventsWrapper.ts` no longer exposes raw collaboration-policy getters | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 6434ec3e5..eef0139e5 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -49,10 +49,6 @@ class Store implements IStore { featureFlags: {[key: string]: boolean} = {}; isEndConsultEnabled: boolean = false; isAddressBookEnabled: boolean = false; - allowConsultToQueue: boolean = false; - accessQueue?: string; - accessEntryPoint?: string; - accessBuddyTeam?: string; agentProfile: AgentLoginProfile = {}; isMuted: boolean = false; isDigitalChannelsInitialized: boolean = false; @@ -120,15 +116,6 @@ class Store implements IStore { this.isEndConsultEnabled = response.isEndConsultEnabled; // TODO: Remove this once SDK performs the validation this.isAddressBookEnabled = Boolean(response.addressBookId); - this.allowConsultToQueue = response.allowConsultToQueue; - const collaborationProfile = response as Profile & { - accessQueue?: string; - accessEntryPoint?: string; - accessBuddyTeam?: string; - }; - this.accessQueue = collaborationProfile.accessQueue; - this.accessEntryPoint = collaborationProfile.accessEntryPoint; - this.accessBuddyTeam = collaborationProfile.accessBuddyTeam; this.agentProfile.agentName = response.agentName; this.agentProfile.isTimeoutDesktopInactivityEnabled = response.isTimeoutDesktopInactivityEnabled; this.agentProfile.timeoutDesktopInactivityMins = response.timeoutDesktopInactivityMins; diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 04c51fda3..c898047d5 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -12,6 +12,8 @@ import { EntryPointListResponse, EntryPointSearchParams, ConsultTransferAction, + ConsultTransferDestinationControls, + ConsultTransferDestinationType, ConsultTransferDestination, ConsultTransferListResponse, ConsultTransferListOptions, @@ -203,10 +205,6 @@ interface IStore { consultStartTimeStamp?: number; callControlAudio: MediaStream | null; isEndConsultEnabled: boolean; - allowConsultToQueue: boolean; - accessQueue?: string; - accessEntryPoint?: string; - accessBuddyTeam?: string; agentProfile: AgentLoginProfile; isMuted: boolean; isAddressBookEnabled: boolean; @@ -314,14 +312,11 @@ type PaginatedListParams = { search?: string; }; -// Generic fetch/transform helpers for paginated APIs +// Generic fetch helper for paginated APIs type FetchPaginatedList = ( params: PaginatedListParams ) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; -// Generic transform function for paginated APIs -type TransformPaginatedData = (item: T, page: number, index: number) => U; - // Utility consts const DIAL_NUMBER: string = 'AGENT_DN'; const EXTENSION: string = 'EXTENSION'; @@ -370,6 +365,8 @@ export type { EntryPointListResponse, EntryPointSearchParams, ConsultTransferAction, + ConsultTransferDestinationControls, + ConsultTransferDestinationType, ConsultTransferDestination, ConsultTransferListResponse, ConsultTransferListOptions, @@ -382,7 +379,6 @@ export type { IWebex, PaginatedListParams, FetchPaginatedList, - TransformPaginatedData, TaskUIControls, TaskUIControlState, InteractionUIControls, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index ef5593dbc..1dad7da2a 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -182,22 +182,6 @@ class StoreWrapper implements IStoreWrapper { return this.store.isEndConsultEnabled; } - get allowConsultToQueue() { - return this.store.allowConsultToQueue; - } - - get accessQueue() { - return this.store.accessQueue; - } - - get accessEntryPoint() { - return this.store.accessEntryPoint; - } - - get accessBuddyTeam() { - return this.store.accessBuddyTeam; - } - get agentProfile() { return this.store.agentProfile; } diff --git a/packages/contact-center/store/src/util.ts b/packages/contact-center/store/src/util.ts index a5765a687..9e5d1d6bb 100644 --- a/packages/contact-center/store/src/util.ts +++ b/packages/contact-center/store/src/util.ts @@ -31,7 +31,6 @@ export function getFeatureFlags(agentProfile: Profile) { 'isAnalyzerEnabled', 'webRtcEnabled', 'isRecordingManagementEnabled', - 'allowConsultToQueue', ]; const keyValuePairs = featureFlagkeys.reduce((acc, key) => { diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 6494b623f..89efd332d 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -104,7 +104,6 @@ jest.mock('../src/store', () => ({ isQueueConsultInProgress: false, currentConsultQueueId: null, isEndConsultEnabled: true, - allowConsultToQueue: false, isDeclineButtonEnabled: false, isDigitalChannelsInitialized: false, acceptedCampaignIds: new Set(), @@ -265,10 +264,6 @@ describe('storeEventsWrapper', () => { expect(storeWrapper.isEndConsultEnabled).toBe(storeWrapper['store'].isEndConsultEnabled); }); - it('should proxy allowConsultToQueue', () => { - expect(storeWrapper.allowConsultToQueue).toBe(storeWrapper['store'].allowConsultToQueue); - }); - it('should proxy isDeclineButtonEnabled', () => { expect(storeWrapper.isDeclineButtonEnabled).toBe(false); }); diff --git a/packages/contact-center/store/tests/util.ts b/packages/contact-center/store/tests/util.ts index b25d9804a..e01a67f69 100644 --- a/packages/contact-center/store/tests/util.ts +++ b/packages/contact-center/store/tests/util.ts @@ -8,7 +8,6 @@ describe('getFeatureFlags', () => { isCampaignManagementEnabled: true, agentPersonalStatsEnabled: true, webRtcEnabled: true, - allowConsultToQueue: true, isEndConsultEnabled: true, isOutboundEnabledForAgent: false, isOutboundEnabledForTenant: false, diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index f70160edb..445b1f227 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -88,7 +88,7 @@ Compatibility notes: - `conferenceEnabled` is normalized to `true` when undefined inside the `CallControl`/`CallControlCAD` wrappers; consumers relying on `undefined` getting `false` would break. ## Requires (dependencies) -- `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask`, `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names: `packages/contact-center/store/src/store.types.ts`. +- `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask` (including SDK `TaskUIControls`), `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names and destination-control types: `packages/contact-center/store/src/store.types.ts`. - `@webex/cc-components` (internal): presentational components (`IncomingTaskComponent`, `TaskListComponent`, `CallControlComponent`, `CallControlCADComponent`, `OutdialCallComponent`, `RealTimeTranscriptComponent`) and types (`ControlProps`, `TaskProps`, `OutdialCallProps`, `Visibility`, `ControlVisibility`, `RealTimeTranscriptComponentProps`, `CampaignCallProcessingDetails`). - `@webex/contact-center` (SDK, transitive via store): the `ITask` interface and methods invoked here (`accept`, `decline`, `hold`, `resume`, `end`, `wrapup`, `cancelAutoWrapupTimer`, `pauseRecording`, `resumeRecording`, `toggleMute`, `transfer`, `consult`, `endConsult`, `consultTransfer`, `consultConference`, `transferConference`, `exitConference`), `cc.startOutdial`, `cc.getOutdialAniEntries`, `cc.addressBook.getEntries`, `cc.agentConfig`. - `react` ^18, `mobx-react-lite`, `react-error-boundary`. @@ -120,6 +120,7 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | +| `TASK-R-024` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated Agent Desktop policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. @@ -392,7 +393,7 @@ The six widget shells are siblings that each bind to exactly one hook and one pr - **UC-3 Hold / resume active call (CallControl):** Agent clicks Hold → `toggleHold(true)` → `currentTask.hold()` → `TASK_HOLD` → hold timer starts via `useHoldTimer`, `onHoldResume({isHeld:true})`. Evidence: `src/helper.ts`, `src/Utils/useHoldTimer.ts`, `tests/helper.ts` (hold/resume cases). UI flow: Hold button toggles to Resume; "Hold" elapsed timer shown. - **UC-4 Toggle recording (CallControl):** Agent clicks record toggle → `toggleRecording()` → `pauseRecording()`/`resumeRecording()` → `TASK_RECORDING_PAUSED/RESUMED` → `isRecording` flips, `onRecordingToggle`. Evidence: `src/helper.ts`, `tests/helper.ts` (recording cases). - **UC-5 Mute / unmute (CallControl):** Agent clicks mute → `toggleMute()` (gated by `controlVisibility.muteUnmute`) → `currentTask.toggleMute()` → `store.setIsMuted`, `onToggleMute`. Evidence: `src/helper.ts`, `tests/helper.ts` ("should successfully toggle mute…", "rapid toggleMute"). UI flow: shows error-safe revert on failure. -- **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Consult/Transfer popover tab visibility follows Agent Desktop: `accessQueue`/`accessEntryPoint`/`accessBuddyTeam` from store plus interaction direction (`contactDirection`, `outdialTransferToQueueEnabled`) for queue tab gating. Evidence: `src/helper.ts`, `packages/contact-center/cc-components/.../consult-transfer-tab.utils.ts`, `task/src/CallControl/index.tsx`. +- **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → component reads the matching ordered category array from `currentTask.uiControls.consultTransferDestinations` → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Evidence: `src/helper.ts`, `src/CallControl/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`. - **UC-7 Blind transfer (CallControl):** Agent picks destination → `transferCall(to,type)` → `currentTask.transfer(...)`; failure re-thrown to the modal. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call transferCall successfully"). - **UC-8 Wrap up a call (CallControl):** Call ends → wrap-up codes shown → Agent selects code → `wrapupCall(reason, auxCodeId)` → `currentTask.wrapup` → next task promoted to `currentTask`, agent → ENGAGED. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call wrapupCall"). Auto-wrap-up: countdown shown with Cancel. UI flow: wrap-up dropdown + optional auto-wrap-up countdown. - **UC-9 Place an outbound call (OutdialCall):** Agent enters number, selects ANI → dial → `startOutdial(destination, origin)`; empty number alerts and aborts; disabled while a telephony task is active. Evidence: `src/helper.ts` (`useOutdialCall`), `tests/OutdialCall/index.tsx`. UI flow: dialpad with validation, ANI dropdown, address book toggled by `isAddressBookEnabled`. @@ -504,6 +505,7 @@ Tests are split between widget-shell render tests (each `tests//index.ts | `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | | `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | | `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | +| `TASK-R-024` SDK destination-control pass-through | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`; cc-components focused destination tests | None | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index c9ef6870a..e62eb104f 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -7,29 +7,6 @@ import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlComponent} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; -import {ITask} from '@webex/contact-center'; - -type ConsultTransferInteractionContext = { - contactDirectionType?: string; - outdialTransferToQueueEnabled?: boolean; - mediaType?: string; -}; - -const buildConsultTransferInteractionContext = (currentTask?: ITask): ConsultTransferInteractionContext => { - const interaction = currentTask?.data?.interaction as - | { - contactDirection?: {type?: string}; - outdialTransferToQueueEnabled?: boolean; - mediaType?: string; - } - | undefined; - - return { - contactDirectionType: interaction?.contactDirection?.type, - outdialTransferToQueueEnabled: interaction?.outdialTransferToQueueEnabled, - mediaType: interaction?.mediaType, - }; -}; const CallControlInternal: React.FunctionComponent = observer( ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute, consultTransferOptions, conferenceEnabled}) => { @@ -39,10 +16,6 @@ const CallControlInternal: React.FunctionComponent = observer( wrapupCodes, consultStartTimeStamp, callControlAudio, - allowConsultToQueue, - accessQueue, - accessEntryPoint, - accessBuddyTeam, isMuted, agentId, acceptedCampaignIds, @@ -73,11 +46,6 @@ const CallControlInternal: React.FunctionComponent = observer( wrapupCodes, consultStartTimeStamp, callControlAudio, - allowConsultToQueue, - accessQueue, - accessEntryPoint, - accessBuddyTeam, - interactionContext: buildConsultTransferInteractionContext(currentTask), logger, consultTransferOptions, }; diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index ab27c18a4..e59026d3c 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -7,29 +7,6 @@ import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlCADComponent} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; -import {ITask} from '@webex/contact-center'; - -type ConsultTransferInteractionContext = { - contactDirectionType?: string; - outdialTransferToQueueEnabled?: boolean; - mediaType?: string; -}; - -const buildConsultTransferInteractionContext = (currentTask?: ITask): ConsultTransferInteractionContext => { - const interaction = currentTask?.data?.interaction as - | { - contactDirection?: {type?: string}; - outdialTransferToQueueEnabled?: boolean; - mediaType?: string; - } - | undefined; - - return { - contactDirectionType: interaction?.contactDirection?.type, - outdialTransferToQueueEnabled: interaction?.outdialTransferToQueueEnabled, - mediaType: interaction?.mediaType, - }; -}; const CallControlCADInternal: React.FunctionComponent = observer( ({ @@ -49,10 +26,6 @@ const CallControlCADInternal: React.FunctionComponent = observ wrapupCodes, consultStartTimeStamp, callControlAudio, - allowConsultToQueue, - accessQueue, - accessEntryPoint, - accessBuddyTeam, isMuted, agentId, acceptedCampaignIds, @@ -82,11 +55,6 @@ const CallControlCADInternal: React.FunctionComponent = observ callControlAudio, callControlClassName, callControlConsultClassName, - allowConsultToQueue, - accessQueue, - accessEntryPoint, - accessBuddyTeam, - interactionContext: buildConsultTransferInteractionContext(currentTask), logger, consultTransferOptions, }; diff --git a/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md b/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md index 4a4efdbb5..47c12808a 100644 --- a/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md +++ b/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md @@ -47,6 +47,7 @@ test-fixtures/src/ ├── fixtures.ts # Core SDK-shaped mocks: mockCC, mockProfile, mockTask, queues, agents, address book, campaign tasks ├── incomingTaskFixtures.ts # mockIncomingTaskData — incoming-task UI data by channel scenario ├── taskListFixtures.ts # mockTaskData — task-list UI data by scenario (active/incoming/action/selection) +├── taskUIControlsFixtures.ts # TaskUIControls factories with per-leg and destination overrides └── components/task/ └── outdialCallFixtures.ts # Outdial mocks composed from mockCC: mockOutdialCallProps, mockAniEntries, mockCCWithAni ``` @@ -59,6 +60,7 @@ test-fixtures/src/ | `packages/contact-center/test-fixtures/src/fixtures.ts` | Core fixture values and their type annotations (`IContactCenter`, `Profile`, `ITask`, etc.). Never re-infer these shapes elsewhere. | | `packages/contact-center/test-fixtures/src/incomingTaskFixtures.ts` | `mockIncomingTaskData` and its `MEDIA_CHANNEL` source import. | | `packages/contact-center/test-fixtures/src/taskListFixtures.ts` | `mockTaskData` and its `MEDIA_CHANNEL` source import. | +| `packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts` | Typed `TaskUIControls` factories, including consult/transfer destination-control overrides. | | `packages/contact-center/test-fixtures/src/components/task/outdialCallFixtures.ts` | Outdial fixtures derived from `mockCC`. | | `packages/contact-center/test-fixtures/package.json` | Dependency list and the `deploy:npm` no-op. | @@ -82,6 +84,7 @@ Internal Surface — consumed only by other packages' Jest tests in this monorep | `test-fixtures.mockCallAssociatedData` | data export | `mockCallAssociatedData` | Call-associated-data variants (global, viewable/hidden, secure) | Additive keys safe | `src/fixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockIncomingTaskData` | data export | `mockIncomingTaskData` | Incoming-task UI data keyed `webRTC`/`extension`/`social`/`chat` | Additive scenario keys safe | `src/incomingTaskFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockTaskData` | data export | `mockTaskData` | Task-list UI data keyed `active`/`incoming`/`action`/`selection` | Additive scenario keys safe | `src/taskListFixtures.ts` | internal (`src/index.ts`) | +| `test-fixtures.createMockTaskUIControls` | factory export | `createMockTaskUIControls(overrides?): TaskUIControls` | Produces SDK-shaped task controls with independent main/consult/active-leg and ordered consult/transfer destination overrides | Track `TaskUIControls`; additive override fields are safe | `src/taskUIControlsFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockOutdialCallProps` | data export | `mockOutdialCallProps` | `mockCC` spread + `startOutdial`/`getOutdialANIEntries` jest mocks | Spread of `mockCC` | `src/components/task/outdialCallFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockAniEntries` | data export | `mockAniEntries` | Outdial ANI entry list | Additive fields safe | `src/components/task/outdialCallFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockCCWithAni` | data export | `mockCCWithAni` | `mockCC` + `agentConfig.outdialANIId` + ANI-resolving `getOutdialAniEntries` | Spread of `mockCC` | `src/components/task/outdialCallFixtures.ts` | internal (`src/index.ts`) | @@ -108,6 +111,7 @@ Compatibility notes: | `test-fixtures-R-005` | `mockIncomingTaskData` and `mockTaskData` expose UI-data variants keyed by scenario (incoming: `webRTC`/`extension`/`social`/`chat`; list: `active`/`incoming`/`action`/`selection`) using the shared `MEDIA_CHANNEL` enum. | Task widget tests render against named, channel-correct scenarios rather than ad-hoc literals. | `src/incomingTaskFixtures.ts`, `src/taskListFixtures.ts` | none found | none | PRESENT | | `test-fixtures-R-006` | Outdial fixtures (`mockOutdialCallProps`, `mockCCWithAni`) are composed by spreading `mockCC` and adding outdial-specific jest mocks/config, keeping a single source of SDK shape. | Avoids a divergent second SDK mock; outdial tests inherit the canonical `mockCC`. | `src/components/task/outdialCallFixtures.ts` | none found | none | PRESENT | | `test-fixtures-R-007` | Every fixture and factory is re-exported through the barrel `src/index.ts`; non-barrelled internals (`mockAddressBook`, `mockQueuesResponse`) are not part of the public surface. | Consumers import from the package root; the barrel is the stability boundary. | `src/index.ts`, `src/fixtures.ts` (export list) | none found | none | PRESENT | +| `test-fixtures-R-008` | `createMockTaskUIControls` starts from the SDK defaults, applies independent per-leg and active-leg overrides, and preserves or overrides the ordered `consultTransferDestinations.consult` and `.transfer` arrays. | Component tests must represent the same task-owned destination visibility and ordering contract used at runtime without rebuilding that policy in test code. | `src/taskUIControlsFixtures.ts` | Consumed by call-control tests in `packages/contact-center/cc-components/tests` | No package-local unit test; type and shape are checked by package builds and consumers. | PRESENT | Do not record raw data/schema inventory as requirements. The per-field contents of each mock are descriptive data in `src/`, not behavioral requirements. @@ -221,6 +225,7 @@ The package ships no tests of its own (no `tests/` directory; confirmed by tree) | `test-fixtures-R-005` (scenario UI data) | None found | Consumed indirectly by task widget tests only | | `test-fixtures-R-006` (outdial composition) | None found | Consumed by outdial widget tests only | | `test-fixtures-R-007` (barrel surface) | None found | No test guarding the public export list | +| `test-fixtures-R-008` (task UI controls) | Call-control consumer tests; `@webex/test-fixtures` and `@webex/cc-components` builds | No package-local assertion for destination override merging | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) diff --git a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts index b3c6001da..ffd16a66b 100644 --- a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts +++ b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts @@ -1,4 +1,10 @@ -import {getDefaultUIControls, InteractionUIControls, TaskUIControls, TaskUILeg} from '@webex/cc-store'; +import { + ConsultTransferDestinationControls, + getDefaultUIControls, + InteractionUIControls, + TaskUIControls, + TaskUILeg, +} from '@webex/cc-store'; const disabledControl = {isVisible: false, isEnabled: false}; const enabledControl = {isVisible: true, isEnabled: true}; @@ -10,12 +16,21 @@ export function createMockTaskUIControls(overrides?: { main?: Partial; consult?: Partial; activeLeg?: TaskUILeg; + consultTransferDestinations?: Partial; }): TaskUIControls { const base = getDefaultUIControls(); return { activeLeg: overrides?.activeLeg ?? base.activeLeg, main: {...base.main, ...overrides?.main}, consult: {...base.consult, ...overrides?.consult}, + consultTransferDestinations: { + consult: + overrides?.consultTransferDestinations?.consult ?? + base.consultTransferDestinations.consult, + transfer: + overrides?.consultTransferDestinations?.transfer ?? + base.consultTransferDestinations.transfer, + }, }; } From 3cc7a18f13f9275492b21b84337025558bd685c4 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Wed, 19 Aug 2026 22:34:13 +0530 Subject: [PATCH 05/10] refactor(contact-center): consume existing destination list APIs --- ai-docs/CONTRACTS.md | 6 +- .../spec/feature-spec.md | 65 ++++++++++--------- .../ai-docs/cc-components-spec.md | 6 +- .../consult-transfer-popover-hooks.ts | 7 +- .../consult-transfer-popover.tsx | 8 ++- .../src/components/task/task.types.ts | 14 ++-- .../consult-transfer-popover.snapshot.tsx | 7 +- .../consult-transfer-popover.tsx | 25 +++---- .../task/CallControl/call-control.tsx | 16 ++++- .../store/ai-docs/store-spec.md | 14 ++-- .../contact-center/store/src/store.types.ts | 14 +--- .../store/src/storeEventsWrapper.ts | 40 +++++++++--- .../store/tests/storeEventsWrapper.ts | 53 +++++++++++---- .../test-fixtures/src/fixtures.ts | 2 - .../src/taskUIControlsFixtures.ts | 8 +-- 15 files changed, 161 insertions(+), 124 deletions(-) diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 96030fb08..51ec4c94c 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -23,8 +23,8 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | -| store.types | `@webex/cc-store` | Type re-exports including `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, `ConsultTransferListResponse`, `ConsultTransferDestinationControls`, and `ConsultTransferDestinationType` | TypeScript exports describing the SDK-backed domain surface; list rows and Task destination controls pass through without widget policy fields | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | -| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action` and `availableDestinations` | `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list and category order and may only apply host hide overrides | stable semver; destination projection and control types track store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | +| store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `ConsultTransferMediaType`, `ConsultTransferDestinationControls`, and `ConsultTransferDestinationType` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | +| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action` and `availableDestinations` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list and category order and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

    (Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | @@ -33,7 +33,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The CC runtime, including specialized consult/transfer list methods, projected response/media types, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | +| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established full-record list types, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md index 12eecc1fb..0c5127079 100644 --- a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md @@ -46,7 +46,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi The widgets previously embedded destination policy in the store: buddy agents were always restricted to Available, queues were fetched through the generic SDK API and filtered in memory by channel, and list response metadata was reconstructed. That made widget behavior diverge from Agent Desktop and made list order dependent on widget-side transformation. -The goal is for widgets to supply only the user's action, pagination/search input, and the current task media needed by the queue policy. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not choose ordering, entry-point media defaults, reusable eligibility, or pagination semantics. +The goal is for widgets to use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods. Widgets supply the user's action and pagination/search input; the store uses current-task media only to override the SDK's telephony default for an active non-telephony task. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not transform returned rows, choose ordering, or reconstruct pagination semantics. ## Stakeholders and open questions @@ -54,7 +54,7 @@ The goal is for widgets to supply only the user's action, pagination/search inpu | --- | --- | --- | | Contact Center agents | Consult and transfer destination lists match Agent Desktop eligibility and order. | Decided | | Widget maintainers | Destination business policy remains outside React and MobX UI code. | Decided | -| SDK maintainers | SDK services own list ordering defaults and specialized methods own consult/transfer eligibility. | Decided in the paired SDK delta | +| SDK maintainers | Existing SDK services own default list eligibility, profile views, and ordering; no consult/transfer-specific list method or response abstraction is added. | Decided in the paired SDK delta | | Host applications | Existing widget UI behavior remains compatible apart from the corrected destination results. | Decided | There are no open product decisions for this delta. @@ -64,7 +64,7 @@ There are no open product decisions for this delta. ### In scope - Pass `Consult` or `Transfer` from the call-control menu and reload action to the SDK through the task hook and store. -- Forward only pagination, page size, and search text for entry-point and dial-number lists; forward current-task media only for queues, where it selects the task channel. +- Forward pagination, page size, and search text through the existing list APIs. For an active non-telephony task, the store supplies a complete channel eligibility filter through the existing `filter` parameter because the SDK list methods do not receive Task context. - Load dial numbers through the generic SDK AddressBook service and rely on its default backend ordering. - Preserve the SDK's `data` order and pagination metadata without local sorting, channel filtering, or metadata reconstruction. - Keep loading, empty, and error behavior in the existing widget layers. @@ -82,7 +82,7 @@ There are no open product decisions for this delta. | Source | What it establishes | Decision or disposition | | --- | --- | --- | -| `packages/contact-center/store/src/storeEventsWrapper.ts` | The old store filtered queue data and rebuilt pagination metadata; the new store delegates to specialized SDK methods. | Used | +| `packages/contact-center/store/src/storeEventsWrapper.ts` | The old store filtered queue data and rebuilt pagination metadata; the new store delegates to the existing SDK methods and only supplies active non-telephony task context as an existing request filter. | Used | | `packages/contact-center/task/src/helper.ts` | The task hook owns UI loading/error state and forwards list input. | Used | | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | The selected menu identifies Consult versus Transfer. | Used | | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Reload has the same action context as initial list loading. | Used | @@ -94,12 +94,12 @@ There are no open product decisions for this delta. | ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence | | --- | --- | --- | --- | --- | --- | --- | -| `WIDGET-LIST-R-001` | The store must use the SDK's specialized consult/transfer queue and entry-point methods, use the generic SDK AddressBook service for dial numbers, and must not apply local eligibility filters, sorting, or pagination reconstruction. | SDK-owned defaults prevent drift while avoiding a redundant consult-specific dial-number API. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK delta at runtime. | Present | +| `WIDGET-LIST-R-001` | The store must use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and the existing AddressBook service for dial numbers. It must not filter or sort returned rows or reconstruct pagination metadata. | Reusing established methods and response types keeps the public surface small while SDK-owned defaults prevent policy drift. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK default-policy delta at runtime. | Present | | `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. | Agent eligibility differs by action, so losing the action would silently return the wrong population. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts` | None. | Present | -| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, search, and current-task media; dial-number requests forward only pagination/search. | Agent Desktop supplies interaction media for both queues and entry points, while channel mapping, eligibility, and all list-order defaults remain SDK decisions. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | When task media is absent, the SDK telephony default applies. | Present | +| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter through the existing `filter` option; for telephony or missing media it must omit the override and use SDK defaults. Dial-number requests forward only pagination/search. | The SDK's generic list methods have no Task argument and cannot infer which concurrent task is being rendered, while the store already owns current-task context. Limiting widget logic to this request override keeps reusable defaults and returned-data decisions in the SDK. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or public request signature is required. | Present | | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | -| `WIDGET-LIST-R-006` | Store and component types must expose the action-aware loader, shared `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` without `any`; they must not type projected queue/entry-point rows as full CMS records or expose SDK policy flags through widget loaders. | Compile-time alignment gives consumers one minimal list contract, prevents widgets from reading fields omitted by the SDK projection, and avoids loosely typed policy escape hatches. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the new exports. | Present | +| `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off consult/transfer destination, list-options, or list-response type may be introduced. | Consumers only need the existing lists. Reusing the established full-record contracts avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior and typed `dbId` additions. | Present | | `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags or derive visibility from media/direction/task payload fields; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, and makes the SDK-provided first category the default selection. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Consumers cannot enable a category omitted by the SDK. | Present | ## Defect context (when applicable) @@ -114,10 +114,10 @@ There are no open product decisions for this delta. ### MOD-001 — Store list delegation (`STORE-R-015`) -- **WHAT**: Replace widget-owned queue filtering/metadata reconstruction and generic entry-point calls with thin delegation to the SDK's consult/transfer queue/entry-point methods. Keep dial numbers on the generic AddressBook service. Pass current-task media to both queues and entry points, pass no ordering or policy inputs, and preserve each SDK response as returned. +- **WHAT**: Replace widget-owned returned-row filtering and metadata reconstruction with delegation to the SDK's existing queue and entry-point methods. Keep dial numbers on the existing AddressBook service. Pass only pagination/search plus a complete non-telephony channel filter when the active Task requires an override, and preserve each SDK response as returned. - **WHY**: Eligibility, backend query flags, and ordering defaults are reusable domain policy and must not diverge across UI clients. - **Evidence:** `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. -- **Acceptance:** No queue, entry-point, or dial-number `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. +- **Acceptance:** No queue, entry-point, or dial-number returned-data `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. Telephony requests rely on SDK defaults; non-telephony requests use only the existing `filter` option. ### MOD-002 — Task consult/transfer orchestration (`TASK-R-011` through `TASK-R-014`) @@ -137,12 +137,12 @@ There are no open product decisions for this delta. - [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). - [x] Consult/Transfer category visibility, order, and default selection come from `Task.uiControls`; widgets contain no collaboration-profile/direction policy (`MOD-003`, `WIDGET-LIST-R-007`). -- [x] Queue and entry-point fetchers delegate to specialized SDK methods, while dial numbers use AddressBook, without local sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). -- [x] Queue and entry-point requests include current-task media when available; dial-number requests contain no widget-selected media, and no list request contains widget-selected sorting or policy flags (`MOD-001`, `WIDGET-LIST-R-003`). +- [x] Queue and entry-point fetchers delegate to existing SDK methods, while dial numbers use AddressBook, without local returned-data sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). +- [x] Telephony queue and entry-point requests rely on SDK defaults; active non-telephony requests supply a complete channel filter through the existing request parameter. No list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). - [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). - [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with the coordinated SDK worktree. -- [ ] The complete cc-components unit suite is blocked in the local-link setup by the SDK calling package's `uuid` ESM/Jest incompatibility; the changed consult/transfer suites pass independently. +- [x] The complete cc-components unit suite and the focused consult/transfer suites pass with the locally linked SDK. ## Scenarios and applicable change views @@ -150,8 +150,8 @@ There are no open product decisions for this delta. | --- | --- | --- | --- | --- | --- | | Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged. | SDK failure produces an empty agent list and clears loading. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005` | | Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; it does not fall back to Consult. | `WIDGET-LIST-R-002` | -| Search Queues | Agent | Active task with media context | Page/search input plus media reach the SDK; response and metadata are preserved. | Missing task media lets the SDK default to telephony. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Search Entry Points | Agent | Entry-point tab visible and active task may carry media | Page/search plus available task media reach the SDK and backend order is rendered. | Missing media uses the SDK default; failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | +| Search Queues | Agent | Active task with media context | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and the response and metadata are preserved. | Telephony or missing media lets SDK defaults apply. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Search Entry Points | Agent | Entry-point tab visible and active task may carry media | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and backend order is rendered. | Telephony or missing media uses SDK defaults; failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | | Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook and its SDK-default backend order is rendered. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | ### Interaction and scenario matrix @@ -160,9 +160,9 @@ There are no open product decisions for this delta. | --- | --- | --- | --- | --- | | Consult + Agents | Open or reload | `loadBuddyAgents('Consult')` | Applying Transfer-only availability filtering | `WIDGET-LIST-R-002` | | Transfer + Agents | Open or reload | `loadBuddyAgents('Transfer')` | Reloading with the default Consult action | `WIDGET-LIST-R-002` | -| Queue + current task media | Fetch/search/page | Store forwards media once to the specialized SDK call | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| No current task media | Queue or entry-point fetch | SDK receives no media override and uses its default | Widget invents a backend channel policy | `WIDGET-LIST-R-003` | -| Entry point + current task media | Fetch/search/page | Store forwards media once to the specialized SDK call and preserves SDK order | Widget maps the channel or supplies sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Queue + non-telephony current task media | Fetch/search/page | Store calls existing `getQueues` with a complete channel filter and preserves SDK order | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Telephony or no current task media | Queue or entry-point fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | +| Entry point + non-telephony current task media | Fetch/search/page | Store calls existing `getEntryPoints` with a complete channel filter and preserves SDK order | Widget filters returned rows or supplies sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | | Dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | ### UI flow and design @@ -174,22 +174,22 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp | API or operation | Change | Consumer impact | Compatibility expectation | Canonical definition | | --- | --- | --- | --- | --- | | Store buddy-agent loader | Accepts optional `Consult`/`Transfer` action instead of a media argument. | Task and component layers pass user intent. | Coordinated widgets release required. | `packages/contact-center/store/src/store.types.ts` | -| Store queue loader | Accepts pagination/search only and delegates with current-task media to the SDK's specialized method. | Callers no longer supply independent media or policy controls. | Coordinated widgets/SDK release required. | `packages/contact-center/store/src/store.types.ts` | -| Store entry-point loader | Accepts pagination/search only and delegates with current-task media to the SDK's specialized method. | No caller-owned media, profile, filter, projection, or sort flags. | Additive at the SDK boundary; coordinated store update. | `packages/contact-center/store/src/store.types.ts` | +| Store queue loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates to `getQueues`; the store adds a non-telephony filter from Task context when needed. | Callers keep the established queue list contract. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | +| Store entry-point loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates to `getEntryPoints`; the store adds a non-telephony filter from Task context when needed. | Callers keep the established entry-point list contract. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | | Store dial-number loader | Delegates pagination/search to the generic SDK AddressBook service. | The SDK default supplies backend name ordering. | Existing SDK surface with a corrected default. | `packages/contact-center/store/src/store.types.ts` | ### Public API and semver impact | Export or entry point | Change | Affected consumers | Required version change | Deprecation or migration | | --- | --- | --- | --- | --- | -| `@webex/cc-store` loader types | Action-aware and specialized request shapes | Internal widget packages and any direct store consumer | Semver-sensitive; coordinate under repository release policy | Direct consumers must pass action rather than media to the buddy loader. | +| `@webex/cc-store` loader types | Buddy loading carries the existing Consult/Transfer action context; queue and entry-point loaders use existing SDK request/response types. | Internal widget packages and any direct store consumer | Semver-sensitive only for the buddy action correction; queue and entry-point list shapes remain established. | Direct buddy-loader consumers pass action; queue and entry-point consumers need no new API. | | `@webex/cc-components` call-control loader prop | Optional action parameter | Call-control consumers | Additive callback argument for compatible functions; coordinate typings | Consumers may ignore the argument, but action-aware loaders should use it. | ### Cross-package impact | Package | Change | Dependency direction | Release sequencing | Owner | | --- | --- | --- | --- | --- | -| `@webex/contact-center` | Supplies specialized queue/entry-point APIs plus AddressBook and EntryPoint ordering defaults. | SDK → store | Build/link first. | SDK maintainers | +| `@webex/contact-center` | Applies Agent Desktop-compatible defaults through existing `getBuddyAgents`, `getQueues`, and `getEntryPoints`, and returns the existing full record types. | SDK → store | Build/link first. | SDK maintainers | | `@webex/cc-store` | Thin delegation and typed boundary. | store → SDK | Release with a compatible SDK version. | Widgets maintainers | | `@webex/cc-task` | Carries action and pagination/search. | task → store | Release after store types. | Widgets maintainers | | `@webex/cc-components` | Carries menu action on open/reload. | components → task callback | Release with task package. | Widgets maintainers | @@ -198,7 +198,7 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp **Provides — MODIFIED:** The widget packages provide action-aware list loading and preserve SDK result order/metadata without owning eligibility policy. -**Requires — MODIFIED:** The store requires an SDK that exports `ConsultTransferAction`, `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, `ConsultTransferListResponse`, `getConsultTransferQueues`, and `getConsultTransferEntryPoints`. +**Requires — MODIFIED:** The store requires the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and their existing queue/entry-point request and response types, plus the action-aware buddy-agent option and Task destination controls. No event contract changes. @@ -216,10 +216,10 @@ No event contract changes. | Entity or payload | Requested field or shape | Purpose | Ownership | Privacy, retention, or compatibility constraint | | --- | --- | --- | --- | --- | | Buddy-agent request | `action`, optional current task `mediaType` | Distinguish Consult and Transfer eligibility. | SDK contract; widgets supply runtime context. | No new retained data or credentials. | -| Queue list request | `page`, `pageSize`, `search`, optional current task `mediaType` | Paginated destination discovery. | SDK owns reusable defaults. | No widget-owned eligibility flags. | -| Entry-point list request | `page`, `pageSize`, `search`, optional current task `mediaType` | Paginated destination discovery. | SDK owns media validation/mapping, eligibility, and backend name ordering. | No widget-owned filter, projection, view, or sort flags. | +| Queue list request | Existing queue search parameters; store adds a complete filter only for an active non-telephony Task. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context. | No new method or signature; no widget-side returned-data filtering. | +| Entry-point list request | Existing entry-point search parameters; store adds a complete filter only for an active non-telephony Task. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context. | No new method or signature; no widget-selected projection, view, or sort flags. | | Dial-number list request | `page`, `pageSize`, `search` | Paginated address-book destination discovery. | AddressBook owns backend name ordering. | No widget-owned sort flags. | -| Queue/entry-point paginated response | SDK `ConsultTransferListResponse` with `data: {id, name, dbId?}[]` and `meta` unchanged | Preserve backend order, typed projection, and pagination truth. | SDK/backend | Widgets must not assume full queue or entry-point records or reconstruct metadata. | +| Queue/entry-point paginated response | Existing `ContactServiceQueuesResponse` / `EntryPointListResponse` with full `ContactServiceQueue` / `EntryPointRecord` rows and `meta` unchanged. | Preserve backend order, established typing, and pagination truth. | SDK/backend | Widgets must not project rows or reconstruct metadata. | ## Impacted domains @@ -234,7 +234,7 @@ No event contract changes. | Risk or assumption | Evidence | Mitigation or decision owner | | --- | --- | --- | -| Widgets are run with an SDK lacking the new methods. | `packages/contact-center/store/package.json` | Coordinate the SDK dependency/release; use the approved local worktree link for testing. | +| Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Coordinate the SDK dependency/release; use the approved local worktree link for testing. | | A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | | Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | @@ -261,16 +261,16 @@ No event contract changes. ## Operations -- Build/link the paired SDK before widgets so the new exports resolve. +- Build/link the paired SDK before widgets so the corrected defaults and existing types resolve. - Run the store, task, and cc-components unit suites plus touched package builds/styles before release. -- Roll back widgets and SDK together if the specialized methods are unavailable; no data migration or cleanup is required. +- Roll back widgets and SDK together if the coordinated behavior is incompatible; no data migration or cleanup is required. ## Migration expectations -- Compatibility: the SDK additions are additive, but direct `@webex/cc-store` callers of the changed loader signatures must migrate with the widgets release. +- Compatibility: queue and entry-point callers retain existing methods and types; direct `@webex/cc-store` buddy-loader callers of the corrected action input must migrate with the widgets release. - Data or consumer transition: release a compatible SDK before or with the store, then task and component packages. - Coexistence period: local development uses the approved SDK worktree link; published packages must use a released compatible SDK. -- Completion and rollback outcome: all packages resolve the same types and list methods; rollback is a coordinated dependency/code rollback with no persisted state. +- Completion and rollback outcome: all packages resolve the existing types and list methods; rollback is a coordinated dependency/code rollback with no persisted state. ## Serviceability @@ -283,7 +283,7 @@ No event contract changes. ## Documentation obligations - This approved delta modifies `STORE-R-015`, the task consult/transfer requirement family, and `CC-COMPONENTS-R-006` without overwriting the draft canonical module specs. -- The paired SDK feature spec remains the canonical owner for filter, projection, ordering, profile-view, media mapping, and cache-bypass decisions. +- The paired SDK feature spec remains the canonical owner for reusable telephony filter, ordering, profile-view, and cache-bypass defaults. This widget delta owns only the active non-telephony Task filter override needed because generic list calls carry no Task context. - A future canonical-spec promotion must fold this delta into the routed module specs and reconcile the delta path rather than duplicate the requirements. ## Decision and change log @@ -293,6 +293,7 @@ No event contract changes. | 2026-08-19 | Approved this exact MODIFIED delta path. | Avoid overwriting draft canonical module specs while keeping spec-currency with the implementation. | Developer | | 2026-08-19 | Assigned reusable list policy to the SDK and retained only UI/runtime context in widgets. | Prevent policy duplication and ordering drift. | Developer + Codex | | 2026-08-19 | Explicitly prohibited widget-side sorting/filtering of SDK destination results. | Preserve the backend order selected by the SDK request. | Developer + Codex | -| 2026-08-19 | Adopted the SDK's single minimal consult/transfer options type and forward current-task media for both queue and entry-point requests. | Match Agent Desktop inputs while keeping filter, projection, view, channel mapping, ordering, and cache decisions out of widgets. | Developer + Codex | +| 2026-08-19 | Reused the existing queue and entry-point methods and full response types; removed the one-off consult/transfer destination/list abstractions. | Widget consumers need lists, not a parallel public model or method family. | Developer + Codex | | 2026-08-19 | Removed widget selection of entry-point media and the consult-specific dial-number helper. | EntryPoint and AddressBook defaults must work for widgets out of the box; other SDK consumers can pass explicit overrides. | Developer + Codex | | 2026-08-19 | Removed the widget/store destination-policy utility and raw profile/context plumbing; CallControl now renders the matching ordered `Task.uiControls.consultTransferDestinations` array. | Task already contains the live interaction and SDK-computed UI decisions, so no extra policy call or duplicated consumer logic is needed. | Developer + Codex | +| 2026-08-19 | Limited widget-owned list policy to a complete non-telephony request filter selected from the active Task media. | The existing generic SDK methods have no Task parameter and cannot infer which concurrent Task the UI is rendering; all reusable defaults and response decisions remain in the SDK. | Developer + Codex | diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 86e4431f7..4ae4036a3 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -93,7 +93,7 @@ Consumed as an imported SDK/code API. The React barrel (`src/index.ts`) is the p | `cc-components.StationLoginComponent` | SDK | `StationLoginComponent` (`StationLoginComponentProps`) | Agent login: device/team selection, login/logout, multiple-login alert, profile mode | semver; props are `Pick`ed — adding optional props = minor, removing/renaming a picked prop = major | `src/components/StationLogin/station-login.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.UserStateComponent` | SDK | `UserStateComponent` (`UserStateComponentsProps`) | Agent state dropdown + idle codes + state timer | semver as above | `src/components/UserState/user-state.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlComponent` | SDK | `CallControlComponent` (`CallControlComponentProps`) | Call control buttons: hold/resume, mute, record, end, wrapup, consult/transfer/conference | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.consult-transfer-lists` | SDK callback props | `getQueuesFetcher`, `getQueues`, and `getEntryPoints` use `FetchPaginatedList` | Render SDK-projected queue and entry-point rows without assuming full CMS record fields | additive alignment with the SDK/store projection; changing required destination fields is semver-sensitive | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.consult-transfer-lists` | SDK callback props | `getQueuesFetcher`/`getQueues` use `FetchPaginatedList` and `getEntryPoints` uses `FetchPaginatedList` | Render the SDK's established queue and entry-point records directly in backend order | types track the existing SDK/store entity contracts | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -110,7 +110,7 @@ Compatibility notes: - `wc.ts` aliases `CallControlCADComponent` to `../CallControl/call-control` (imports `CallControlComponent` under the `CallControlCADComponent` name); the distinct CAD component is `src/components/task/CallControlCAD/call-control-cad.tsx`. See Pitfalls. ## Requires (dependencies) -- `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `ConsultTransferDestination`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. +- `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `ContactServiceQueue`, `EntryPointRecord`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. - `@webex/cc-ui-logging` (workspace:\*) — `withMetrics` HOC wrapping each top-level component for mount/metrics tracking. - `@momentum-ui/react-collaboration` (peer `>=26.197.0`) and `@momentum-design/components/dist/react` — UI primitives. - `@r2wc/react-to-web-component` `2.0.3` — custom-element wrapping in `wc.ts`. @@ -136,7 +136,7 @@ Compatibility notes: | `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | -| `CC-COMPONENTS-R-016` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render queue/entry-point/dial-number arrays in received order. | The component must preserve backend-selected order and exact projected rows instead of introducing a second list policy. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | +| `CC-COMPONENTS-R-016` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render `ContactServiceQueue`, `EntryPointRecord`, and dial-number arrays in received order. | The component must preserve backend-selected order and established SDK entity records instead of introducing a second list policy or destination abstraction. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | ## Design Overview Every component follows the same shape: a typed function component destructures props, derives display data through pure helpers in a co-located `*.utils.ts(x)`, renders Momentum primitives, and calls back through callback props on user interaction. Local `useState` holds only transient UI (open menus, selected-but-not-yet-submitted values, input text) — never domain state. Top-level components are wrapped in `withMetrics`. This keeps each component unit-testable with plain props and jest mocks and is the reason the archived "presentational pattern" guidance still holds. diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index 58f9b633e..e81358579 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -1,7 +1,8 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import { AddressBookEntry, - ConsultTransferDestination, + ContactServiceQueue, + EntryPointRecord, ILogger, FetchPaginatedList, PaginatedListParams, @@ -149,7 +150,7 @@ export function useConsultTransferPopover({ loading: loadingEntryPoints, loadData: loadEntryPoints, reset: resetEntryPoints, - } = usePaginatedData(getEntryPoints, CATEGORY_ENTRY_POINT, logger); + } = usePaginatedData(getEntryPoints, CATEGORY_ENTRY_POINT, logger); const { data: queuesData, @@ -158,7 +159,7 @@ export function useConsultTransferPopover({ loading: loadingQueues, loadData: loadQueues, reset: resetQueues, - } = usePaginatedData(getQueues, CATEGORY_QUEUES, logger); + } = usePaginatedData(getQueues, CATEGORY_QUEUES, logger); const loadNextPage = useCallback(() => { if (!canLoadCategory(selectedCategory)) return; 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 f283211bd..3664a5e4d 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 @@ -88,13 +88,13 @@ const ConsultTransferPopoverComponent: React.FC(false); - const renderList = ( + const renderList = ( items: T[], onButtonPress: (item: T) => void ) => ( {items.map((item) => ( -

    e.stopPropagation()} className="consult-list-item-wrapper"> +
    e.stopPropagation()} className="consult-list-item-wrapper"> {renderList(queuesData, (item) => - handleQueueSelection(item.id, item.name, allowParticipantsToInteract, onQueueSelect, logger) + item.id + ? handleQueueSelection(item.id, item.name, allowParticipantsToInteract, onQueueSelect, logger) + : undefined )} {hasMoreQueues && (
    diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 0e8bac8bb..3e332f5dc 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -6,8 +6,8 @@ import { BuddyDetails, DestinationType, ContactServiceQueue, + EntryPointRecord, ConsultTransferAction, - ConsultTransferDestination, ConsultTransferDestinationType, AddressBookEntry, FetchPaginatedList, @@ -498,10 +498,10 @@ export interface ControlProps { getAddressBookEntries?: FetchPaginatedList; /** Fetch paginated entry points */ - getEntryPoints?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; /** Fetch paginated consult/transfer queues from the SDK-owned policy */ - getQueuesFetcher?: FetchPaginatedList; + getQueuesFetcher?: FetchPaginatedList; /** * Options to configure consult/transfer popover behavior. @@ -671,8 +671,8 @@ export interface ConsultTransferPopoverComponentProps { loadingBuddyAgents: boolean; loadBuddyAgents?: (action?: ConsultTransferAction) => Promise; getAddressBookEntries?: FetchPaginatedList; - getEntryPoints?: FetchPaginatedList; - getQueues?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; + getQueues?: FetchPaginatedList; onAgentSelect: (agentId: string, agentName: string, allowParticipantsToInteract: boolean) => void; onQueueSelect: (queueId: string, queueName: string, allowParticipantsToInteract: boolean) => void; onEntryPointSelect: (entryPointId: string, entryPointName: string, allowParticipantsToInteract: boolean) => void; @@ -884,8 +884,8 @@ export const CATEGORY_AGENTS: CategoryType = 'Agents'; export type UseConsultTransferParams = { availableCategories: CategoryType[]; getAddressBookEntries?: FetchPaginatedList; - getEntryPoints?: FetchPaginatedList; - getQueues?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; + getQueues?: FetchPaginatedList; logger?: ILogger; }; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx index f351342e2..c778e99da 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx @@ -2,7 +2,7 @@ import React from 'react'; import '@testing-library/jest-dom'; import {render, fireEvent, act, waitFor} from '@testing-library/react'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; -import {ConsultTransferDestination, ConsultTransferDestinationType} from '@webex/cc-store'; +import {ContactServiceQueue, ConsultTransferDestinationType} from '@webex/cc-store'; const mockUIDProps = (container) => { container @@ -29,10 +29,7 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const mockOnAgentSelect = jest.fn(); const mockOnQueueSelect = jest.fn(); - const buildQueue = (id: string, name: string): ConsultTransferDestination => ({ - id, - name, - }); + const buildQueue = (id: string, name: string): ContactServiceQueue => ({id, name}) as ContactServiceQueue; const defaultProps = { heading: 'Select an Agent', 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 8f766d89c..6f8f850af 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 @@ -2,12 +2,7 @@ import React from 'react'; 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 { - AddressBookEntry, - ConsultTransferDestination, - ConsultTransferDestinationType, - EntryPointRecord, -} from '@webex/cc-store'; +import {AddressBookEntry, ContactServiceQueue, ConsultTransferDestinationType, EntryPointRecord} from '@webex/cc-store'; import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; const loggerMock = { @@ -55,8 +50,8 @@ describe('ConsultTransferPopoverComponent', () => { ], getQueues: async () => ({ data: [ - {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, - {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, + {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, + {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, ], meta: {page: 0, totalPages: 1}, }), @@ -298,8 +293,8 @@ describe('ConsultTransferPopoverComponent', () => { it('debounces and triggers queue search on 2+ chars and on clear', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ data: [ - {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, - {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, + {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, + {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, ], meta: {page: 0, totalPages: 1}, }); @@ -379,8 +374,8 @@ describe('ConsultTransferPopoverComponent', () => { it('reloads queues when reload button clicked on Queues tab', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ data: [ - {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, - {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, + {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, + {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, ], meta: {page: 0, totalPages: 1}, }); @@ -511,8 +506,8 @@ describe('ConsultTransferPopoverComponent', () => { it('shows spinner in load more area when loading more queues', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ data: [ - {id: 'queue1', name: 'Queue One'} as ConsultTransferDestination, - {id: 'queue2', name: 'Queue Two'} as ConsultTransferDestination, + {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, + {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, ], meta: {page: 0, totalPages: 2}, }); @@ -556,7 +551,7 @@ describe('ConsultTransferPopoverComponent', () => { it('reloads with current search query on Queues tab', async () => { const getQueuesMock = jest.fn().mockResolvedValue({ - data: [{id: 'queue1', name: 'Queue One'} as ConsultTransferDestination], + data: [{id: 'queue1', name: 'Queue One'} as ContactServiceQueue], meta: {page: 0, totalPages: 1}, }); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 7f19addcc..f66ae7ab8 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -467,7 +467,13 @@ describe('CallControlComponent', () => { const screen = await render( ); @@ -501,7 +507,13 @@ describe('CallControlComponent', () => { const screen = await render( ); diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 0e554c49c..71f6945d5 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -74,7 +74,7 @@ This module is consumed as an imported SDK/code API (the `@webex/cc-store` packa | Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | |---|---|---|---|---|---|---| | `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.types` | SDK | type re-exports including `ConsultTransferListOptions`, `ConsultTransferMediaType`, `ConsultTransferDestination`, and `ConsultTransferListResponse` | Typed SDK-backed domain surface; consult/transfer queue and entry-point inputs share one minimal contract and rows use the exact projected shape | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.types` | SDK | type re-exports including the existing `ContactServiceQueue`, `ContactServiceQueuesResponse`, `ContactServiceQueueSearchParams`, `EntryPointRecord`, `EntryPointListResponse`, and `EntryPointSearchParams` contracts plus Task destination controls | Typed SDK-backed domain surface using the SDK's established entity and list types directly | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -104,7 +104,7 @@ Compatibility notes: | `STORE-R-012` | `handleTaskRemove` detaches every task listener, clears `realtimeTranscriptionData` for the removed current task, drops accepted-campaign tracking, resets custom state, and refreshes the list | Prevent listener/audio/state leaks across task lifecycles | `src/storeEventsWrapper.ts:458-521` | `tests/storeEventsWrapper.ts` ("handleTaskRemove — campaign ID cleanup") | Per-listener detach is asserted only partially; full leak audit is a gap | PRESENT | | `STORE-R-013` | `agent:logoutSuccess` triggers `cleanUpStore()` which resets session observables and removes CC SDK listeners; `agent:multiLogin` sets `showMultipleLoginAlert` | Clean session teardown and multi-login warning | `src/storeEventsWrapper.ts:811-819,1003-1024,1029-1066` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | | `STORE-R-014` | `agent:stateChange` (type `AgentStateChangeSuccess`) updates `currentState` (defaulting `auxCodeId` `''`→`'0'`) and both state-change timestamps | Drives the agent-state widget and timers | `src/storeEventsWrapper.ts:797-809` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | -| `STORE-R-015` | Consult/transfer list fetchers thinly delegate action, pagination/search, and SDK-originated task media to the SDK; queue and entry-point methods return `ConsultTransferListResponse` without local filtering, sorting, or metadata reconstruction. Errors are logged and rethrown; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false. | Keep reusable eligibility, projection, media validation, and ordering decisions in the SDK while widgets preserve backend order and pagination truth. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/storeEventsWrapper.ts` | Address-book-disabled branch coverage is a gap. | PRESENT | +| `STORE-R-015` | Consult/transfer fetchers use the existing SDK `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and return their established entity/list responses without local result filtering, sorting, projection, or metadata reconstruction. Telephony calls rely on SDK defaults; for a non-telephony active task the store supplies only a complete channel eligibility expression through the existing `filter` parameter. Errors are logged and rethrown; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false. | Keep ordinary list policy in the SDK and preserve backend order while carrying the one piece of per-task context an SDK-global list method cannot infer without a new signature. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/storeEventsWrapper.ts` | Address-book-disabled branch coverage is a gap. | PRESENT | | `STORE-R-016` | `setOnError` wraps the caller callback to also submit a behavioral metrics event before invoking it | Consistent telemetry on widget errors | `src/storeEventsWrapper.ts:285-301` | None found | Negative/telemetry-path test missing | WEAK | | `STORE-R-017` | `isIncomingTask` returns true only when the task is not wrap-up-required, the agent has not joined, and the interaction state is `new`/`consult`/`connected`/`conference` | Gates whether a task is treated as an unanswered incoming offer | `src/task-utils.ts:26-37` | `tests/task-utils.ts` ("isIncomingTask" — incoming / not incoming / edge cases) | none | PRESENT | | `STORE-R-018` | `getConsultStatus`/`getTaskStatus` map participant `consultState` + interaction state to a `ConsultStatus`, with special handling for secondary EP-DN agents | Consult/conference UI relies on a single derived status | `src/task-utils.ts:39-146` | None found (direct `getConsultStatus` test) | Only `isIncomingTask`, conference, and hold helpers are directly tested; consult-status helper is a gap | WEAK | @@ -246,9 +246,9 @@ sequenceDiagram participant SDK as "@webex/contact-center" Widget->>W: getQueues(params) / getEntryPoints(params) - W->>SDK: specialized method({params, currentTaskMedia}) + W->>SDK: getQueues/getEntryPoints(existing params) alt resolves - SDK-->>W: ConsultTransferListResponse + SDK-->>W: existing full-record paginated response W-->>Widget: unchanged {data, meta} else rejects SDK-->>W: error @@ -281,7 +281,7 @@ classDiagram - **UC-3 Observe agent/session state in React:** Widget wraps in `observer()` and reads `store.agentId`, `store.isAgentLoggedIn`, `store.deviceType`, `store.currentState` → re-renders on mutation. Evidence: `src/storeEventsWrapper.ts:56-187`, `_archive/.../AGENTS.md` usage. - **UC-4 Handle an incoming task through to wrap-up:** SDK `task:incoming` → listeners registered + `onIncomingTask` fired → `task:assigned` sets ENGAGED/current → `task:end` + `handleTaskRemove` cleans up. Evidence: `src/storeEventsWrapper.ts:585-762`, `tests/storeEventsWrapper.ts` ("events reactions"). - **UC-5 Campaign-preview accept flow:** `task:campaignPreviewReservation` puts a preview in RESERVED; preview stays out of `currentTask` until accepted (`acceptedCampaignIds`), then transitions to ENGAGED. Evidence: `src/storeEventsWrapper.ts:243-283,537-583,772-795`, `tests/storeEventsWrapper.ts` ("campaign preview task lifecycle"). -- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult calls `getBuddyAgents()`/`getQueues()`/`getEntryPoints()` and Outdial calls `getAddressBookEntries()`. The store delegates consult/transfer lists to the specialized SDK methods, forwards only runtime context and list inputs, and returns projected data/metadata unchanged. Evidence: `src/storeEventsWrapper.ts`, `tests/storeEventsWrapper.ts`. +- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult calls the existing `getBuddyAgents()`/`getQueues()`/`getEntryPoints()` methods and Outdial calls `getAddressBookEntries()`. The store relies on SDK telephony defaults, supplies an existing filter only for a non-telephony active task, and returns full-record data/metadata unchanged. Evidence: `src/storeEventsWrapper.ts`, `tests/storeEventsWrapper.ts`. ## State Model The store is a single MobX `makeAutoObservable` instance. Observable slices (all in `src/store.ts:23-56`): @@ -289,7 +289,7 @@ The store is a single MobX `makeAutoObservable` instance. Observable slices (all - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. - **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `isDigitalChannelsInitialized`. Destination availability/order remains on the SDK Task's `uiControls` rather than duplicated store observables. -- **`getQueues` / `getEntryPoints`:** forward current-task media plus pagination/search and return `ConsultTransferListResponse` from the SDK specialized methods. They do not add eligibility/view/sort flags or reinterpret the projected `id`/`name`/optional-`dbId` rows. +- **`getQueues` / `getEntryPoints`:** call the same-named SDK methods and return `ContactServiceQueuesResponse` / `EntryPointListResponse`. Telephony uses SDK defaults; non-telephony tasks use the existing `filter` parameter. They do not sort, filter returned rows, project fields, or reinterpret metadata. - **`getBuddyAgents`:** returns the SDK `agentList` unchanged (full list, no pagination). - **Misc:** `currentTheme`, `cc` (`observable.ref` — not deeply observed), `isAddressBookEnabled`. @@ -339,7 +339,7 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-012` | `tests/storeEventsWrapper.ts` (handleTaskRemove cleanup) | full per-listener detach not exhaustively asserted | | `STORE-R-013` | `tests/storeEventsWrapper.ts` (events reactions) | none | | `STORE-R-014` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-015` | `tests/storeEventsWrapper.ts` (specialized list delegation, unchanged order/metadata, errors) | address-book-disabled branch not directly asserted | +| `STORE-R-015` | `tests/storeEventsWrapper.ts` (existing-method delegation, non-telephony existing-filter use, unchanged order/metadata, errors) | address-book-disabled branch not directly asserted | | `STORE-R-016` | None found | missing telemetry-path test | | `STORE-R-017` | `tests/task-utils.ts` (isIncomingTask) | none | | `STORE-R-018` | None found | `getConsultStatus`/`getTaskStatus` untested | diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index c898047d5..76199c013 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -14,9 +14,6 @@ import { ConsultTransferAction, ConsultTransferDestinationControls, ConsultTransferDestinationType, - ConsultTransferDestination, - ConsultTransferListResponse, - ConsultTransferListOptions, ConsultTransferMediaType, AddressBookEntry, AddressBookEntriesResponse, @@ -69,8 +66,6 @@ interface IContactCenter { getBuddyAgents(data: BuddyAgents): Promise; getQueues(params?: ContactServiceQueueSearchParams): Promise; getEntryPoints(params?: EntryPointSearchParams): Promise; - getConsultTransferQueues(params?: ConsultTransferListOptions): Promise; - getConsultTransferEntryPoints(params?: ConsultTransferListOptions): Promise; addressBook: AddressBook; agentConfig?: { regexUS: RegExp | string; @@ -223,8 +218,8 @@ interface IStoreWrapper extends IStore { setCurrentTask(task: ITask): void; refreshTaskList(): void; getBuddyAgents(action?: ConsultTransferAction): Promise; - getQueues(params?: ConsultTransferListSearchOptions): Promise; - getEntryPoints(params?: ConsultTransferListSearchOptions): Promise; + getQueues(params?: ContactServiceQueueSearchParams): Promise; + getEntryPoints(params?: EntryPointSearchParams): Promise; getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; setDeviceType(option: string): void; setDialNumber(input: string): void; @@ -250,8 +245,6 @@ interface IStoreWrapper extends IStore { clearRealTimeAssist(interactionId: string): void; } -type ConsultTransferListSearchOptions = Omit; - interface IWrapupCode { id: string; name: string; @@ -367,9 +360,6 @@ export type { ConsultTransferAction, ConsultTransferDestinationControls, ConsultTransferDestinationType, - ConsultTransferDestination, - ConsultTransferListResponse, - ConsultTransferListOptions, ConsultTransferMediaType, AddressBookEntry, AddressBookEntriesResponse, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 1dad7da2a..c8d6b8230 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -16,9 +16,11 @@ import { RESERVED_LABEL, RESERVED_USERNAME, ConsultTransferAction, - ConsultTransferListResponse, - ConsultTransferListOptions, ConsultTransferMediaType, + ContactServiceQueuesResponse, + ContactServiceQueueSearchParams, + EntryPointListResponse, + EntryPointSearchParams, AddressBookEntriesResponse, AddressBookEntrySearchParams, Profile, @@ -38,6 +40,24 @@ import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; import {SUGGESTED_RESPONSE_EVENT, TASK_MULTI_LOGIN_HYDRATE} from './constants'; +const CONSULT_TRANSFER_CHANNELS: Record = { + telephony: 'TELEPHONY', + chat: 'CHAT', + social: 'SOCIAL_CHANNEL', + email: 'EMAIL', +}; + +const getTaskChannelFilter = (entityType: 'queue' | 'entryPoint', mediaType?: string): string | undefined => { + const normalizedMediaType = typeof mediaType === 'string' ? mediaType.toLowerCase() : ''; + const channelType = CONSULT_TRANSFER_CHANNELS[normalizedMediaType as ConsultTransferMediaType]; + + if (!channelType || channelType === 'TELEPHONY') return undefined; + + const typeField = entityType === 'queue' ? 'queueType' : 'entryPointType'; + + return `${typeField}==INBOUND;channelType==${channelType};active==true`; +}; + class StoreWrapper implements IStoreWrapper { store: IStore; onIncomingTask: ({task}: {task: ITask}) => void; @@ -1078,13 +1098,14 @@ class StoreWrapper implements IStoreWrapper { } }; - getQueues = async (params?: Omit): Promise => { + getQueues = async (params?: ContactServiceQueueSearchParams): Promise => { try { const mediaType = this.currentTask?.data?.interaction?.mediaType; + const filter = getTaskChannelFilter('queue', mediaType); - return await this.store.cc.getConsultTransferQueues({ + return await this.store.cc.getQueues({ + ...(filter ? {filter} : {}), ...(params ?? {}), - ...(mediaType ? {mediaType: mediaType as ConsultTransferMediaType} : {}), }); } catch (error) { this.store.logger.error('Error fetching queues:', error); @@ -1092,15 +1113,14 @@ class StoreWrapper implements IStoreWrapper { } }; - getEntryPoints = async ( - params?: Omit - ): Promise => { + getEntryPoints = async (params?: EntryPointSearchParams): Promise => { try { const mediaType = this.currentTask?.data?.interaction?.mediaType; + const filter = getTaskChannelFilter('entryPoint', mediaType); - return await this.store.cc.getConsultTransferEntryPoints({ + return await this.store.cc.getEntryPoints({ + ...(filter ? {filter} : {}), ...(params ?? {}), - ...(mediaType ? {mediaType: mediaType as ConsultTransferMediaType} : {}), }); } catch (error) { this.store.logger.error('Error fetching entry points:', error); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 89efd332d..ee428f19f 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1034,35 +1034,45 @@ describe('storeEventsWrapper', () => { ]; const response = {data: queueList, meta: {page: 0, totalPages: 1}}; storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; - storeWrapper['store'].cc.getConsultTransferQueues = jest.fn().mockResolvedValue(response); + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(response); const result = await storeWrapper.getQueues(); expect(result.data).toEqual(queueList); - expect(storeWrapper['store'].cc.getConsultTransferQueues).toHaveBeenCalledWith({ - mediaType: 'telephony', - }); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({}); }); it('should pass only runtime context and list inputs when getQueues is called with params', async () => { const queueList = [{id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}]; storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; - storeWrapper['store'].cc.getConsultTransferQueues = jest + storeWrapper['store'].cc.getQueues = jest .fn() .mockResolvedValue({data: queueList, meta: {page: 1, totalPages: 1}}); await storeWrapper.getQueues({page: 1, pageSize: 25}); - expect(storeWrapper['store'].cc.getConsultTransferQueues).toHaveBeenCalledWith({ + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ page: 1, pageSize: 25, - mediaType: 'telephony', + }); + }); + + it('should use the existing queue filter parameter for a non-telephony task', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'social'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues({page: 0, pageSize: 25}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==SOCIAL_CHANNEL;active==true', + page: 0, + pageSize: 25, }); }); it('should handle error in getQueues and throw error', async () => { storeWrapper['store'].currentTask = null; - storeWrapper['store'].cc.getConsultTransferQueues = jest.fn().mockRejectedValue(new Error('queue error')); + storeWrapper['store'].cc.getQueues = jest.fn().mockRejectedValue(new Error('queue error')); await expect(storeWrapper.getQueues()).rejects.toThrow('queue error'); }); @@ -1074,12 +1084,12 @@ describe('storeEventsWrapper', () => { ]; const response = {data: queueList, meta: {page: 1, pageSize: 50, totalRecords: 2, totalPages: 1}}; storeWrapper['store'].currentTask = null; - storeWrapper['store'].cc.getConsultTransferQueues = jest.fn().mockResolvedValue(response); + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(response); const result = await storeWrapper.getQueues(); expect(result).toEqual(response); - expect(storeWrapper['store'].cc.getConsultTransferQueues).toHaveBeenCalledWith({}); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({}); }); it('should handle consultQueueCancelled event', () => { @@ -1095,20 +1105,35 @@ describe('storeEventsWrapper', () => { it('should fetch entry points successfully', async () => { storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; - storeWrapper['store'].cc.getConsultTransferEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); + storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); - expect(storeWrapper['store'].cc.getConsultTransferEntryPoints).toHaveBeenCalledWith({ + expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ page: 0, pageSize: 25, - mediaType: 'telephony', }); expect(result).toEqual(mockEntryPointsResponse); }); + it('should use the existing entry-point filter parameter for a non-telephony task', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'chat'}}} as ITask; + storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue({ + data: [], + meta: {page: 0, totalPages: 0}, + }); + + await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); + + expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ + filter: 'entryPointType==INBOUND;channelType==CHAT;active==true', + page: 0, + pageSize: 25, + }); + }); + it('should handle error while fetching entry points', async () => { storeWrapper['store'].currentTask = null; - storeWrapper['store'].cc.getConsultTransferEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); + storeWrapper['store'].cc.getEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); await expect(storeWrapper.getEntryPoints({page: 0, pageSize: 25})).rejects.toThrow('ep error'); }); diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index 8453b0e2a..f0f1d1272 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -619,8 +619,6 @@ const mockCC: IContactCenter = { getBuddyAgents: jest.fn().mockResolvedValue(mockAgents), getQueues: jest.fn().mockResolvedValue(mockQueuesResponse), getEntryPoints: jest.fn().mockResolvedValue(mockEntryPointsResponse), - getConsultTransferQueues: jest.fn().mockResolvedValue(mockQueuesResponse), - getConsultTransferEntryPoints: jest.fn().mockResolvedValue(mockEntryPointsResponse), addressBook: mockAddressBook, setAgentState: jest.fn().mockResolvedValue({}), getOutdialAniEntries: jest.fn().mockResolvedValue({entries: []}), diff --git a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts index ffd16a66b..fd717f3ed 100644 --- a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts +++ b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts @@ -24,12 +24,8 @@ export function createMockTaskUIControls(overrides?: { main: {...base.main, ...overrides?.main}, consult: {...base.consult, ...overrides?.consult}, consultTransferDestinations: { - consult: - overrides?.consultTransferDestinations?.consult ?? - base.consultTransferDestinations.consult, - transfer: - overrides?.consultTransferDestinations?.transfer ?? - base.consultTransferDestinations.transfer, + consult: overrides?.consultTransferDestinations?.consult ?? base.consultTransferDestinations.consult, + transfer: overrides?.consultTransferDestinations?.transfer ?? base.consultTransferDestinations.transfer, }, }; } From 9e9b5ccbb0d43e5c47995e53ac3a8d90768b4452 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Wed, 19 Aug 2026 23:08:39 +0530 Subject: [PATCH 06/10] refactor(contact-center): minimize consult transfer public types --- ai-docs/CONTRACTS.md | 2 +- .../spec/feature-spec.md | 23 ++++++++++--------- .../src/components/task/task.types.ts | 8 +++---- .../consult-transfer-popover.snapshot.tsx | 8 ++++--- .../consult-transfer-popover.tsx | 10 ++++---- .../store/ai-docs/store-spec.md | 2 +- .../contact-center/store/src/store.types.ts | 10 +------- .../store/src/storeEventsWrapper.ts | 22 +++++++++++------- .../store/tests/storeEventsWrapper.ts | 13 +++++++++-- .../contact-center/task/ai-docs/task-spec.md | 2 +- .../src/taskUIControlsFixtures.ts | 10 ++------ 11 files changed, 57 insertions(+), 53 deletions(-) rename ai-docs/features/{agent-desktop-consult-transfer-list-policy => consult-transfer-list-policy}/spec/feature-spec.md (93%) diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 51ec4c94c..33bfd8566 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -23,7 +23,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | -| store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `ConsultTransferMediaType`, `ConsultTransferDestinationControls`, and `ConsultTransferDestinationType` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | +| store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | | cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action` and `availableDestinations` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list and category order and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | diff --git a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md similarity index 93% rename from ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md rename to ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index 0c5127079..047e00f19 100644 --- a/ai-docs/features/agent-desktop-consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -1,11 +1,11 @@ --- type: Feature Spec -title: Agent Desktop consult and transfer list policy -description: Keep consult and transfer destination eligibility and ordering aligned with Agent Desktop while leaving widgets as a thin SDK consumer. +title: Consult and transfer list policy +description: Keep consult and transfer destination eligibility and ordering consistent while leaving widgets as a thin SDK consumer. tags: [feature, specification, contact-center, consult-transfer] --- -# Agent Desktop consult and transfer list policy +# Consult and transfer list policy This document owns the feature's what and why. The paired SDK delta owns reusable destination-list policy and the ordered destination availability attached to each Task; this repository owns list loading, selection UI, host hide overrides, and error presentation. @@ -20,7 +20,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi | Status | Approved and implemented; diff-scoped drift validation PASS; independent validation pending | | Work type | Defect | | Change class | Contract / UI | -| Source/intake | Developer-approved Agent Desktop parity review and current code/tests | +| Source/intake | Developer-approved consult/transfer behavior review and current code/tests | | Last verified | 2026-08-19 in a working tree based on `69fdb37c` | ## Applicability @@ -44,7 +44,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi ## Problem and goal -The widgets previously embedded destination policy in the store: buddy agents were always restricted to Available, queues were fetched through the generic SDK API and filtered in memory by channel, and list response metadata was reconstructed. That made widget behavior diverge from Agent Desktop and made list order dependent on widget-side transformation. +The widgets previously embedded destination policy in the store: buddy agents were always restricted to Available, queues were fetched through the generic SDK API and filtered in memory by channel, and list response metadata was reconstructed. That made behavior inconsistent across consumers and made list order dependent on widget-side transformation. The goal is for widgets to use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods. Widgets supply the user's action and pagination/search input; the store uses current-task media only to override the SDK's telephony default for an active non-telephony task. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not transform returned rows, choose ordering, or reconstruct pagination semantics. @@ -52,7 +52,7 @@ The goal is for widgets to use the SDK's existing `getBuddyAgents`, `getQueues`, | Stakeholder | Need or decision | Status | | --- | --- | --- | -| Contact Center agents | Consult and transfer destination lists match Agent Desktop eligibility and order. | Decided | +| Contact Center agents | Consult and transfer destination lists have consistent eligibility and order. | Decided | | Widget maintainers | Destination business policy remains outside React and MobX UI code. | Decided | | SDK maintainers | Existing SDK services own default list eligibility, profile views, and ordering; no consult/transfer-specific list method or response abstraction is added. | Decided in the paired SDK delta | | Host applications | Existing widget UI behavior remains compatible apart from the corrected destination results. | Decided | @@ -99,13 +99,13 @@ There are no open product decisions for this delta. | `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter through the existing `filter` option; for telephony or missing media it must omit the override and use SDK defaults. Dial-number requests forward only pagination/search. | The SDK's generic list methods have no Task argument and cannot infer which concurrent task is being rendered, while the store already owns current-task context. Limiting widget logic to this request override keeps reusable defaults and returned-data decisions in the SDK. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or public request signature is required. | Present | | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | -| `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off consult/transfer destination, list-options, or list-response type may be introduced. | Consumers only need the existing lists. Reusing the established full-record contracts avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior and typed `dbId` additions. | Present | +| `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `TaskUIControls`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off action, media, destination-control, destination-list, list-options, or list-response public type may be introduced. | Consumers only need the existing methods, lists, and Task control field. Deriving destination typing from `TaskUIControls` avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior. | Present | | `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags or derive visibility from media/direction/task payload fields; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, and makes the SDK-provided first category the default selection. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Consumers cannot enable a category omitted by the SDK. | Present | ## Defect context (when applicable) -- Observed versus expected behavior: widgets could show a different destination population/order because they always requested Available agents, used generic queue/entry-point APIs, locally filtered queues, and rebuilt metadata; expected behavior is the Agent Desktop request policy with no widget-side sorting or filtering. -- Reproduction and environment: open Consult and Transfer destination popovers for the same active task and compare Agents, Queues, and Entry Points with Agent Desktop. +- Observed versus expected behavior: widgets could show a different destination population/order because they always requested Available agents, used generic queue/entry-point APIs, locally filtered queues, and rebuilt metadata; expected behavior is the SDK-owned request policy with no widget-side sorting or filtering. +- Reproduction and environment: open Consult and Transfer destination popovers for the same active task and compare Agents, Queues, and Entry Points across SDK and widget consumers. - Regression range or last known good state: unknown; the previous widget implementation predates this coordinated SDK policy. - Severity, frequency, and workaround: user-visible whenever backend order or action eligibility differs; no reliable host-side workaround. - Diagnostic evidence: `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. @@ -129,7 +129,7 @@ There are no open product decisions for this delta. ### MOD-003 — Call-control action context (`CC-COMPONENTS-R-006`) - **WHAT**: Initial menu opening and agent-list reload must call the loader with the active menu action. Category visibility, order, and initial selection come from the matching SDK Task destination-control array; widget wrappers no longer build interaction context or forward raw profile access flags. -- **WHY**: A reload must not silently revert Transfer eligibility to Consult eligibility, and UI consumers must not duplicate the SDK's Agent Desktop policy. +- **WHY**: A reload must not silently revert Transfer eligibility to Consult eligibility, and UI consumers must not duplicate the SDK's destination policy. - **Evidence:** `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl`. - **Acceptance:** Component tests cover action-preserving reload, SDK category omission, SDK category order, and host-only hide overrides. @@ -189,7 +189,7 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp | Package | Change | Dependency direction | Release sequencing | Owner | | --- | --- | --- | --- | --- | -| `@webex/contact-center` | Applies Agent Desktop-compatible defaults through existing `getBuddyAgents`, `getQueues`, and `getEntryPoints`, and returns the existing full record types. | SDK → store | Build/link first. | SDK maintainers | +| `@webex/contact-center` | Applies consult/transfer defaults through existing `getBuddyAgents`, `getQueues`, and `getEntryPoints`, and returns the existing full record types. | SDK → store | Build/link first. | SDK maintainers | | `@webex/cc-store` | Thin delegation and typed boundary. | store → SDK | Release with a compatible SDK version. | Widgets maintainers | | `@webex/cc-task` | Carries action and pagination/search. | task → store | Release after store types. | Widgets maintainers | | `@webex/cc-components` | Carries menu action on open/reload. | components → task callback | Release with task package. | Widgets maintainers | @@ -290,6 +290,7 @@ No event contract changes. | Date | Decision or change | Rationale | Owner | | --- | --- | --- | --- | +| 2026-08-19 | Removed dependencies on new action/media/destination aliases and typed widget destinations from `TaskUIControls`; removed the queue `dbId` passthrough fixture. | Widgets need the existing methods, entity records, and Task control field only; deriving types prevents an unnecessary public SDK surface. | Developer + Codex | | 2026-08-19 | Approved this exact MODIFIED delta path. | Avoid overwriting draft canonical module specs while keeping spec-currency with the implementation. | Developer | | 2026-08-19 | Assigned reusable list policy to the SDK and retained only UI/runtime context in widgets. | Prevent policy duplication and ordering drift. | Developer + Codex | | 2026-08-19 | Explicitly prohibited widget-side sorting/filtering of SDK destination results. | Preserve the backend order selected by the SDK request. | Developer + Codex | diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 3e332f5dc..2a1d27c03 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -7,8 +7,6 @@ import { DestinationType, ContactServiceQueue, EntryPointRecord, - ConsultTransferAction, - ConsultTransferDestinationType, AddressBookEntry, FetchPaginatedList, Participant, @@ -669,7 +667,7 @@ export interface ConsultTransferPopoverComponentProps { buttonIcon: string; buddyAgents: BuddyDetails[]; loadingBuddyAgents: boolean; - loadBuddyAgents?: (action?: ConsultTransferAction) => Promise; + loadBuddyAgents?: (action?: 'Consult' | 'Transfer') => Promise; getAddressBookEntries?: FetchPaginatedList; getEntryPoints?: FetchPaginatedList; getQueues?: FetchPaginatedList; @@ -677,8 +675,8 @@ export interface ConsultTransferPopoverComponentProps { onQueueSelect: (queueId: string, queueName: string, allowParticipantsToInteract: boolean) => void; onEntryPointSelect: (entryPointId: string, entryPointName: string, allowParticipantsToInteract: boolean) => void; onDialNumberSelect: (dialNumber: string, allowParticipantsToInteract: boolean) => void; - action: ConsultTransferAction; - availableDestinations: ConsultTransferDestinationType[]; + action: 'Consult' | 'Transfer'; + availableDestinations: TaskUIControls['consultTransferDestinations']['consult']; /** Options governing popover visibility/behavior */ consultTransferOptions?: ConsultTransferOptions; isConferenceInProgress?: boolean; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx index c778e99da..981f7a200 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx @@ -2,7 +2,9 @@ import React from 'react'; import '@testing-library/jest-dom'; import {render, fireEvent, act, waitFor} from '@testing-library/react'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; -import {ContactServiceQueue, ConsultTransferDestinationType} from '@webex/cc-store'; +import {ContactServiceQueue, TaskUIControls} from '@webex/cc-store'; + +type AvailableDestinations = TaskUIControls['consultTransferDestinations']['consult']; const mockUIDProps = (container) => { container @@ -58,7 +60,7 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { onDialNumberSelect: jest.fn(), onEntryPointSelect: jest.fn(), action: 'Consult' as const, - availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], + availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as AvailableDestinations, loadingBuddyAgents: false, logger: mockLogger, }; @@ -145,7 +147,7 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const noQueueConsultProps = { ...defaultProps, heading: 'Consult', - availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], + availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as AvailableDestinations, }; let screen; await act(async () => { 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 6f8f850af..8b6ad132e 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 @@ -2,9 +2,11 @@ import React from 'react'; 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 {AddressBookEntry, ContactServiceQueue, ConsultTransferDestinationType, EntryPointRecord} from '@webex/cc-store'; +import {AddressBookEntry, ContactServiceQueue, EntryPointRecord, TaskUIControls} from '@webex/cc-store'; import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; +type AvailableDestinations = TaskUIControls['consultTransferDestinations']['consult']; + const loggerMock = { log: jest.fn(), info: jest.fn(), @@ -60,7 +62,7 @@ describe('ConsultTransferPopoverComponent', () => { onDialNumberSelect: jest.fn(), onEntryPointSelect: jest.fn(), action: 'Consult' as const, - availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], + availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as AvailableDestinations, loadingBuddyAgents: false, logger: loggerMock, }; @@ -227,7 +229,7 @@ describe('ConsultTransferPopoverComponent', () => { it('hides a category omitted by the SDK controls', async () => { const propsWithoutQueue = { ...baseProps, - availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as ConsultTransferDestinationType[], + availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as AvailableDestinations, }; const screen = await render(); @@ -239,7 +241,7 @@ describe('ConsultTransferPopoverComponent', () => { const orderedProps = { ...baseProps, action: 'Transfer' as const, - availableDestinations: ['queue', 'agent', 'entryPoint', 'dialNumber'] as ConsultTransferDestinationType[], + availableDestinations: ['queue', 'agent', 'entryPoint', 'dialNumber'] as AvailableDestinations, }; const screen = await render(); diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 71f6945d5..a1de8b479 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -307,7 +307,7 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via - **Event enums are local copies (`store.types.ts:204-259`):** `CC_EVENTS`/`TASK_EVENTS` string values must match the SDK exactly; an SDK rename will silently stop a handler from firing. - **Pending campaign previews must not become `currentTask`:** `setCurrentTask` clears `currentTask` for a preview in state `new` that is not in `acceptedCampaignIds` (`storeEventsWrapper.ts:255-267`). Bypassing this (e.g. calling SDK methods directly) re-introduces the bug where CallControl renders for an unaccepted preview. - **Listener leaks:** every `task.on(...)` in `registerTaskEventListeners` has a matching `task.off(...)` in `handleTaskRemove`. Adding a listener in one without the other leaks handlers and can double-fire `refreshTaskList`. -- **Task media is SDK-originated but broadly typed on `ITask`:** the store forwards it as `ConsultTransferMediaType`; runtime validation remains SDK-owned, and absent media uses the SDK telephony default. +- **Task media is SDK-originated but broadly typed on `ITask`:** the store validates it against the supported media keys before passing the existing `BuddyAgents` option; absent or unknown media uses the SDK telephony default. - **`@ts-expect-error` markers tie to SDK gaps:** several casts (e.g. `response.teams`, credentials API) are pinned to `CAI-6762`; removing the workaround before the SDK fix breaks the build. ## Module Do's / Don'ts diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 76199c013..bbdf59004 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -11,10 +11,6 @@ import { EntryPointRecord, EntryPointListResponse, EntryPointSearchParams, - ConsultTransferAction, - ConsultTransferDestinationControls, - ConsultTransferDestinationType, - ConsultTransferMediaType, AddressBookEntry, AddressBookEntriesResponse, AddressBookEntrySearchParams, @@ -217,7 +213,7 @@ interface IStoreWrapper extends IStore { onErrorCallback?: (widgetName: string, error: Error) => void; setCurrentTask(task: ITask): void; refreshTaskList(): void; - getBuddyAgents(action?: ConsultTransferAction): Promise; + getBuddyAgents(action?: 'Consult' | 'Transfer'): Promise; getQueues(params?: ContactServiceQueueSearchParams): Promise; getEntryPoints(params?: EntryPointSearchParams): Promise; getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; @@ -357,10 +353,6 @@ export type { EntryPointRecord, EntryPointListResponse, EntryPointSearchParams, - ConsultTransferAction, - ConsultTransferDestinationControls, - ConsultTransferDestinationType, - ConsultTransferMediaType, AddressBookEntry, AddressBookEntriesResponse, AddressBookEntrySearchParams, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index c8d6b8230..658957ce3 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -15,8 +15,6 @@ import { ENGAGED_USERNAME, RESERVED_LABEL, RESERVED_USERNAME, - ConsultTransferAction, - ConsultTransferMediaType, ContactServiceQueuesResponse, ContactServiceQueueSearchParams, EntryPointListResponse, @@ -40,16 +38,24 @@ import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; import {SUGGESTED_RESPONSE_EVENT, TASK_MULTI_LOGIN_HYDRATE} from './constants'; -const CONSULT_TRANSFER_CHANNELS: Record = { +const CONSULT_TRANSFER_CHANNELS = { telephony: 'TELEPHONY', chat: 'CHAT', social: 'SOCIAL_CHANNEL', email: 'EMAIL', +} as const; + +const getSupportedMediaType = (mediaType?: string): keyof typeof CONSULT_TRANSFER_CHANNELS | undefined => { + const normalizedMediaType = typeof mediaType === 'string' ? mediaType.toLowerCase() : ''; + + return normalizedMediaType in CONSULT_TRANSFER_CHANNELS + ? (normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS) + : undefined; }; const getTaskChannelFilter = (entityType: 'queue' | 'entryPoint', mediaType?: string): string | undefined => { - const normalizedMediaType = typeof mediaType === 'string' ? mediaType.toLowerCase() : ''; - const channelType = CONSULT_TRANSFER_CHANNELS[normalizedMediaType as ConsultTransferMediaType]; + const supportedMediaType = getSupportedMediaType(mediaType); + const channelType = supportedMediaType ? CONSULT_TRANSFER_CHANNELS[supportedMediaType] : undefined; if (!channelType || channelType === 'TELEPHONY') return undefined; @@ -1084,12 +1090,12 @@ class StoreWrapper implements IStoreWrapper { }); }; - getBuddyAgents = async (action: ConsultTransferAction = 'Consult'): Promise> => { + getBuddyAgents = async (action: 'Consult' | 'Transfer' = 'Consult'): Promise> => { try { - const mediaType = this.currentTask?.data?.interaction?.mediaType; + const mediaType = getSupportedMediaType(this.currentTask?.data?.interaction?.mediaType); const response = await this.store.cc.getBuddyAgents({ action, - ...(mediaType ? {mediaType: mediaType as ConsultTransferMediaType} : {}), + ...(mediaType ? {mediaType} : {}), }); return 'data' in response ? response.data.agentList : []; } catch (error) { diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index ee428f19f..58848d418 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1020,6 +1020,15 @@ describe('storeEventsWrapper', () => { }); }); + it('should omit an unsupported task media type and use the SDK default', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'video'}}} as ITask; + storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); + + await storeWrapper.getBuddyAgents('Consult'); + + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({action: 'Consult'}); + }); + it('should handle error in getBuddyAgents and throw error', async () => { storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockRejectedValue(new Error('error')); @@ -1079,8 +1088,8 @@ describe('storeEventsWrapper', () => { it('should return contact service queues list when SDK returns paginated response', async () => { const queueList = [ - {id: mockQueueDetails[0].id, name: mockQueueDetails[0].name, dbId: 'queue-db-1'}, - {id: mockQueueDetails[1].id, name: mockQueueDetails[1].name, dbId: 'queue-db-2'}, + {id: mockQueueDetails[0].id, name: mockQueueDetails[0].name}, + {id: mockQueueDetails[1].id, name: mockQueueDetails[1].name}, ]; const response = {data: queueList, meta: {page: 1, pageSize: 50, totalRecords: 2, totalPages: 1}}; storeWrapper['store'].currentTask = null; diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 445b1f227..98e6b3489 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -120,7 +120,7 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | -| `TASK-R-024` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated Agent Desktop policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | +| `TASK-R-024` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated destination policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. diff --git a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts index fd717f3ed..cf6741beb 100644 --- a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts +++ b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts @@ -1,10 +1,4 @@ -import { - ConsultTransferDestinationControls, - getDefaultUIControls, - InteractionUIControls, - TaskUIControls, - TaskUILeg, -} from '@webex/cc-store'; +import {getDefaultUIControls, InteractionUIControls, TaskUIControls, TaskUILeg} from '@webex/cc-store'; const disabledControl = {isVisible: false, isEnabled: false}; const enabledControl = {isVisible: true, isEnabled: true}; @@ -16,7 +10,7 @@ export function createMockTaskUIControls(overrides?: { main?: Partial; consult?: Partial; activeLeg?: TaskUILeg; - consultTransferDestinations?: Partial; + consultTransferDestinations?: Partial; }): TaskUIControls { const base = getDefaultUIControls(); return { From b457fa243a6c31de7854130565113355eadd7aad Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Fri, 21 Aug 2026 13:49:19 +0530 Subject: [PATCH 07/10] fix(contact-center): render destination presence and numbers --- ai-docs/CONTRACTS.md | 4 +- .../spec/feature-spec.md | 46 +- .../call-control-custom.utils.ts | 14 +- .../consult-transfer-list-item.tsx | 8 +- .../consult-transfer-popover.tsx | 10 +- .../src/components/task/task.types.ts | 1 + ...nsult-transfer-list-item.snapshot.tsx.snap | 285 +++---------- ...consult-transfer-popover.snapshot.tsx.snap | 398 ++++-------------- .../call-control-custom.util.tsx | 9 +- .../consult-transfer-list-item.tsx | 31 +- .../consult-transfer-popover.tsx | 42 +- .../store/src/storeEventsWrapper.ts | 16 +- .../store/tests/storeEventsWrapper.ts | 5 +- 13 files changed, 265 insertions(+), 604 deletions(-) diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 797e88a37..9eab3aa13 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -24,7 +24,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | | store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | -| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action` and `availableDestinations` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list and category order and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | +| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action`, `availableDestinations`, and optional list-item `presence` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list/category order, passes buddy availability to Momentum Avatar as semantic presence, shows `AddressBookEntry.number` and `EntryPointRecord.number` subtitles, and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts; optional presentation prop is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md`; `ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | cc-components.E911Modal | `@webex/cc-components` | `E911Modal` and `E911ModalProps` | React component; props `isOpen: boolean`, `onSaveAndContinue: () => Promise`, and `onCancel: () => void`; React-only with no custom-element wrapper | stable semver; adding the export is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | @@ -34,7 +34,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established full-record list types, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | +| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established queue records and entry-point response wrapper with mapped `number`, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index 047e00f19..b1030524b 100644 --- a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -21,7 +21,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi | Work type | Defect | | Change class | Contract / UI | | Source/intake | Developer-approved consult/transfer behavior review and current code/tests | -| Last verified | 2026-08-19 in a working tree based on `69fdb37c` | +| Last verified | 2026-08-21 in the approved SDK/widgets worktrees | ## Applicability @@ -46,7 +46,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi The widgets previously embedded destination policy in the store: buddy agents were always restricted to Available, queues were fetched through the generic SDK API and filtered in memory by channel, and list response metadata was reconstructed. That made behavior inconsistent across consumers and made list order dependent on widget-side transformation. -The goal is for widgets to use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods. Widgets supply the user's action and pagination/search input; the store uses current-task media only to override the SDK's telephony default for an active non-telephony task. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not transform returned rows, choose ordering, or reconstruct pagination semantics. +The goal is for widgets to use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods. Widgets supply the user's action and pagination/search input; the store uses current-task media only to override the SDK's queue default for an active non-telephony task. Entry points delegate directly so the SDK can fetch and map the profile-scoped dial-number records. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not transform returned rows, choose ordering, or reconstruct pagination semantics. ## Stakeholders and open questions @@ -64,7 +64,7 @@ There are no open product decisions for this delta. ### In scope - Pass `Consult` or `Transfer` from the call-control menu and reload action to the SDK through the task hook and store. -- Forward pagination, page size, and search text through the existing list APIs. For an active non-telephony task, the store supplies a complete channel eligibility filter through the existing `filter` parameter because the SDK list methods do not receive Task context. +- Forward pagination, page size, and search text through the existing list APIs. For an active non-telephony task, the store supplies a complete channel eligibility filter only to the queue request because the generic queue method does not receive Task context. Entry-point requests delegate without widget-owned filters. - Load dial numbers through the generic SDK AddressBook service and rely on its default backend ordering. - Preserve the SDK's `data` order and pagination metadata without local sorting, channel filtering, or metadata reconstruction. - Keep loading, empty, and error behavior in the existing widget layers. @@ -82,7 +82,7 @@ There are no open product decisions for this delta. | Source | What it establishes | Decision or disposition | | --- | --- | --- | -| `packages/contact-center/store/src/storeEventsWrapper.ts` | The old store filtered queue data and rebuilt pagination metadata; the new store delegates to the existing SDK methods and only supplies active non-telephony task context as an existing request filter. | Used | +| `packages/contact-center/store/src/storeEventsWrapper.ts` | The old store filtered queue data and rebuilt pagination metadata; the new store delegates to the existing SDK methods and only supplies active non-telephony task context to queue requests through the existing filter. | Used | | `packages/contact-center/task/src/helper.ts` | The task hook owns UI loading/error state and forwards list input. | Used | | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | The selected menu identifies Consult versus Transfer. | Used | | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Reload has the same action context as initial list loading. | Used | @@ -96,11 +96,12 @@ There are no open product decisions for this delta. | --- | --- | --- | --- | --- | --- | --- | | `WIDGET-LIST-R-001` | The store must use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and the existing AddressBook service for dial numbers. It must not filter or sort returned rows or reconstruct pagination metadata. | Reusing established methods and response types keeps the public surface small while SDK-owned defaults prevent policy drift. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK default-policy delta at runtime. | Present | | `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. | Agent eligibility differs by action, so losing the action would silently return the wrong population. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts` | None. | Present | -| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter through the existing `filter` option; for telephony or missing media it must omit the override and use SDK defaults. Dial-number requests forward only pagination/search. | The SDK's generic list methods have no Task argument and cannot infer which concurrent task is being rendered, while the store already owns current-task context. Limiting widget logic to this request override keeps reusable defaults and returned-data decisions in the SDK. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or public request signature is required. | Present | +| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter only to the queue request; for telephony or missing media it omits the queue override. Entry-point and dial-number requests delegate without widget-owned filter or sort policy. | The SDK's generic queue method has no Task argument and cannot infer which concurrent task is being rendered, while entry-point profile/dial-number mapping is reusable SDK policy. This keeps only irreducible queue Task context in the store. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or public request signature is required. | Present | | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | | `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `TaskUIControls`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off action, media, destination-control, destination-list, list-options, or list-response public type may be introduced. | Consumers only need the existing methods, lists, and Task control field. Deriving destination typing from `TaskUIControls` avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior. | Present | | `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags or derive visibility from media/direction/task payload fields; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, and makes the SDK-provided first category the default selection. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Consumers cannot enable a category omitted by the SDK. | Present | +| `WIDGET-LIST-R-008` | Agent rows must pass Momentum Avatar presence `active` for SDK state `Available` and `away` for every other state. Every destination avatar must derive initials from the first character of the first and last non-empty name tokens, using one character for a single-token name. Dial-number and entry-point rows must show their typed SDK `number` as secondary text below the name. | The design-system Avatar owns presence presentation, first/last-token initials keep multi-token destination labels distinguishable, and secondary identifiers distinguish similarly named routable destinations without widget-side response transformation. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Entry-point `number` requires the paired SDK dial-number mapping. | Present | ## Defect context (when applicable) @@ -114,10 +115,10 @@ There are no open product decisions for this delta. ### MOD-001 — Store list delegation (`STORE-R-015`) -- **WHAT**: Replace widget-owned returned-row filtering and metadata reconstruction with delegation to the SDK's existing queue and entry-point methods. Keep dial numbers on the existing AddressBook service. Pass only pagination/search plus a complete non-telephony channel filter when the active Task requires an override, and preserve each SDK response as returned. +- **WHAT**: Replace widget-owned returned-row filtering and metadata reconstruction with delegation to the SDK's existing queue and entry-point methods. Keep dial numbers on the existing AddressBook service. Pass pagination/search plus a complete non-telephony channel filter only to queue requests when the active Task requires an override; delegate entry points without widget policy and preserve each SDK response as returned. - **WHY**: Eligibility, backend query flags, and ordering defaults are reusable domain policy and must not diverge across UI clients. - **Evidence:** `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. -- **Acceptance:** No queue, entry-point, or dial-number returned-data `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. Telephony requests rely on SDK defaults; non-telephony requests use only the existing `filter` option. +- **Acceptance:** No queue, entry-point, or dial-number returned-data `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. Telephony queue requests rely on SDK defaults; non-telephony queue requests use only the existing `filter` option; entry points always delegate directly. ### MOD-002 — Task consult/transfer orchestration (`TASK-R-011` through `TASK-R-014`) @@ -138,9 +139,10 @@ There are no open product decisions for this delta. - [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). - [x] Consult/Transfer category visibility, order, and default selection come from `Task.uiControls`; widgets contain no collaboration-profile/direction policy (`MOD-003`, `WIDGET-LIST-R-007`). - [x] Queue and entry-point fetchers delegate to existing SDK methods, while dial numbers use AddressBook, without local returned-data sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). -- [x] Telephony queue and entry-point requests rely on SDK defaults; active non-telephony requests supply a complete channel filter through the existing request parameter. No list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). +- [x] Telephony queue requests rely on SDK defaults; active non-telephony queue requests supply a complete channel filter through the existing request parameter. Entry-point requests always delegate directly, and no list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). - [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). +- [x] Agent rows show active/away presence from buddy state, and dial-number/entry-point rows show their typed secondary identifiers (`WIDGET-LIST-R-008`). - [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with the coordinated SDK worktree. - [x] The complete cc-components unit suite and the focused consult/transfer suites pass with the locally linked SDK. @@ -148,11 +150,11 @@ There are no open product decisions for this delta. | Scenario | Actor | Preconditions | Expected behavior | Failure or boundary behavior | Requirements | | --- | --- | --- | --- | --- | --- | -| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged. | SDK failure produces an empty agent list and clears loading. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005` | +| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged and each row shows active/away presence from buddy state. | SDK failure produces an empty agent list and clears loading. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | | Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; it does not fall back to Consult. | `WIDGET-LIST-R-002` | | Search Queues | Agent | Active task with media context | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and the response and metadata are preserved. | Telephony or missing media lets SDK defaults apply. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Search Entry Points | Agent | Entry-point tab visible and active task may carry media | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and backend order is rendered. | Telephony or missing media uses SDK defaults; failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | -| Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook and its SDK-default backend order is rendered. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005` | +| Search Entry Points | Agent | Entry-point tab visible | Page/search reach the existing SDK method without widget policy; backend order is rendered, and typed `number` appears below the name when present. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | +| Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook, its SDK-default backend order is rendered, and `number` appears below the name. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | ### Interaction and scenario matrix @@ -161,13 +163,13 @@ There are no open product decisions for this delta. | Consult + Agents | Open or reload | `loadBuddyAgents('Consult')` | Applying Transfer-only availability filtering | `WIDGET-LIST-R-002` | | Transfer + Agents | Open or reload | `loadBuddyAgents('Transfer')` | Reloading with the default Consult action | `WIDGET-LIST-R-002` | | Queue + non-telephony current task media | Fetch/search/page | Store calls existing `getQueues` with a complete channel filter and preserves SDK order | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Telephony or no current task media | Queue or entry-point fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | -| Entry point + non-telephony current task media | Fetch/search/page | Store calls existing `getEntryPoints` with a complete channel filter and preserves SDK order | Widget filters returned rows or supplies sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Telephony or no current task media | Queue fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | +| Entry point | Fetch/search/page | Store calls existing `getEntryPoints` directly and preserves SDK rows/order | Widget filters returned rows or supplies query policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | | Dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | ### UI flow and design -The visible popover, tabs, row presentation, pagination, loading indicators, empty states, and accessibility labels do not change. The only UI contract change is that initial and reload actions carry the active Consult/Transfer intent. The rendered list order is exactly the SDK response order. +The popover, tabs, pagination, loading indicators, empty states, and accessibility labels remain unchanged. Agent avatars pass semantic active/away presence derived directly from `BuddyDetails.state` to the Momentum Avatar; dial-number and entry-point rows show their typed `number` below the name. The rendered list order remains exactly the SDK response order. ### API contract delta @@ -175,8 +177,9 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp | --- | --- | --- | --- | --- | | Store buddy-agent loader | Accepts optional `Consult`/`Transfer` action instead of a media argument. | Task and component layers pass user intent. | Coordinated widgets release required. | `packages/contact-center/store/src/store.types.ts` | | Store queue loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates to `getQueues`; the store adds a non-telephony filter from Task context when needed. | Callers keep the established queue list contract. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | -| Store entry-point loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates to `getEntryPoints`; the store adds a non-telephony filter from Task context when needed. | Callers keep the established entry-point list contract. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | +| Store entry-point loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates directly to `getEntryPoints`. | Callers keep the established entry-point list contract and receive SDK-mapped numbers. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | | Store dial-number loader | Delegates pagination/search to the generic SDK AddressBook service. | The SDK default supplies backend name ordering. | Existing SDK surface with a corrected default. | `packages/contact-center/store/src/store.types.ts` | +| Consult/transfer list-item props | Adds optional semantic `presence` passed directly to Momentum Avatar. | Agent rows expose SDK availability while leaving visual presentation to the design system. | Additive optional prop. | `packages/contact-center/cc-components/src/components/task/task.types.ts` | ### Public API and semver impact @@ -184,12 +187,13 @@ The visible popover, tabs, row presentation, pagination, loading indicators, emp | --- | --- | --- | --- | --- | | `@webex/cc-store` loader types | Buddy loading carries the existing Consult/Transfer action context; queue and entry-point loaders use existing SDK request/response types. | Internal widget packages and any direct store consumer | Semver-sensitive only for the buddy action correction; queue and entry-point list shapes remain established. | Direct buddy-loader consumers pass action; queue and entry-point consumers need no new API. | | `@webex/cc-components` call-control loader prop | Optional action parameter | Call-control consumers | Additive callback argument for compatible functions; coordinate typings | Consumers may ignore the argument, but action-aware loaders should use it. | +| `@webex/cc-components` list-item props | Optional `active`/`away` `presence` value | Internal list rows and direct type consumers | Additive optional prop | Consumers that omit it retain the plain avatar. | ### Cross-package impact | Package | Change | Dependency direction | Release sequencing | Owner | | --- | --- | --- | --- | --- | -| `@webex/contact-center` | Applies consult/transfer defaults through existing `getBuddyAgents`, `getQueues`, and `getEntryPoints`, and returns the existing full record types. | SDK → store | Build/link first. | SDK maintainers | +| `@webex/contact-center` | Applies consult/transfer defaults through existing `getBuddyAgents`, `getQueues`, and `getEntryPoints`; Queue returns full records and EntryPoint maps EP-DN rows through its existing response wrapper. | SDK → store | Build/link first. | SDK maintainers | | `@webex/cc-store` | Thin delegation and typed boundary. | store → SDK | Release with a compatible SDK version. | Widgets maintainers | | `@webex/cc-task` | Carries action and pagination/search. | task → store | Release after store types. | Widgets maintainers | | `@webex/cc-components` | Carries menu action on open/reload. | components → task callback | Release with task package. | Widgets maintainers | @@ -217,9 +221,11 @@ No event contract changes. | --- | --- | --- | --- | --- | | Buddy-agent request | `action`, optional current task `mediaType` | Distinguish Consult and Transfer eligibility. | SDK contract; widgets supply runtime context. | No new retained data or credentials. | | Queue list request | Existing queue search parameters; store adds a complete filter only for an active non-telephony Task. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context. | No new method or signature; no widget-side returned-data filtering. | -| Entry-point list request | Existing entry-point search parameters; store adds a complete filter only for an active non-telephony Task. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context. | No new method or signature; no widget-selected projection, view, or sort flags. | +| Entry-point list request | Existing entry-point search parameters, delegated without widget-owned filter policy. | Paginated destination discovery with mapped dialled numbers. | SDK owns the profile-scoped dial-number query, row mapping, and ordering defaults. | No new method or signature; no widget-selected filter, projection, view, or sort flags. | | Dial-number list request | `page`, `pageSize`, `search` | Paginated address-book destination discovery. | AddressBook owns backend name ordering. | No widget-owned sort flags. | -| Queue/entry-point paginated response | Existing `ContactServiceQueuesResponse` / `EntryPointListResponse` with full `ContactServiceQueue` / `EntryPointRecord` rows and `meta` unchanged. | Preserve backend order, established typing, and pagination truth. | SDK/backend | Widgets must not project rows or reconstruct metadata. | +| Buddy-agent row | `state` | Pass Avatar presence `active` only for `Available`; pass `away` otherwise. | SDK response owns agent state; Momentum owns presence presentation. | No identity or state value is logged or retained. | +| Dial-number / entry-point row | `AddressBookEntry.number` / optional `EntryPointRecord.number` | Show a secondary routable identifier below each destination name. | SDK response owns the fields; cc-components renders them unchanged. | Entry-point numbers come from the SDK's dial-number mapping. | +| Queue/entry-point paginated response | Existing `ContactServiceQueuesResponse` with full `ContactServiceQueue` rows and `EntryPointListResponse` with SDK-mapped `{id, name, number?}` `EntryPointRecord` rows; `meta` remains unchanged. | Preserve backend order, established typing, and pagination truth. | SDK/backend | Widgets must not project rows or reconstruct metadata. | ## Impacted domains @@ -283,13 +289,15 @@ No event contract changes. ## Documentation obligations - This approved delta modifies `STORE-R-015`, the task consult/transfer requirement family, and `CC-COMPONENTS-R-006` without overwriting the draft canonical module specs. -- The paired SDK feature spec remains the canonical owner for reusable telephony filter, ordering, profile-view, and cache-bypass defaults. This widget delta owns only the active non-telephony Task filter override needed because generic list calls carry no Task context. +- The paired SDK feature spec remains the canonical owner for reusable queue eligibility/order/profile defaults and entry-point mapping/order/cache behavior. This widget delta owns only the active non-telephony queue filter override needed because the generic queue call carries no Task context. - A future canonical-spec promotion must fold this delta into the routed module specs and reconcile the delta path rather than duplicate the requirements. ## Decision and change log | Date | Decision or change | Rationale | Owner | | --- | --- | --- | --- | +| 2026-08-21 | Changed destination initials from the first two tokens to the first and last non-empty tokens. | Multi-token destination labels must yield compact, distinguishable initials such as `Queue e2e 1 → Q1` and `Entry point e2e set 1 → E1`. | Developer + Codex | +| 2026-08-21 | Mapped SDK availability to the Momentum Avatar's built-in `active`/`away` presence and removed widget-owned icon/color/position CSS. The provisional entry-point `dbId` subtitle was replaced with SDK-mapped `EntryPointRecord.number`, and entry-point requests now delegate without a task-media filter. | Figma and the installed design-system API assign presence presentation to Avatar, while the backend's dial-number mapping supplies the visible entry-point number and owns its query policy. | Developer + Codex | | 2026-08-19 | Removed dependencies on new action/media/destination aliases and typed widget destinations from `TaskUIControls`; removed the queue `dbId` passthrough fixture. | Widgets need the existing methods, entity records, and Task control field only; deriving types prevents an unnecessary public SDK surface. | Developer + Codex | | 2026-08-19 | Approved this exact MODIFIED delta path. | Avoid overwriting draft canonical module specs while keeping spec-currency with the implementation. | Developer | | 2026-08-19 | Assigned reusable list policy to the SDK and retained only UI/runtime context in widgets. | Prevent policy duplication and ordering drift. | Developer + Codex | 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 1bc2f21f9..e8c87945f 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 @@ -116,12 +116,14 @@ export const getVisibleButtons = (buttons: ButtonConfig[], logger?): ButtonConfi */ export const createInitials = (name: string, logger?): string => { try { - return name - .split(' ') - .map((word) => word[0]) - .join('') - .slice(0, 2) - .toUpperCase(); + const words = name.trim().split(/\s+/).filter(Boolean); + + if (words.length === 0) { + return ''; + } + + const lastInitial = words.length > 1 ? words[words.length - 1][0] : ''; + return `${words[0][0]}${lastInitial}`.toUpperCase(); } catch (error) { logger?.error('CC-Widgets: CallControlCustom: Error in createInitials', { module: 'cc-components#call-control-custom.utils.ts', diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index 3440f3cc4..e21ee42d3 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import {ListItemBase, ListItemBaseSection, AvatarNext, Text, ButtonCircle} from '@momentum-ui/react-collaboration'; -import {Icon} from '@momentum-design/components/dist/react'; +import {ListItemBase, ListItemBaseSection, Text, ButtonCircle} from '@momentum-ui/react-collaboration'; +import {Avatar, Icon} from '@momentum-design/components/dist/react'; import classnames from 'classnames'; import {ConsultTransferListComponentProps} from '../../task.types'; import {createInitials, handleListItemPress} from './call-control-custom.utils'; const ConsultTransferListComponent: React.FC = (props) => { - const {title, subtitle, buttonIcon, onButtonPress, className, logger} = props; + const {title, subtitle, presence, buttonIcon, onButtonPress, className, logger} = props; const initials = createInitials(title); @@ -17,7 +17,7 @@ const ConsultTransferListComponent: React.FC return ( - + 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 3664a5e4d..b1a0edb55 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 @@ -88,16 +88,21 @@ const ConsultTransferPopoverComponent: React.FC(false); - const renderList = ( + const renderList = ( items: T[], onButtonPress: (item: T) => void ) => ( {items.map((item) => ( -
    e.stopPropagation()} className="consult-list-item-wrapper"> +
    e.stopPropagation()} + className="consult-list-item-wrapper" + > onButtonPress(item)} logger={logger} @@ -233,6 +238,7 @@ const ConsultTransferPopoverComponent: React.FC ({ id: agent.agentId, name: agent.agentName, + presence: agent.state?.toLowerCase() === 'available' ? ('active' as const) : ('away' as const), })), (item) => handleAgentSelection(item.id, item.name, allowParticipantsToInteract, onAgentSelect, logger) ) diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 2a1d27c03..622060562 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -641,6 +641,7 @@ export type OutdialCallComponentProps = Pick< export interface ConsultTransferListComponentProps { title: string; subtitle?: string; + presence?: 'active' | 'away'; buttonIcon: string; onButtonPress: () => void; className?: string; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap index 8ed318e08..02ddcc9a9 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap @@ -17,21 +17,10 @@ exports[`ConsultTransferListComponent Snapshots Interactions should render compo class="call-control-list-item-start" data-position="start" > - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    - +
    { expect(createInitials('John')).toBe('J'); }); - it('should create initials from multiple names, taking first two', () => { - expect(createInitials('John Michael Doe')).toBe('JM'); + it('should create initials from the first and last words', () => { + expect(createInitials('John Michael Doe')).toBe('JD'); + }); + + it('should use the first and last tokens for multi-token destination initials', () => { + expect(createInitials('Queue e2e 1')).toBe('Q1'); + expect(createInitials('Entry point e2e set 1')).toBe('E1'); }); it('should handle empty string', () => { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index 11652b518..3b8d22345 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -3,6 +3,11 @@ import {render, fireEvent} from '@testing-library/react'; import '@testing-library/jest-dom'; import ConsultTransferListComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-list-item'; +type AvatarElement = HTMLElement & { + initials?: string; + presence?: 'active' | 'away'; +}; + const loggerMock = { log: jest.fn(), info: jest.fn(), @@ -54,18 +59,11 @@ describe('CallControlListItemPresentational', () => { expect(listItem).toHaveAttribute('tabindex', '0'); // Verify avatar section - const avatarWrapper = screen.container.querySelector('.md-avatar-wrapper'); - expect(avatarWrapper).toBeInTheDocument(); - expect(avatarWrapper).toHaveAttribute('role', 'img'); - expect(avatarWrapper).toHaveAttribute('data-size', '32'); - expect(avatarWrapper).toHaveAttribute('data-color', 'default'); - expect(avatarWrapper).toHaveAttribute('aria-hidden', 'false'); - - // Verify initials display - const initialsSpan = screen.container.querySelector('.md-avatar-wrapper-children'); - expect(initialsSpan).toBeInTheDocument(); - expect(initialsSpan).toHaveTextContent('JD'); - expect(initialsSpan).toHaveAttribute('aria-hidden', 'true'); + const avatar = screen.container.querySelector('mdc-avatar') as AvatarElement; + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('aria-hidden', 'true'); + expect(avatar.initials).toBe('JD'); + expect(avatar).toHaveAttribute('size', '32'); // Verify middle section with title and subtitle (class-based layout) const middleSection = screen.container.querySelector('[data-position="middle"]'); @@ -132,4 +130,13 @@ describe('CallControlListItemPresentational', () => { expect(titleOnlyElements).toHaveLength(1); expect(titleOnlyElements[0]).toHaveTextContent('John Doe'); }); + + it.each([['active' as const], ['away' as const]])('passes %s presence to the avatar', (presence) => { + const screen = render(); + const listItem = screen.container.querySelector('.call-control-list-item'); + const avatar = screen.container.querySelector('mdc-avatar') as AvatarElement; + + expect(listItem).toHaveAttribute('aria-label', 'John Doe'); + expect(avatar.presence).toBe(presence); + }); }); 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 8b6ad132e..72acb140b 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 @@ -45,7 +45,7 @@ describe('ConsultTransferPopoverComponent', () => { agentId: 'agent2', agentName: 'Agent Two', dn: '1002', - state: 'Available', + state: 'Idle', teamId: 'team1', siteId: 'site1', }, @@ -111,6 +111,11 @@ describe('ConsultTransferPopoverComponent', () => { expect(listItems[0]).toHaveTextContent('Agent One'); expect(listItems[1]).toHaveTextContent('Agent Two'); + const availableAvatar = listItems[0].querySelector('mdc-avatar') as HTMLElement & {presence?: string}; + const awayAvatar = listItems[1].querySelector('mdc-avatar') as HTMLElement & {presence?: string}; + expect(availableAvatar.presence).toBe('active'); + expect(awayAvatar.presence).toBe('away'); + // Verify list item wrappers render const listItemContainers = screen.container.querySelectorAll('.consult-list-item-wrapper'); expect(listItemContainers).toHaveLength(2); @@ -140,6 +145,41 @@ describe('ConsultTransferPopoverComponent', () => { expect(mockOnQueueSelect).toHaveBeenCalledWith('queue1', 'Queue One', false); }); + it('shows the number below dial-number and entry-point names', async () => { + const screen = render( + ({ + data: [ + { + id: 'dn1', + name: 'Dial Number One', + number: '12345', + } as AddressBookEntry, + ], + meta: {page: 0, totalPages: 1}, + })} + getEntryPoints={async () => ({ + data: [ + { + id: 'ep1', + name: 'Entry Point One', + number: '67890', + } as EntryPointRecord, + ], + meta: {page: 0, totalPages: 1}, + })} + /> + ); + + fireEvent.click(screen.getByRole('button', {name: 'Dial Number'})); + await waitFor(() => expect(screen.getByText('12345')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', {name: 'Entry Point'})); + await waitFor(() => expect(screen.getByText('67890')).toBeInTheDocument()); + }); + it('hides Dial Number tab when consultTransferOptions.showDialNumberTab is false', async () => { const screen = await render( diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index be38da304..6d667107a 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -54,15 +54,13 @@ const getSupportedMediaType = (mediaType?: string): keyof typeof CONSULT_TRANSFE : undefined; }; -const getTaskChannelFilter = (entityType: 'queue' | 'entryPoint', mediaType?: string): string | undefined => { +const getTaskQueueChannelFilter = (mediaType?: string): string | undefined => { const supportedMediaType = getSupportedMediaType(mediaType); const channelType = supportedMediaType ? CONSULT_TRANSFER_CHANNELS[supportedMediaType] : undefined; if (!channelType || channelType === 'TELEPHONY') return undefined; - const typeField = entityType === 'queue' ? 'queueType' : 'entryPointType'; - - return `${typeField}==INBOUND;channelType==${channelType};active==true`; + return `queueType==INBOUND;channelType==${channelType};active==true`; }; class StoreWrapper implements IStoreWrapper { @@ -1283,7 +1281,7 @@ class StoreWrapper implements IStoreWrapper { getQueues = async (params?: ContactServiceQueueSearchParams): Promise => { try { const mediaType = this.currentTask?.data?.interaction?.mediaType; - const filter = getTaskChannelFilter('queue', mediaType); + const filter = getTaskQueueChannelFilter(mediaType); return await this.store.cc.getQueues({ ...(filter ? {filter} : {}), @@ -1297,13 +1295,7 @@ class StoreWrapper implements IStoreWrapper { getEntryPoints = async (params?: EntryPointSearchParams): Promise => { try { - const mediaType = this.currentTask?.data?.interaction?.mediaType; - const filter = getTaskChannelFilter('entryPoint', mediaType); - - return await this.store.cc.getEntryPoints({ - ...(filter ? {filter} : {}), - ...(params ?? {}), - }); + return await this.store.cc.getEntryPoints(params); } catch (error) { this.store.logger.error('Error fetching entry points:', error); throw error; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 3f4736b04..02897192e 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1120,7 +1120,6 @@ describe('storeEventsWrapper', () => { }); it('should fetch entry points successfully', async () => { - storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); @@ -1131,8 +1130,7 @@ describe('storeEventsWrapper', () => { expect(result).toEqual(mockEntryPointsResponse); }); - it('should use the existing entry-point filter parameter for a non-telephony task', async () => { - storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'chat'}}} as ITask; + it('should delegate entry-point parameters without widget-owned media filtering', async () => { storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue({ data: [], meta: {page: 0, totalPages: 0}, @@ -1141,7 +1139,6 @@ describe('storeEventsWrapper', () => { await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ - filter: 'entryPointType==INBOUND;channelType==CHAT;active==true', page: 0, pageSize: 25, }); From af2eb4aa326ccd7f590d092fbe950925c780b2b8 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Fri, 21 Aug 2026 15:29:37 +0530 Subject: [PATCH 08/10] fix(contact-center): finalize consult transfer list delegation --- ai-docs/CONTRACTS.md | 1 - .../spec/feature-spec.md | 46 +++++++++------ .../consult-transfer-popover-hooks.ts | 12 +++- .../task/CallControl/call-control.tsx | 18 +++--- .../consult-transfer-popover.tsx | 44 +++++++++++++++ .../task/CallControl/call-control.tsx | 41 ++++++++++++++ .../contact-center/store/src/store.types.ts | 8 ++- .../store/src/storeEventsWrapper.ts | 50 ++++++++++++----- .../store/tests/storeEventsWrapper.ts | 55 ++++++++++++++++++ packages/contact-center/task/src/helper.ts | 24 +++++--- packages/contact-center/task/tests/helper.ts | 56 ++++++++++++++++--- .../test-fixtures/src/fixtures.ts | 1 - 12 files changed, 295 insertions(+), 61 deletions(-) diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 9eab3aa13..07c59c20f 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -25,7 +25,6 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | | store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | | cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action`, `availableDestinations`, and optional list-item `presence` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list/category order, passes buddy availability to Momentum Avatar as semantic presence, shows `AddressBookEntry.number` and `EntryPointRecord.number` subtitles, and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts; optional presentation prop is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md`; `ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | -| cc-components.E911Modal | `@webex/cc-components` | `E911Modal` and `E911ModalProps` | React component; props `isOpen: boolean`, `onSaveAndContinue: () => Promise`, and `onCancel: () => void`; React-only with no custom-element wrapper | stable semver; adding the export is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

    (Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index b1030524b..75ef5b460 100644 --- a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -95,13 +95,15 @@ There are no open product decisions for this delta. | ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence | | --- | --- | --- | --- | --- | --- | --- | | `WIDGET-LIST-R-001` | The store must use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and the existing AddressBook service for dial numbers. It must not filter or sort returned rows or reconstruct pagination metadata. | Reusing established methods and response types keeps the public surface small while SDK-owned defaults prevent policy drift. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK default-policy delta at runtime. | Present | -| `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. | Agent eligibility differs by action, so losing the action would silently return the wrong population. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts` | None. | Present | -| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter only to the queue request; for telephony or missing media it omits the queue override. Entry-point and dial-number requests delegate without widget-owned filter or sort policy. | The SDK's generic queue method has no Task argument and cannot infer which concurrent task is being rendered, while entry-point profile/dial-number mapping is reusable SDK policy. This keeps only irreducible queue Task context in the store. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or public request signature is required. | Present | +| `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. The store must also continue accepting the established media-type call form for direct consumers. | Agent eligibility differs by action, so losing the action would silently return the wrong population; retaining the old call form avoids an unnecessary breaking store change. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts`, `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts` | None. | Present | +| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter only to the queue request and combine it with any caller filter; params-only telephony or missing-media calls omit that override. The queue loader must accept both the thin params-only form and the established media-plus-params form; the legacy form retains explicit channel scoping, and params-only calls preserve an explicit empty filter. Entry-point and dial-number requests delegate without widget-owned filter or sort policy. | The SDK's generic queue method has no Task argument and cannot infer which concurrent task is being rendered, while entry-point profile/dial-number mapping is reusable SDK policy. Combining filters prevents caller input from erasing required Task scope, and exact compatibility handling avoids breaking existing store consumers or swallowing an SDK override. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or response type is required. | Present | | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | | `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `TaskUIControls`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off action, media, destination-control, destination-list, list-options, or list-response public type may be introduced. | Consumers only need the existing methods, lists, and Task control field. Deriving destination typing from `TaskUIControls` avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior. | Present | -| `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags or derive visibility from media/direction/task payload fields; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, and makes the SDK-provided first category the default selection. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Consumers cannot enable a category omitted by the SDK. | Present | +| `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags, derive visibility from media/direction/task payload fields, or fetch buddy agents when Agents is omitted; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, makes the SDK-provided first category the default selection, and avoids loading data for a disallowed category. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl` | Consumers cannot enable a category omitted by the SDK. | Present | | `WIDGET-LIST-R-008` | Agent rows must pass Momentum Avatar presence `active` for SDK state `Available` and `away` for every other state. Every destination avatar must derive initials from the first character of the first and last non-empty name tokens, using one character for a single-token name. Dial-number and entry-point rows must show their typed SDK `number` as secondary text below the name. | The design-system Avatar owns presence presentation, first/last-token initials keep multi-token destination labels distinguishable, and secondary identifiers distinguish similarly named routable destinations without widget-side response transformation. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Entry-point `number` requires the paired SDK dial-number mapping. | Present | +| `WIDGET-LIST-R-009` | Each paginated destination list must apply state only from its newest request; starting a newer search or resetting the category invalidates older in-flight responses. | A slower unfiltered page must not overwrite a newer filtered response and make the UI appear to ignore entry-point, queue, or dial-number search. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | The underlying request is not cancelled; its stale result is ignored. | Present | +| `WIDGET-LIST-R-010` | Buddy-agent state and loading must be updated only by the newest request, including when the action switches between Consult and Transfer while a request is in flight. | A slower Consult response must not overwrite the Transfer population currently selected by the user. | `packages/contact-center/task/src/helper.ts` | `packages/contact-center/task/tests/helper.ts` | The underlying request is not cancelled; its stale result is ignored. | Present | ## Defect context (when applicable) @@ -115,17 +117,17 @@ There are no open product decisions for this delta. ### MOD-001 — Store list delegation (`STORE-R-015`) -- **WHAT**: Replace widget-owned returned-row filtering and metadata reconstruction with delegation to the SDK's existing queue and entry-point methods. Keep dial numbers on the existing AddressBook service. Pass pagination/search plus a complete non-telephony channel filter only to queue requests when the active Task requires an override; delegate entry points without widget policy and preserve each SDK response as returned. +- **WHAT**: Replace widget-owned returned-row filtering and metadata reconstruction with delegation to the SDK's existing queue and entry-point methods. Keep dial numbers on the existing AddressBook service. Pass pagination/search plus a complete non-telephony channel filter only to queue requests when the active Task requires an override, combine that scope with a caller filter, and preserve the established media-plus-params store call as a compatibility overload; delegate entry points without widget policy and preserve each SDK response as returned. - **WHY**: Eligibility, backend query flags, and ordering defaults are reusable domain policy and must not diverge across UI clients. - **Evidence:** `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. -- **Acceptance:** No queue, entry-point, or dial-number returned-data `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. Telephony queue requests rely on SDK defaults; non-telephony queue requests use only the existing `filter` option; entry points always delegate directly. +- **Acceptance:** No queue, entry-point, or dial-number returned-data `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. Telephony queue requests rely on SDK defaults; non-telephony queue requests use only the existing `filter` option without losing a caller filter; both queue call forms work; entry points always delegate directly. ### MOD-002 — Task consult/transfer orchestration (`TASK-R-011` through `TASK-R-014`) -- **WHAT**: Extend the existing consult/transfer flow so list loading carries `Consult` or `Transfer`; queue loading no longer derives and passes an independent media argument from the hook. +- **WHAT**: Extend the existing consult/transfer flow so list loading carries `Consult` or `Transfer`; preserve the store's legacy media-type buddy call for compatibility; make buddy state latest-request-wins; queue loading no longer derives and passes an independent media argument from the hook. - **WHY**: The action affects buddy-agent eligibility, while media and request policy must be resolved once at the store/SDK boundary. - **Evidence:** `packages/contact-center/task/src/helper.ts`, `packages/contact-center/task/tests/helper.ts`. -- **Acceptance:** Transfer loading reaches the store as `Transfer`, Consult remains the default, and paginated queue inputs contain only page, page size, and search. +- **Acceptance:** Transfer loading reaches the store as `Transfer`, Consult remains the default, legacy media callers keep working, a late request from the prior action cannot overwrite the current agent list, and paginated queue inputs contain only page, page size, and search. ### MOD-003 — Call-control action context (`CC-COMPONENTS-R-006`) @@ -139,10 +141,12 @@ There are no open product decisions for this delta. - [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). - [x] Consult/Transfer category visibility, order, and default selection come from `Task.uiControls`; widgets contain no collaboration-profile/direction policy (`MOD-003`, `WIDGET-LIST-R-007`). - [x] Queue and entry-point fetchers delegate to existing SDK methods, while dial numbers use AddressBook, without local returned-data sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). -- [x] Telephony queue requests rely on SDK defaults; active non-telephony queue requests supply a complete channel filter through the existing request parameter. Entry-point requests always delegate directly, and no list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). +- [x] Params-only telephony queue requests rely on SDK defaults; active non-telephony requests supply a complete channel filter, and legacy media-plus-params calls preserve their channel scope and caller filter. Entry-point requests always delegate directly, and no list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). - [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). - [x] Agent rows show active/away presence from buddy state, and dial-number/entry-point rows show their typed secondary identifiers (`WIDGET-LIST-R-008`). +- [x] A late unfiltered destination response cannot overwrite the newest search result, and category resets invalidate pending list responses (`WIDGET-LIST-R-009`). +- [x] A late buddy-agent response from a previous Consult/Transfer action cannot overwrite the current action's list or loading state (`WIDGET-LIST-R-010`). - [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with the coordinated SDK worktree. - [x] The complete cc-components unit suite and the focused consult/transfer suites pass with the locally linked SDK. @@ -150,10 +154,10 @@ There are no open product decisions for this delta. | Scenario | Actor | Preconditions | Expected behavior | Failure or boundary behavior | Requirements | | --- | --- | --- | --- | --- | --- | -| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged and each row shows active/away presence from buddy state. | SDK failure produces an empty agent list and clears loading. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | -| Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; it does not fall back to Consult. | `WIDGET-LIST-R-002` | +| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged and each row shows active/away presence from buddy state. | SDK failure produces an empty agent list and clears loading; a stale request cannot replace newer state. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008`, `WIDGET-LIST-R-010` | +| Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; a late Consult response cannot replace Transfer results. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-010` | | Search Queues | Agent | Active task with media context | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and the response and metadata are preserved. | Telephony or missing media lets SDK defaults apply. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Search Entry Points | Agent | Entry-point tab visible | Page/search reach the existing SDK method without widget policy; backend order is rendered, and typed `number` appears below the name when present. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | +| Search Entry Points | Agent | Entry-point tab visible | Page/search reach the existing SDK method without widget policy; backend order is rendered, typed `number` appears below the name when present, and only the latest request may update the list. | Failure becomes the existing empty paginated result; a late older response is ignored. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008`, `WIDGET-LIST-R-009` | | Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook, its SDK-default backend order is rendered, and `number` appears below the name. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | ### Interaction and scenario matrix @@ -163,9 +167,11 @@ There are no open product decisions for this delta. | Consult + Agents | Open or reload | `loadBuddyAgents('Consult')` | Applying Transfer-only availability filtering | `WIDGET-LIST-R-002` | | Transfer + Agents | Open or reload | `loadBuddyAgents('Transfer')` | Reloading with the default Consult action | `WIDGET-LIST-R-002` | | Queue + non-telephony current task media | Fetch/search/page | Store calls existing `getQueues` with a complete channel filter and preserves SDK order | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Telephony or no current task media | Queue fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | +| Telephony or no current task media + params-only call | Queue fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | | Entry point | Fetch/search/page | Store calls existing `getEntryPoints` directly and preserves SDK rows/order | Widget filters returned rows or supplies query policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | | Dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Any paginated category with an older request in flight | Search or category reset starts newer request state | Only the newest response may update data, pagination, and loading state | Older unfiltered data replaces the current filtered list | `WIDGET-LIST-R-009` | +| Consult buddy request in flight | User opens or reloads Transfer Agents | Only the Transfer request may update agents and loading state | Late Consult response replaces the Transfer list | `WIDGET-LIST-R-010` | ### UI flow and design @@ -175,8 +181,8 @@ The popover, tabs, pagination, loading indicators, empty states, and accessibili | API or operation | Change | Consumer impact | Compatibility expectation | Canonical definition | | --- | --- | --- | --- | --- | -| Store buddy-agent loader | Accepts optional `Consult`/`Transfer` action instead of a media argument. | Task and component layers pass user intent. | Coordinated widgets release required. | `packages/contact-center/store/src/store.types.ts` | -| Store queue loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates to `getQueues`; the store adds a non-telephony filter from Task context when needed. | Callers keep the established queue list contract. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | +| Store buddy-agent loader | Adds a `Consult`/`Transfer` action overload while retaining the established optional media-type form. | Task and component layers pass user intent; existing direct store callers remain valid. | Additive overload; coordinated SDK behavior update required. | `packages/contact-center/store/src/store.types.ts` | +| Store queue loader | Adds a thin params-only overload while retaining the established media-plus-params form, then delegates to SDK `getQueues`; the store adds a non-telephony filter from Task context when needed. | Widget callers omit redundant media plumbing; existing direct store callers remain valid. | Additive overload; no new SDK method or response type. | `packages/contact-center/store/src/store.types.ts` | | Store entry-point loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates directly to `getEntryPoints`. | Callers keep the established entry-point list contract and receive SDK-mapped numbers. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | | Store dial-number loader | Delegates pagination/search to the generic SDK AddressBook service. | The SDK default supplies backend name ordering. | Existing SDK surface with a corrected default. | `packages/contact-center/store/src/store.types.ts` | | Consult/transfer list-item props | Adds optional semantic `presence` passed directly to Momentum Avatar. | Agent rows expose SDK availability while leaving visual presentation to the design system. | Additive optional prop. | `packages/contact-center/cc-components/src/components/task/task.types.ts` | @@ -185,7 +191,7 @@ The popover, tabs, pagination, loading indicators, empty states, and accessibili | Export or entry point | Change | Affected consumers | Required version change | Deprecation or migration | | --- | --- | --- | --- | --- | -| `@webex/cc-store` loader types | Buddy loading carries the existing Consult/Transfer action context; queue and entry-point loaders use existing SDK request/response types. | Internal widget packages and any direct store consumer | Semver-sensitive only for the buddy action correction; queue and entry-point list shapes remain established. | Direct buddy-loader consumers pass action; queue and entry-point consumers need no new API. | +| `@webex/cc-store` loader types | Buddy loading adds Consult/Transfer action context and queue loading adds a params-only form; both established call forms remain accepted. Entry-point loaders retain existing SDK request/response types. | Internal widget packages and any direct store consumer | Additive overloads; queue and entry-point list shapes remain established. | No required direct-consumer migration; new widget code should use action and params-only forms. | | `@webex/cc-components` call-control loader prop | Optional action parameter | Call-control consumers | Additive callback argument for compatible functions; coordinate typings | Consumers may ignore the argument, but action-aware loaders should use it. | | `@webex/cc-components` list-item props | Optional `active`/`away` `presence` value | Internal list rows and direct type consumers | Additive optional prop | Consumers that omit it retain the plain avatar. | @@ -220,7 +226,7 @@ No event contract changes. | Entity or payload | Requested field or shape | Purpose | Ownership | Privacy, retention, or compatibility constraint | | --- | --- | --- | --- | --- | | Buddy-agent request | `action`, optional current task `mediaType` | Distinguish Consult and Transfer eligibility. | SDK contract; widgets supply runtime context. | No new retained data or credentials. | -| Queue list request | Existing queue search parameters; store adds a complete filter only for an active non-telephony Task. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context. | No new method or signature; no widget-side returned-data filtering. | +| Queue list request | Existing queue search parameters; params-only calls add a complete filter only for an active non-telephony Task, while the legacy media form retains explicit channel scope. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context and legacy-call compatibility. | No new method or response; no widget-side returned-data filtering. | | Entry-point list request | Existing entry-point search parameters, delegated without widget-owned filter policy. | Paginated destination discovery with mapped dialled numbers. | SDK owns the profile-scoped dial-number query, row mapping, and ordering defaults. | No new method or signature; no widget-selected filter, projection, view, or sort flags. | | Dial-number list request | `page`, `pageSize`, `search` | Paginated address-book destination discovery. | AddressBook owns backend name ordering. | No widget-owned sort flags. | | Buddy-agent row | `state` | Pass Avatar presence `active` only for `Available`; pass `away` otherwise. | SDK response owns agent state; Momentum owns presence presentation. | No identity or state value is logged or retained. | @@ -243,6 +249,8 @@ No event contract changes. | Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Coordinate the SDK dependency/release; use the approved local worktree link for testing. | | A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | | Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | +| A slow initial destination request completes after search and restores the full list. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | Track the newest request per paginated category and ignore state updates from older responses. | +| A Consult buddy request completes after the user switches to Transfer. | `packages/contact-center/task/src/helper.ts` | Track the newest buddy request and ignore older data, error fallback, and loading-state updates. | ## Error Matrix @@ -256,6 +264,8 @@ No event contract changes. ## Resilience - The change adds no retry or duplicate request loop; existing component reload remains the explicit retry mechanism. +- Paginated destination hooks retain only the newest request result and invalidate pending responses on category reset, preventing stale data, pagination, or loading state from replacing the current search. +- Buddy-agent loading retains only the newest action request, preventing a prior Consult/Transfer response from replacing the selected action's data or loading state. - Empty fallbacks remain confined to the task/UI boundary, while the store preserves rejection semantics for other consumers. - Response order and pagination are not cached or reconstructed by widgets. @@ -273,7 +283,7 @@ No event contract changes. ## Migration expectations -- Compatibility: queue and entry-point callers retain existing methods and types; direct `@webex/cc-store` buddy-loader callers of the corrected action input must migrate with the widgets release. +- Compatibility: queue, entry-point, and legacy buddy-agent callers retain their existing methods and call forms; action-aware buddy loading and params-only queue loading are additive overloads. - Data or consumer transition: release a compatible SDK before or with the store, then task and component packages. - Coexistence period: local development uses the approved SDK worktree link; published packages must use a released compatible SDK. - Completion and rollback outcome: all packages resolve the existing types and list methods; rollback is a coordinated dependency/code rollback with no persisted state. @@ -296,6 +306,8 @@ No event contract changes. | Date | Decision or change | Rationale | Owner | | --- | --- | --- | --- | +| 2026-08-21 | Made paginated destination state latest-request-wins and invalidated pending responses on category reset. | An initial unfiltered entry-point request could finish after a search request and restore every item even though the search parameter reached the SDK correctly. | Developer + Codex | +| 2026-08-21 | Preserved legacy buddy media and queue media-plus-params store calls, combined Task and caller queue filters, and made buddy-agent state latest-request-wins across action changes. | The simplified widget calls must not break direct store consumers, erase required digital Task scope, or allow a stale Consult response to replace Transfer results. | Developer + Codex | | 2026-08-21 | Changed destination initials from the first two tokens to the first and last non-empty tokens. | Multi-token destination labels must yield compact, distinguishable initials such as `Queue e2e 1 → Q1` and `Entry point e2e set 1 → E1`. | Developer + Codex | | 2026-08-21 | Mapped SDK availability to the Momentum Avatar's built-in `active`/`away` presence and removed widget-owned icon/color/position CSS. The provisional entry-point `dbId` subtitle was replaced with SDK-mapped `EntryPointRecord.number`, and entry-point requests now delegate without a task-media filter. | Figma and the installed design-system API assign presence presentation to Avatar, while the backend's dial-number mapping supplies the visible entry-point number and owns its query policy. | Developer + Codex | | 2026-08-19 | Removed dependencies on new action/media/destination aliases and typed widget destinations from `TaskUIControls`; removed the queue `dbId` passthrough fixture. | Widgets need the existing methods, entity records, and Task control field only; deriving types prevents an unnecessary public SDK surface. | Developer + Codex | diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index e81358579..68f52b111 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -27,7 +27,7 @@ import {DEFAULT_PAGE_SIZE} from '../../constants'; * @param logger - Optional logger instance for diagnostics. * @returns An object containing SDK response data, pagination state and helpers. */ -export const usePaginatedData = ( +export const usePaginatedData = ( fetchFunction: FetchPaginatedList | undefined, categoryName: string, logger?: ILogger @@ -37,9 +37,12 @@ export const usePaginatedData = ( const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); + const latestRequestIdRef = useRef(0); const loadData = useCallback( async (currentPage = 0, search = '', reset = false) => { + const requestId = ++latestRequestIdRef.current; + if (!fetchFunction) { setData([]); setHasMore(false); @@ -63,6 +66,8 @@ export const usePaginatedData = ( }); const response = await fetchFunction(apiParams); + if (requestId !== latestRequestIdRef.current) return; + if (!response || !response.data) { logger?.error(`CC-Components: No data received from fetch function for ${categoryName}`, { module: MODULE, @@ -103,21 +108,24 @@ export const usePaginatedData = ( method: 'usePaginatedData#loadData', error: errorMessage, }); + if (requestId !== latestRequestIdRef.current) return; if (reset || currentPage === 0) { setData([]); } setHasMore(false); } finally { - setLoading(false); + if (requestId === latestRequestIdRef.current) setLoading(false); } }, [fetchFunction, logger, categoryName] ); const reset = useCallback(() => { + latestRequestIdRef.current += 1; setData([]); setPage(0); setHasMore(true); + setLoading(false); }, []); return {data, page, hasMore, loading, loadData, reset}; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index e268777bd..9454f9791 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -163,6 +163,12 @@ function CallControlComponent(props: CallControlComponentProps) { if (!button.isVisible) return null; if (button.menuType) { + const action = button.menuType === 'Transfer' ? 'Transfer' : 'Consult'; + const availableDestinations = + action === 'Transfer' + ? controls.consultTransferDestinations.transfer + : controls.consultTransferDestinations.consult; + return ( { setShowAgentMenu(false); @@ -240,12 +248,8 @@ function CallControlComponent(props: CallControlComponentProps) { onDialNumberSelect={(dialNumber, allowParticipantsToInteract) => handleTargetSelect(dialNumber, dialNumber, 'dialNumber', allowParticipantsToInteract) } - action={button.menuType === 'Transfer' ? 'Transfer' : 'Consult'} - availableDestinations={ - button.menuType === 'Transfer' - ? controls.consultTransferDestinations.transfer - : controls.consultTransferDestinations.consult - } + action={action} + availableDestinations={availableDestinations} consultTransferOptions={consultTransferOptions} isConferenceInProgress={controls?.main?.exitConference?.isVisible ?? false} logger={logger} 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 72acb140b..32233a8b8 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 @@ -372,6 +372,50 @@ describe('ConsultTransferPopoverComponent', () => { expect(getQueuesMock.mock.calls.length).toBe(afterTwoChars + 1); }); + it('keeps the latest entry-point search result when an older request finishes later', async () => { + let resolveInitialRequest!: (value: {data: EntryPointRecord[]; meta: {page: number; totalPages: number}}) => void; + const initialRequest = new Promise<{ + data: EntryPointRecord[]; + meta: {page: number; totalPages: number}; + }>((resolve) => { + resolveInitialRequest = resolve; + }); + const getEntryPointsMock = jest.fn(({search}: {search?: string}) => { + if (search === 'set 1') { + return Promise.resolve({ + data: [{id: 'entry-point-1', name: 'Entry point e2e set 1', number: '1001'} as EntryPointRecord], + meta: {page: 0, totalPages: 1}, + }); + } + return initialRequest; + }); + + const screen = render( + + ); + + fireEvent.click(screen.getByRole('button', {name: 'Entry Point'})); + fireEvent.change(screen.getByPlaceholderText('Search...'), {target: {value: 'set 1'}}); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(screen.getByText('Entry point e2e set 1')).toBeInTheDocument(); + + await act(async () => { + resolveInitialRequest({ + data: [ + {id: 'entry-point-1', name: 'Entry point e2e set 1', number: '1001'} as EntryPointRecord, + {id: 'entry-point-2', name: 'Entry point e2e set 2', number: '1002'} as EntryPointRecord, + ], + meta: {page: 0, totalPages: 1}, + }); + }); + + expect(screen.queryByText('Entry point e2e set 2')).not.toBeInTheDocument(); + }); + it('does not trigger search when category is Agents', async () => { const getQueuesMock = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); const screen = await render(); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index f66ae7ab8..483becc57 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -305,6 +305,13 @@ describe('CallControlComponent', () => { const modifiedProps = { ...defaultProps, buddyAgents: mockBuddyAgents, + controls: { + ...defaultProps.controls, + consultTransferDestinations: { + consult: [], + transfer: ['agent' as const], + }, + }, }; const screen = await render(); @@ -488,6 +495,40 @@ describe('CallControlComponent', () => { expect(screen.queryByRole('button', {name: 'Dial Number'})).not.toBeInTheDocument(); }); + it('does not load buddy agents when the SDK omits the Agents destination', async () => { + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id: 'consult', + icon: 'consult', + tooltip: 'Consult', + className: 'call-control-button', + disabled: false, + menuType: 'Consult', + isVisible: true, + dataTestId: 'consult-button', + }, + ]); + + const screen = await render( + + ); + + fireEvent.click(screen.getByLabelText('Consult')); + + await screen.findByRole('button', {name: 'Queues'}); + expect(defaultProps.loadBuddyAgents).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', {name: 'Agents'})).not.toBeInTheDocument(); + }); + it('hides Dial Number and Entry Point tabs for non-telephony media', async () => { jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index b48c7e70f..f995b8d9a 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -71,7 +71,6 @@ interface IContactCenter { setAgentState(data: StateChange): Promise; getOutdialAniEntries(params: OutdialAniParams): Promise; getAccessToken(): Promise; - startOutdial(destination: string, origin?: string): Promise; acceptPreviewContact(payload: PreviewContactPayload): Promise; skipPreviewContact(payload: PreviewContactPayload): Promise; removePreviewContact(payload: PreviewContactPayload): Promise; @@ -216,8 +215,13 @@ interface IStoreWrapper extends IStore { onErrorCallback?: (widgetName: string, error: Error) => void; setCurrentTask(task: ITask): void; refreshTaskList(): void; - getBuddyAgents(action?: 'Consult' | 'Transfer'): Promise; + getBuddyAgents(action: 'Consult' | 'Transfer'): Promise; + getBuddyAgents(mediaType?: string): Promise; getQueues(params?: ContactServiceQueueSearchParams): Promise; + getQueues( + mediaType: string | undefined, + params?: ContactServiceQueueSearchParams + ): Promise; getEntryPoints(params?: EntryPointSearchParams): Promise; getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; setDeviceType(option: string): void; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 6d667107a..f365f4063 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -32,6 +32,7 @@ import Store from './store'; import { DEVICE_TYPE_BROWSER, MEDIA_TYPE_TELEPHONY_LOWER, + AGENT_STATE_AVAILABLE, CAMPAIGN_PREVIEW_OUTBOUND_TYPES, CAMPAIGN_PREVIEW_CAMPAIGN_TYPES, } from './store.types'; @@ -48,17 +49,16 @@ const CONSULT_TRANSFER_CHANNELS = { const getSupportedMediaType = (mediaType?: string): keyof typeof CONSULT_TRANSFER_CHANNELS | undefined => { const normalizedMediaType = typeof mediaType === 'string' ? mediaType.toLowerCase() : ''; + const channel = CONSULT_TRANSFER_CHANNELS[normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS]; - return normalizedMediaType in CONSULT_TRANSFER_CHANNELS - ? (normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS) - : undefined; + return typeof channel === 'string' ? (normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS) : undefined; }; -const getTaskQueueChannelFilter = (mediaType?: string): string | undefined => { +const getQueueChannelFilter = (mediaType?: string): string | undefined => { const supportedMediaType = getSupportedMediaType(mediaType); const channelType = supportedMediaType ? CONSULT_TRANSFER_CHANNELS[supportedMediaType] : undefined; - if (!channelType || channelType === 'TELEPHONY') return undefined; + if (!channelType) return undefined; return `queueType==INBOUND;channelType==${channelType};active==true`; }; @@ -1264,13 +1264,22 @@ class StoreWrapper implements IStoreWrapper { }); }; - getBuddyAgents = async (action: 'Consult' | 'Transfer' = 'Consult'): Promise> => { + getBuddyAgents = async (actionOrMediaType?: string): Promise> => { try { - const mediaType = getSupportedMediaType(this.currentTask?.data?.interaction?.mediaType); - const response = await this.store.cc.getBuddyAgents({ - action, - ...(mediaType ? {mediaType} : {}), - }); + const isAction = actionOrMediaType === 'Consult' || actionOrMediaType === 'Transfer'; + const taskMediaType = getSupportedMediaType(this.currentTask?.data?.interaction?.mediaType); + const mediaType = isAction ? taskMediaType : (getSupportedMediaType(actionOrMediaType) ?? taskMediaType); + const response = await this.store.cc.getBuddyAgents( + isAction + ? { + action: actionOrMediaType, + ...(mediaType ? {mediaType} : {}), + } + : { + mediaType: mediaType ?? MEDIA_TYPE_TELEPHONY_LOWER, + state: AGENT_STATE_AVAILABLE, + } + ); return 'data' in response ? response.data.agentList : []; } catch (error) { this.store.logger.error('Error fetching buddy agents:', error); @@ -1278,14 +1287,25 @@ class StoreWrapper implements IStoreWrapper { } }; - getQueues = async (params?: ContactServiceQueueSearchParams): Promise => { + getQueues = async ( + mediaTypeOrParams?: string | ContactServiceQueueSearchParams, + legacyParams?: ContactServiceQueueSearchParams + ): Promise => { try { - const mediaType = this.currentTask?.data?.interaction?.mediaType; - const filter = getTaskQueueChannelFilter(mediaType); + const usesLegacyMediaSignature = typeof mediaTypeOrParams === 'string' || legacyParams !== undefined; + const params = typeof mediaTypeOrParams === 'string' ? legacyParams : (mediaTypeOrParams ?? legacyParams); + const mediaType = + typeof mediaTypeOrParams === 'string' ? mediaTypeOrParams : this.currentTask?.data?.interaction?.mediaType; + const supportedMediaType = getSupportedMediaType(mediaType); + const taskFilter = + usesLegacyMediaSignature || (supportedMediaType && supportedMediaType !== 'telephony') + ? getQueueChannelFilter(mediaType) + : undefined; + const filter = taskFilter && params?.filter ? `${taskFilter};${params.filter}` : (taskFilter ?? params?.filter); return await this.store.cc.getQueues({ - ...(filter ? {filter} : {}), ...(params ?? {}), + ...(filter !== undefined ? {filter} : {}), }); } catch (error) { this.store.logger.error('Error fetching queues:', error); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 02897192e..11fad0db9 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1027,6 +1027,17 @@ describe('storeEventsWrapper', () => { }); }); + it('should preserve the legacy media-type buddy-agent call', async () => { + storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); + + await storeWrapper.getBuddyAgents('chat'); + + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({ + mediaType: 'chat', + state: 'Available', + }); + }); + it('should omit an unsupported task media type and use the SDK default', async () => { storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'video'}}} as ITask; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); @@ -1086,6 +1097,50 @@ describe('storeEventsWrapper', () => { }); }); + it('should combine a caller filter with the active task channel filter', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'chat'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues({filter: 'name==Support', page: 0}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==CHAT;active==true;name==Support', + page: 0, + }); + }); + + it('should preserve the legacy media-type and params queue call', async () => { + storeWrapper['store'].currentTask = null; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues('social', {page: 2, search: 'support'}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==SOCIAL_CHANNEL;active==true', + page: 2, + search: 'support', + }); + }); + + it('should preserve telephony scoping for the legacy queue call when a caller filter is supplied', async () => { + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues('telephony', {filter: 'name==Support'}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==TELEPHONY;active==true;name==Support', + }); + }); + + it('should preserve an explicit empty filter in the params-only queue call', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues({filter: ''}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({filter: ''}); + }); + it('should handle error in getQueues and throw error', async () => { storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getQueues = jest.fn().mockRejectedValue(new Error('queue error')); diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 43ff6057b..890f88b77 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -575,11 +575,17 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, agentId, extractConsultingAgent]); + const buddyAgentsRequestIdRef = useRef(0); + const loadBuddyAgents = useCallback( async (action: 'Consult' | 'Transfer' = 'Consult') => { + const requestId = ++buddyAgentsRequestIdRef.current; + try { setLoadingBuddyAgents(true); const agents = await store.getBuddyAgents(action); + if (requestId !== buddyAgentsRequestIdRef.current) return; + logger.info(`Loaded ${agents.length} buddy agents`, {module: 'helper.ts', method: 'loadBuddyAgents'}); setBuddyAgents(agents); } catch (error) { @@ -587,9 +593,13 @@ export const useCallControl = (props: useCallControlProps) => { module: 'useCallControl', method: 'loadBuddyAgents', }); + if (requestId !== buddyAgentsRequestIdRef.current) return; + setBuddyAgents([]); } finally { - setLoadingBuddyAgents(false); + if (requestId === buddyAgentsRequestIdRef.current) { + setLoadingBuddyAgents(false); + } } }, [logger] @@ -1306,14 +1316,12 @@ export const useOutdialCall = (props: useOutdialCallProps) => { return; } - const outdialPromise = origin ? cc.startOutdial(destination, origin) : cc.startOutdial(destination); + // Only pass origin if it's defined and not empty + const outdialArgs = origin ? [destination, origin] : [destination]; - outdialPromise - .then(() => { - logger.info('Outdial call started', { - module: 'widget-OutdialCall#helper.ts', - method: 'startOutdial', - }); + cc.startOutdial(...outdialArgs) + .then((response) => { + logger.info('Outdial call started', response); }) .catch((error: Error) => { logger.error(`${error}`, { diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 423c02737..240d638b4 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -2407,6 +2407,52 @@ describe('useCallControl', () => { getBuddyAgentsSpy.mockRestore(); }); + it('should ignore a stale buddy-agent response after the action changes', async () => { + let resolveConsult!: (agents: typeof mockAgents) => void; + let resolveTransfer!: (agents: typeof mockAgents) => void; + const consultResponse = new Promise((resolve) => { + resolveConsult = resolve; + }); + const transferResponse = new Promise((resolve) => { + resolveTransfer = resolve; + }); + jest + .spyOn(store, 'getBuddyAgents') + .mockImplementation((action) => (action === 'Transfer' ? transferResponse : consultResponse)); + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + let consultRequest!: Promise; + let transferRequest!: Promise; + act(() => { + consultRequest = result.current.loadBuddyAgents('Consult'); + transferRequest = result.current.loadBuddyAgents('Transfer'); + }); + + await act(async () => { + resolveTransfer([mockAgents[1]]); + await transferRequest; + }); + expect(result.current.buddyAgents).toEqual([mockAgents[1]]); + + await act(async () => { + resolveConsult([mockAgents[0]]); + await consultRequest; + }); + expect(result.current.buddyAgents).toEqual([mockAgents[1]]); + expect(result.current.loadingBuddyAgents).toBe(false); + }); + it('should call transferCall successfully', async () => { const transferSpy = jest.fn().mockResolvedValue('Transferred'); const currentTaskSuccess = {...mockCurrentTask, transfer: transferSpy}; @@ -5697,10 +5743,7 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', { - module: 'widget-OutdialCall#helper.ts', - method: 'startOutdial', - }); + expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); }); it('should successfully start an outdial call with origin', async () => { @@ -5717,10 +5760,7 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination, origin); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', { - module: 'widget-OutdialCall#helper.ts', - method: 'startOutdial', - }); + expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); }); it('should show alert when destination is empty or only contains spaces', async () => { diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index f0f1d1272..e095af87f 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -623,7 +623,6 @@ const mockCC: IContactCenter = { setAgentState: jest.fn().mockResolvedValue({}), getOutdialAniEntries: jest.fn().mockResolvedValue({entries: []}), getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), - startOutdial: jest.fn().mockResolvedValue({}), acceptPreviewContact: jest.fn().mockResolvedValue({}), skipPreviewContact: jest.fn().mockResolvedValue({}), removePreviewContact: jest.fn().mockResolvedValue({}), From f99fb634bf04146f99d5eeb926c1b519f70c2207 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Fri, 21 Aug 2026 16:11:27 +0530 Subject: [PATCH 09/10] fix(contact-center): minimize consult transfer widget changes --- ai-docs/GLOSSARY.md | 2 +- ai-docs/SERVICE_STATE.md | 1 + .../spec/feature-spec.md | 2 +- .../src/components/task/task.types.ts | 8 ++++- .../CallControl/call-control.snapshot.tsx | 1 + .../task/CallControl/call-control.tsx | 1 + .../call-control-cad.snapshot.tsx | 1 + .../task/CallControlCAD/call-control-cad.tsx | 1 + .../store/ai-docs/store-spec.md | 4 +-- packages/contact-center/store/src/store.ts | 2 ++ .../contact-center/store/src/store.types.ts | 5 +++ .../store/src/storeEventsWrapper.ts | 10 ++++-- packages/contact-center/store/src/util.ts | 1 + .../store/tests/storeEventsWrapper.ts | 33 +++++-------------- packages/contact-center/store/tests/util.ts | 1 + .../task/src/CallControl/index.tsx | 2 ++ .../task/src/CallControlCAD/index.tsx | 2 ++ .../task/tests/call-control-recording.tsx | 7 ++-- 18 files changed, 50 insertions(+), 34 deletions(-) diff --git a/ai-docs/GLOSSARY.md b/ai-docs/GLOSSARY.md index b8a19bd02..500514ba0 100644 --- a/ai-docs/GLOSSARY.md +++ b/ai-docs/GLOSSARY.md @@ -27,7 +27,7 @@ | agent state | The agent's current presence/availability, held as `currentState` and changed via the user-state widget through the store. | `packages/contact-center/store/src/store.ts` (`currentState`); `packages/contact-center/user-state/src/helper.ts` | Not "status". | | station login | The agent login flow selecting team and device (dial number / extension / browser); the station-login widget. | `packages/contact-center/station-login/src/station-login/index.tsx` (+ `station-login.types.ts`) | Not "sign-in" in identifiers. | | buddy agents | Other agents available as consult/transfer targets, loaded via `store.getBuddyAgents()` into the task hook as `BuddyDetails[]`. | `packages/contact-center/task/src/helper.ts` (`loadBuddyAgents`, `buddyAgents`) | Not "peers" / "colleagues". | -| queue | A routing destination for tasks; a transfer/consult target type and a task metadata field. Destination availability is supplied by SDK Task UI controls. | `packages/contact-center/task/src/task.types.ts` (`QUEUE: 'queue'`); `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | Not "skill group". | +| queue | A routing destination for tasks; a transfer/consult target type and a task metadata field. | `packages/contact-center/task/src/task.types.ts` (`QUEUE: 'queue'`); store `currentConsultQueueId`, `allowConsultToQueue` in `store/src/store.ts` | Not "skill group". | | entry point | A routing entry destination; a transfer/consult target type for tasks. | `packages/contact-center/task/src/task.types.ts` (`ENTRY_POINT: 'entryPoint'`) | One concept; write `entryPoint` in code. | | wrapup code | A configured post-interaction disposition code applied at task end; modeled as `IWrapupCode`. | `packages/contact-center/store/src/store.ts` (`wrapupCodes: IWrapupCode[]`); surfaced in `CallControlCAD` | Not "disposition" in identifiers. | | r2wc / Web Component | The `@r2wc/react-to-web-component` wrapper that turns each React widget into a framework-agnostic custom element registered via `customElements.define`. | `packages/contact-center/cc-widgets/src/wc.ts` | "r2wc" is the library; the output is a custom element / Web Component. | diff --git a/ai-docs/SERVICE_STATE.md b/ai-docs/SERVICE_STATE.md index 862757d52..d4f1b11d6 100644 --- a/ai-docs/SERVICE_STATE.md +++ b/ai-docs/SERVICE_STATE.md @@ -37,6 +37,7 @@ Feature flags are not owned or defaulted by this repo — they are read from the | `isAnalyzerEnabled` | Analyzer-backed features | SDK-provided | Webex CC back end | SDK stops emitting it | | `webRtcEnabled` | WebRTC (browser) device option | SDK-provided | Webex CC back end | SDK stops emitting it | | `isRecordingManagementEnabled` | Recording toggle in CallControl | SDK-provided | Webex CC back end | SDK stops emitting it | +| `allowConsultToQueue` | Consult-to-queue option | SDK-provided | Webex CC back end | SDK stops emitting it | ## Compliance / Certifications - FedRAMP: PR template (`.github/PULL_REQUEST_TEMPLATE.md`) compliance is mandatory and must not be regressed (COMPLETES, Change Type, test scenarios, GAI Policy, Checklist sections). diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index 75ef5b460..fb5cd9b9f 100644 --- a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -100,7 +100,7 @@ There are no open product decisions for this delta. | `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | | `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | | `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `TaskUIControls`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off action, media, destination-control, destination-list, list-options, or list-response public type may be introduced. | Consumers only need the existing methods, lists, and Task control field. Deriving destination typing from `TaskUIControls` avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior. | Present | -| `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not mirror collaboration profile flags, derive visibility from media/direction/task payload fields, or fetch buddy agents when Agents is omitted; host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift, fixes incorrect payload-path reads, makes the SDK-provided first category the default selection, and avoids loading data for a disallowed category. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl` | Consumers cannot enable a category omitted by the SDK. | Present | +| `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not use collaboration profile flags or media/direction/task payload fields to derive visibility, and must not fetch buddy agents when Agents is omitted; the legacy `allowConsultToQueue` store/property pass-through remains exported only for compatibility and is not consumed by this UI. Host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift while retaining the existing public widgets surface, fixes incorrect payload-path reads, makes the SDK-provided first category the default selection, and avoids loading data for a disallowed category. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/store/tests/storeEventsWrapper.ts` | Consumers cannot enable a category omitted by the SDK; compatibility fields do not participate in the decision. | Present | | `WIDGET-LIST-R-008` | Agent rows must pass Momentum Avatar presence `active` for SDK state `Available` and `away` for every other state. Every destination avatar must derive initials from the first character of the first and last non-empty name tokens, using one character for a single-token name. Dial-number and entry-point rows must show their typed SDK `number` as secondary text below the name. | The design-system Avatar owns presence presentation, first/last-token initials keep multi-token destination labels distinguishable, and secondary identifiers distinguish similarly named routable destinations without widget-side response transformation. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Entry-point `number` requires the paired SDK dial-number mapping. | Present | | `WIDGET-LIST-R-009` | Each paginated destination list must apply state only from its newest request; starting a newer search or resetting the category invalidates older in-flight responses. | A slower unfiltered page must not overwrite a newer filtered response and make the UI appear to ignore entry-point, queue, or dial-number search. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | The underlying request is not cancelled; its stale result is ignored. | Present | | `WIDGET-LIST-R-010` | Buddy-agent state and loading must be updated only by the newest request, including when the action switches between Consult and Transfer while a request is in flight. | A slower Consult response must not overwrite the Transfer population currently selected by the user. | `packages/contact-center/task/src/helper.ts` | `packages/contact-center/task/tests/helper.ts` | The underlying request is not cancelled; its stale result is ignored. | Present | diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 622060562..d3f6bf576 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -6,8 +6,8 @@ import { BuddyDetails, DestinationType, ContactServiceQueue, - EntryPointRecord, AddressBookEntry, + EntryPointRecord, FetchPaginatedList, Participant, AddressBookEntrySearchParams, @@ -463,6 +463,11 @@ export interface ControlProps { */ isEndConsultEnabled: boolean; + /** + * Flag to determine if the consulting to queue is enabled for the agent + */ + allowConsultToQueue: boolean; + /** * Flag to enable or disable conference feature */ @@ -547,6 +552,7 @@ export type CallControlComponentProps = Pick< | 'stateTimerTimestamp' | 'consultTimerLabel' | 'consultTimerTimestamp' + | 'allowConsultToQueue' | 'lastTargetType' | 'setLastTargetType' | 'controls' diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx index 84b960453..2309338ab 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx @@ -110,6 +110,7 @@ describe('CallControlComponent Snapshots', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, + allowConsultToQueue: mockProfile.allowConsultToQueue, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 483becc57..e7ef90f03 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -93,6 +93,7 @@ describe('CallControlComponent', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, + allowConsultToQueue: true, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx index 570aa9ded..6d6fc2d42 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx @@ -150,6 +150,7 @@ describe('CallControlCADComponent Snapshots', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, + allowConsultToQueue: true, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index 2b2662660..dd4888597 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -119,6 +119,7 @@ describe('CallControlCADComponent', () => { stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, + allowConsultToQueue: true, lastTargetType: TARGET_TYPE.AGENT, setLastTargetType: jest.fn(), isHeld: false, diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 6c144ab6f..2a445d89e 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -111,7 +111,7 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | -| `STORE-R-022` | The store must not mirror or project `allowConsultToQueue`, `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; destination visibility/order is consumed from each SDK Task's `uiControls.consultTransferDestinations`. | Raw profile duplication gives widgets a second policy source and can drift from SDK task/media/direction decisions. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest these profile values internally when computing Task controls. | PRESENT | +| `STORE-R-022` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. @@ -288,7 +288,7 @@ The store is a single MobX `makeAutoObservable` instance. Observable slices (all - **Session / profile:** `agentId`, `agentProfile`, `isAgentLoggedIn`, `deviceType`, `dialNumber`, `teamId`, `teams`, `loginOptions`, `idleCodes`, `wrapupCodes`, `featureFlags`, `dataCenter`. - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. -- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `isDigitalChannelsInitialized`. Destination availability/order remains on the SDK Task's `uiControls` rather than duplicated store observables. +- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `isDigitalChannelsInitialized`. `allowConsultToQueue` remains a compatibility pass-through only; destination availability/order comes from the SDK Task's `uiControls`. - **`getQueues` / `getEntryPoints`:** call the same-named SDK methods and return `ContactServiceQueuesResponse` / `EntryPointListResponse`. Telephony uses SDK defaults; non-telephony tasks use the existing `filter` parameter. They do not sort, filter returned rows, project fields, or reinterpret metadata. - **`getBuddyAgents`:** returns the SDK `agentList` unchanged (full list, no pagination). - **Misc:** `currentTheme`, `cc` (`observable.ref` — not deeply observed), `isAddressBookEnabled`. diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 75fb28f10..2fd5a8b5f 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -49,6 +49,7 @@ class Store implements IStore { featureFlags: {[key: string]: boolean} = {}; isEndConsultEnabled: boolean = false; isAddressBookEnabled: boolean = false; + allowConsultToQueue: boolean = false; agentProfile: AgentLoginProfile = {}; isMuted: boolean = false; isDigitalChannelsInitialized: boolean = false; @@ -118,6 +119,7 @@ class Store implements IStore { 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; this.agentProfile.isTimeoutDesktopInactivityEnabled = response.isTimeoutDesktopInactivityEnabled; this.agentProfile.timeoutDesktopInactivityMins = response.timeoutDesktopInactivityMins; diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index f995b8d9a..1ace2ee1b 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -196,6 +196,7 @@ interface IStore { consultStartTimeStamp?: number; callControlAudio: MediaStream | null; isEndConsultEnabled: boolean; + allowConsultToQueue: boolean; agentProfile: AgentLoginProfile; isMuted: boolean; isAddressBookEnabled: boolean; @@ -317,6 +318,9 @@ type FetchPaginatedList = ( params: PaginatedListParams ) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; +// Generic transform function for paginated APIs +type TransformPaginatedData = (item: T, page: number, index: number) => U; + // Utility consts const DIAL_NUMBER: string = 'AGENT_DN'; const EXTENSION: string = 'EXTENSION'; @@ -372,6 +376,7 @@ export type { IWebex, PaginatedListParams, FetchPaginatedList, + TransformPaginatedData, TaskUIControls, TaskUIControlState, InteractionUIControls, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index f365f4063..7a003bdad 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -215,6 +215,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.isEndConsultEnabled; } + get allowConsultToQueue() { + return this.store.allowConsultToQueue; + } + get agentProfile() { return this.store.agentProfile; } @@ -1315,7 +1319,8 @@ class StoreWrapper implements IStoreWrapper { getEntryPoints = async (params?: EntryPointSearchParams): Promise => { try { - return await this.store.cc.getEntryPoints(params); + const response: EntryPointListResponse = await this.store.cc.getEntryPoints(params); + return response; } catch (error) { this.store.logger.error('Error fetching entry points:', error); throw error; @@ -1327,7 +1332,8 @@ class StoreWrapper implements IStoreWrapper { if (!this.store.isAddressBookEnabled) { return {data: [], meta: {page: 0, totalPages: 0}}; } - return await this.store.cc.addressBook.getEntries(params ?? {}); + const response: AddressBookEntriesResponse = await this.store.cc.addressBook.getEntries(params ?? {}); + return response; } catch (error) { this.store.logger.error('Error fetching address book entries:', error); throw error; diff --git a/packages/contact-center/store/src/util.ts b/packages/contact-center/store/src/util.ts index 78420a67c..099732d53 100644 --- a/packages/contact-center/store/src/util.ts +++ b/packages/contact-center/store/src/util.ts @@ -31,6 +31,7 @@ export function getFeatureFlags(agentProfile: Profile) { 'isAnalyzerEnabled', 'webRtcEnabled', 'isRecordingManagementEnabled', + 'allowConsultToQueue', ]; const keyValuePairs = featureFlagkeys.reduce((acc, key) => { diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 11fad0db9..dfd2d43b3 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -109,6 +109,7 @@ jest.mock('../src/store', () => ({ isQueueConsultInProgress: false, currentConsultQueueId: null, isEndConsultEnabled: true, + allowConsultToQueue: false, isDeclineButtonEnabled: false, isDigitalChannelsInitialized: false, acceptedCampaignIds: new Set(), @@ -271,6 +272,10 @@ describe('storeEventsWrapper', () => { expect(storeWrapper.isEndConsultEnabled).toBe(storeWrapper['store'].isEndConsultEnabled); }); + it('should proxy allowConsultToQueue', () => { + expect(storeWrapper.allowConsultToQueue).toBe(storeWrapper['store'].allowConsultToQueue); + }); + it('should proxy isDeclineButtonEnabled', () => { expect(storeWrapper.isDeclineButtonEnabled).toBe(false); }); @@ -1178,27 +1183,10 @@ describe('storeEventsWrapper', () => { storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); - expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ - page: 0, - pageSize: 25, - }); + expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({page: 0, pageSize: 25}); expect(result).toEqual(mockEntryPointsResponse); }); - it('should delegate entry-point parameters without widget-owned media filtering', async () => { - storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue({ - data: [], - meta: {page: 0, totalPages: 0}, - }); - - await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); - - expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({ - page: 0, - pageSize: 25, - }); - }); - it('should handle error while fetching entry points', async () => { storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); @@ -1207,19 +1195,16 @@ describe('storeEventsWrapper', () => { it('should fetch address book entries successfully', async () => { storeWrapper['store'].isAddressBookEnabled = true; - storeWrapper['store'].cc.addressBook.getEntries = jest.fn().mockResolvedValue(mockAddressBookEntriesResponse); + jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockResolvedValue(mockAddressBookEntriesResponse); const result = await storeWrapper.getAddressBookEntries({page: 0, pageSize: 25}); - expect(storeWrapper['store'].cc.addressBook.getEntries).toHaveBeenCalledWith({ - page: 0, - pageSize: 25, - }); + expect(storeWrapper['store'].cc.addressBook.getEntries).toHaveBeenCalledWith({page: 0, pageSize: 25}); expect(result).toEqual(mockAddressBookEntriesResponse); }); it('should handle error while fetching address book entries', async () => { storeWrapper['store'].isAddressBookEnabled = true; - storeWrapper['store'].cc.addressBook.getEntries = jest.fn().mockRejectedValue(new Error('ab error')); + jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockRejectedValue(new Error('ab error')); await expect(storeWrapper.getAddressBookEntries({page: 0, pageSize: 25})).rejects.toThrow('ab error'); }); diff --git a/packages/contact-center/store/tests/util.ts b/packages/contact-center/store/tests/util.ts index 7b5065c6b..0aed0e8fa 100644 --- a/packages/contact-center/store/tests/util.ts +++ b/packages/contact-center/store/tests/util.ts @@ -8,6 +8,7 @@ describe('getFeatureFlags', () => { isCampaignManagementEnabled: true, agentPersonalStatsEnabled: true, webRtcEnabled: true, + allowConsultToQueue: true, isEndTaskEnabled: true, isEndConsultEnabled: true, isOutboundEnabledForAgent: false, diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index e62eb104f..443072076 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -16,6 +16,7 @@ const CallControlInternal: React.FunctionComponent = observer( wrapupCodes, consultStartTimeStamp, callControlAudio, + allowConsultToQueue, isMuted, agentId, acceptedCampaignIds, @@ -46,6 +47,7 @@ const CallControlInternal: React.FunctionComponent = observer( wrapupCodes, consultStartTimeStamp, callControlAudio, + allowConsultToQueue, logger, consultTransferOptions, }; diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index e59026d3c..1426d19da 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -26,6 +26,7 @@ const CallControlCADInternal: React.FunctionComponent = observ wrapupCodes, consultStartTimeStamp, callControlAudio, + allowConsultToQueue, isMuted, agentId, acceptedCampaignIds, @@ -55,6 +56,7 @@ const CallControlCADInternal: React.FunctionComponent = observ callControlAudio, callControlClassName, callControlConsultClassName, + allowConsultToQueue, logger, consultTransferOptions, }; diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx index f27df0810..9b5ea11ee 100644 --- a/packages/contact-center/task/tests/call-control-recording.tsx +++ b/packages/contact-center/task/tests/call-control-recording.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {render, screen, act} from '@testing-library/react'; import '@testing-library/jest-dom'; import {EventEmitter} from 'events'; -import store, {TASK_EVENTS, IContactCenter, ITask} from '@webex/cc-store'; +import store, {TASK_EVENTS, IContactCenter} from '@webex/cc-store'; import {mockTask, mockCC, createEnabledMainTaskUIControls} from '@webex/test-fixtures'; import {CallControl} from '../src/CallControl'; @@ -76,8 +76,8 @@ const promoteTask = (task: FakeTask) => { store.store.agentId = AGENT_ID; // Registers the store's own task listeners (refreshTaskList on recording // pause/resume, etc.) exactly as production does. - store.handleIncomingTask(task as unknown as ITask); - store.setCurrentTask(task as unknown as ITask); + store.handleIncomingTask(task); + store.setCurrentTask(task); }; /** What the SDK does when the ContactRecordingPaused websocket event arrives. */ @@ -104,6 +104,7 @@ describe('CallControl recording pause/resume state', () => { info: jest.fn(), warn: jest.fn(), error: jest.fn(), + debug: jest.fn(), trace: jest.fn(), }; }); From ea628be4d5e1817b2f125508de54a809326f4c5d Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Sat, 22 Aug 2026 14:12:49 +0530 Subject: [PATCH 10/10] revert(contact-center): defer destination search fixes --- .../spec/feature-spec.md | 18 +++----- .../consult-transfer-popover-hooks.ts | 10 +---- .../consult-transfer-popover.tsx | 44 ------------------- 3 files changed, 7 insertions(+), 65 deletions(-) diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index fb5cd9b9f..07a3174b4 100644 --- a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -102,8 +102,7 @@ There are no open product decisions for this delta. | `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `TaskUIControls`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off action, media, destination-control, destination-list, list-options, or list-response public type may be introduced. | Consumers only need the existing methods, lists, and Task control field. Deriving destination typing from `TaskUIControls` avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior. | Present | | `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not use collaboration profile flags or media/direction/task payload fields to derive visibility, and must not fetch buddy agents when Agents is omitted; the legacy `allowConsultToQueue` store/property pass-through remains exported only for compatibility and is not consumed by this UI. Host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift while retaining the existing public widgets surface, fixes incorrect payload-path reads, makes the SDK-provided first category the default selection, and avoids loading data for a disallowed category. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/store/tests/storeEventsWrapper.ts` | Consumers cannot enable a category omitted by the SDK; compatibility fields do not participate in the decision. | Present | | `WIDGET-LIST-R-008` | Agent rows must pass Momentum Avatar presence `active` for SDK state `Available` and `away` for every other state. Every destination avatar must derive initials from the first character of the first and last non-empty name tokens, using one character for a single-token name. Dial-number and entry-point rows must show their typed SDK `number` as secondary text below the name. | The design-system Avatar owns presence presentation, first/last-token initials keep multi-token destination labels distinguishable, and secondary identifiers distinguish similarly named routable destinations without widget-side response transformation. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Entry-point `number` requires the paired SDK dial-number mapping. | Present | -| `WIDGET-LIST-R-009` | Each paginated destination list must apply state only from its newest request; starting a newer search or resetting the category invalidates older in-flight responses. | A slower unfiltered page must not overwrite a newer filtered response and make the UI appear to ignore entry-point, queue, or dial-number search. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | The underlying request is not cancelled; its stale result is ignored. | Present | -| `WIDGET-LIST-R-010` | Buddy-agent state and loading must be updated only by the newest request, including when the action switches between Consult and Transfer while a request is in flight. | A slower Consult response must not overwrite the Transfer population currently selected by the user. | `packages/contact-center/task/src/helper.ts` | `packages/contact-center/task/tests/helper.ts` | The underlying request is not cancelled; its stale result is ignored. | Present | +| `WIDGET-LIST-R-009` | Buddy-agent state and loading must be updated only by the newest request, including when the action switches between Consult and Transfer while a request is in flight. | A slower Consult response must not overwrite the Transfer population currently selected by the user. | `packages/contact-center/task/src/helper.ts` | `packages/contact-center/task/tests/helper.ts` | The underlying request is not cancelled; its stale result is ignored. | Present | ## Defect context (when applicable) @@ -145,8 +144,7 @@ There are no open product decisions for this delta. - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). - [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). - [x] Agent rows show active/away presence from buddy state, and dial-number/entry-point rows show their typed secondary identifiers (`WIDGET-LIST-R-008`). -- [x] A late unfiltered destination response cannot overwrite the newest search result, and category resets invalidate pending list responses (`WIDGET-LIST-R-009`). -- [x] A late buddy-agent response from a previous Consult/Transfer action cannot overwrite the current action's list or loading state (`WIDGET-LIST-R-010`). +- [x] A late buddy-agent response from a previous Consult/Transfer action cannot overwrite the current action's list or loading state (`WIDGET-LIST-R-009`). - [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with the coordinated SDK worktree. - [x] The complete cc-components unit suite and the focused consult/transfer suites pass with the locally linked SDK. @@ -154,10 +152,10 @@ There are no open product decisions for this delta. | Scenario | Actor | Preconditions | Expected behavior | Failure or boundary behavior | Requirements | | --- | --- | --- | --- | --- | --- | -| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged and each row shows active/away presence from buddy state. | SDK failure produces an empty agent list and clears loading; a stale request cannot replace newer state. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008`, `WIDGET-LIST-R-010` | -| Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; a late Consult response cannot replace Transfer results. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-010` | +| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged and each row shows active/away presence from buddy state. | SDK failure produces an empty agent list and clears loading; a stale request cannot replace newer state. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008`, `WIDGET-LIST-R-009` | +| Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; a late Consult response cannot replace Transfer results. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-009` | | Search Queues | Agent | Active task with media context | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and the response and metadata are preserved. | Telephony or missing media lets SDK defaults apply. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Search Entry Points | Agent | Entry-point tab visible | Page/search reach the existing SDK method without widget policy; backend order is rendered, typed `number` appears below the name when present, and only the latest request may update the list. | Failure becomes the existing empty paginated result; a late older response is ignored. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008`, `WIDGET-LIST-R-009` | +| Search Entry Points | Agent | Entry-point tab visible | Page/search reach the existing SDK method without widget policy; backend order is rendered and typed `number` appears below the name when present. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | | Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook, its SDK-default backend order is rendered, and `number` appears below the name. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | ### Interaction and scenario matrix @@ -170,8 +168,7 @@ There are no open product decisions for this delta. | Telephony or no current task media + params-only call | Queue fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | | Entry point | Fetch/search/page | Store calls existing `getEntryPoints` directly and preserves SDK rows/order | Widget filters returned rows or supplies query policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | | Dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | -| Any paginated category with an older request in flight | Search or category reset starts newer request state | Only the newest response may update data, pagination, and loading state | Older unfiltered data replaces the current filtered list | `WIDGET-LIST-R-009` | -| Consult buddy request in flight | User opens or reloads Transfer Agents | Only the Transfer request may update agents and loading state | Late Consult response replaces the Transfer list | `WIDGET-LIST-R-010` | +| Consult buddy request in flight | User opens or reloads Transfer Agents | Only the Transfer request may update agents and loading state | Late Consult response replaces the Transfer list | `WIDGET-LIST-R-009` | ### UI flow and design @@ -249,7 +246,6 @@ No event contract changes. | Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Coordinate the SDK dependency/release; use the approved local worktree link for testing. | | A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | | Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | -| A slow initial destination request completes after search and restores the full list. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | Track the newest request per paginated category and ignore state updates from older responses. | | A Consult buddy request completes after the user switches to Transfer. | `packages/contact-center/task/src/helper.ts` | Track the newest buddy request and ignore older data, error fallback, and loading-state updates. | ## Error Matrix @@ -264,7 +260,6 @@ No event contract changes. ## Resilience - The change adds no retry or duplicate request loop; existing component reload remains the explicit retry mechanism. -- Paginated destination hooks retain only the newest request result and invalidate pending responses on category reset, preventing stale data, pagination, or loading state from replacing the current search. - Buddy-agent loading retains only the newest action request, preventing a prior Consult/Transfer response from replacing the selected action's data or loading state. - Empty fallbacks remain confined to the task/UI boundary, while the store preserves rejection semantics for other consumers. - Response order and pagination are not cached or reconstructed by widgets. @@ -306,7 +301,6 @@ No event contract changes. | Date | Decision or change | Rationale | Owner | | --- | --- | --- | --- | -| 2026-08-21 | Made paginated destination state latest-request-wins and invalidated pending responses on category reset. | An initial unfiltered entry-point request could finish after a search request and restore every item even though the search parameter reached the SDK correctly. | Developer + Codex | | 2026-08-21 | Preserved legacy buddy media and queue media-plus-params store calls, combined Task and caller queue filters, and made buddy-agent state latest-request-wins across action changes. | The simplified widget calls must not break direct store consumers, erase required digital Task scope, or allow a stale Consult response to replace Transfer results. | Developer + Codex | | 2026-08-21 | Changed destination initials from the first two tokens to the first and last non-empty tokens. | Multi-token destination labels must yield compact, distinguishable initials such as `Queue e2e 1 → Q1` and `Entry point e2e set 1 → E1`. | Developer + Codex | | 2026-08-21 | Mapped SDK availability to the Momentum Avatar's built-in `active`/`away` presence and removed widget-owned icon/color/position CSS. The provisional entry-point `dbId` subtitle was replaced with SDK-mapped `EntryPointRecord.number`, and entry-point requests now delegate without a task-media filter. | Figma and the installed design-system API assign presence presentation to Avatar, while the backend's dial-number mapping supplies the visible entry-point number and owns its query policy. | Developer + Codex | diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index 68f52b111..7c5aab327 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -37,12 +37,9 @@ export const usePaginatedData = ( const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); - const latestRequestIdRef = useRef(0); const loadData = useCallback( async (currentPage = 0, search = '', reset = false) => { - const requestId = ++latestRequestIdRef.current; - if (!fetchFunction) { setData([]); setHasMore(false); @@ -66,8 +63,6 @@ export const usePaginatedData = ( }); const response = await fetchFunction(apiParams); - if (requestId !== latestRequestIdRef.current) return; - if (!response || !response.data) { logger?.error(`CC-Components: No data received from fetch function for ${categoryName}`, { module: MODULE, @@ -108,24 +103,21 @@ export const usePaginatedData = ( method: 'usePaginatedData#loadData', error: errorMessage, }); - if (requestId !== latestRequestIdRef.current) return; if (reset || currentPage === 0) { setData([]); } setHasMore(false); } finally { - if (requestId === latestRequestIdRef.current) setLoading(false); + setLoading(false); } }, [fetchFunction, logger, categoryName] ); const reset = useCallback(() => { - latestRequestIdRef.current += 1; setData([]); setPage(0); setHasMore(true); - setLoading(false); }, []); return {data, page, hasMore, loading, loadData, reset}; 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 32233a8b8..72acb140b 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 @@ -372,50 +372,6 @@ describe('ConsultTransferPopoverComponent', () => { expect(getQueuesMock.mock.calls.length).toBe(afterTwoChars + 1); }); - it('keeps the latest entry-point search result when an older request finishes later', async () => { - let resolveInitialRequest!: (value: {data: EntryPointRecord[]; meta: {page: number; totalPages: number}}) => void; - const initialRequest = new Promise<{ - data: EntryPointRecord[]; - meta: {page: number; totalPages: number}; - }>((resolve) => { - resolveInitialRequest = resolve; - }); - const getEntryPointsMock = jest.fn(({search}: {search?: string}) => { - if (search === 'set 1') { - return Promise.resolve({ - data: [{id: 'entry-point-1', name: 'Entry point e2e set 1', number: '1001'} as EntryPointRecord], - meta: {page: 0, totalPages: 1}, - }); - } - return initialRequest; - }); - - const screen = render( - - ); - - fireEvent.click(screen.getByRole('button', {name: 'Entry Point'})); - fireEvent.change(screen.getByPlaceholderText('Search...'), {target: {value: 'set 1'}}); - - await act(async () => { - jest.advanceTimersByTime(500); - }); - - expect(screen.getByText('Entry point e2e set 1')).toBeInTheDocument(); - - await act(async () => { - resolveInitialRequest({ - data: [ - {id: 'entry-point-1', name: 'Entry point e2e set 1', number: '1001'} as EntryPointRecord, - {id: 'entry-point-2', name: 'Entry point e2e set 2', number: '1002'} as EntryPointRecord, - ], - meta: {page: 0, totalPages: 1}, - }); - }); - - expect(screen.queryByText('Entry point e2e set 2')).not.toBeInTheDocument(); - }); - it('does not trigger search when category is Agents', async () => { const getQueuesMock = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); const screen = await render();