Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the cc-components spec for the keypad surface

This change adds a new cc-components keypad and changes the exported CallControl prop surface, but only the task and store module specs were updated; packages/contact-center/cc-components/ai-docs/cc-components-spec.md remains unchanged. Update the owning module spec and public-contract documentation in this change so consumers and validators do not retain the old CallControl contract.

AGENTS.md reference: AGENTS.md:L68-L68

Useful? React with 👍 / 👎.

import {Button} from '@momentum-design/components/dist/react';
import {KEY_LIST} from '../OutdialCall/constants';
import type {ILogger} from '@webex/cc-store';

export type CallControlDtmfKeypadProps = {
onDigitPress: (digit: string) => void;
logger?: ILogger;
};

/**
* In-call DTMF keypad for wxApp telephony sessions (Extension login).
* Each key press sends a single tone via SDK transmitDtmfOnWebex().
*/
const CallControlDtmfKeypad: React.FunctionComponent<CallControlDtmfKeypadProps> = ({onDigitPress, logger}) => {
const handleDigitPress = (digit: string) => {
logger?.info(`CC-Widgets: CallControl: DTMF digit pressed`, {
module: 'call-control-dtmf-keypad.tsx',
method: 'handleDigitPress',
});
onDigitPress(digit);
};

return (
<ul className="call-control-dtmf-keys" data-testid="call-control-keypad-keys">
{KEY_LIST.map((key) => (
<li key={key}>
<Button className="call-control-dtmf-key" onClick={() => handleDigitPress(key)} aria-label={`DTMF ${key}`}>
{key}
</Button>
</li>
))}
</ul>
);
};

export default CallControlDtmfKeypad;
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@
margin-top: 1rem;
}

.call-control-dtmf-keys {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
list-style: none;
padding: 0.5rem;
margin: 0;
min-width: 12rem;
}

.call-control-dtmf-key {
width: 100%;
min-height: 2.5rem;
}


.wrapup-group {
display: flex;
flex-direction: column;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import './call-control.styles.scss';
import {PopoverNext, TooltipNext, Text, ButtonCircle} from '@momentum-ui/react-collaboration';
import {Icon, Button, Select, Option} from '@momentum-design/components/dist/react';
import ConsultTransferPopoverComponent from './CallControlCustom/consult-transfer-popover';
import CallControlDtmfKeypad from './call-control-dtmf-keypad';
import AutoWrapupTimer from '../AutoWrapupTimer/AutoWrapupTimer';
import type {MEDIA_CHANNEL as MediaChannelType} from '../task.types';
import {DestinationType} from '@webex/cc-store';
Expand Down Expand Up @@ -40,6 +41,7 @@ function CallControlComponent(props: CallControlComponentProps) {
toggleHold,
toggleRecording,
toggleMute,
sendDtmf,
isMuted,
endCall,
wrapupCall,
Expand Down Expand Up @@ -168,13 +170,15 @@ function CallControlComponent(props: CallControlComponentProps) {
<PopoverNext
key={index}
onShow={() => {
logger.info(`CC-Widgets: CallControl: showing consult-transfer popover`, {
logger.info(`CC-Widgets: CallControl: showing ${button.menuType} popover`, {
module: 'call-control.tsx',
method: 'onShowPopover',
});
setShowAgentMenu(true);
setAgentMenuType(button.menuType as CallControlMenuType);
loadBuddyAgents();
if (button.menuType !== 'Keypad') {
loadBuddyAgents();
}
}}
onHide={() => {
setShowAgentMenu(false);
Expand Down Expand Up @@ -219,7 +223,9 @@ function CallControlComponent(props: CallControlComponentProps) {
</TooltipNext>
}
>
{showAgentMenu && agentMenuType === button.menuType ? (
{showAgentMenu && agentMenuType === button.menuType && button.menuType === 'Keypad' ? (
<CallControlDtmfKeypad onDigitPress={sendDtmf} logger={logger} />
) : showAgentMenu && agentMenuType === button.menuType ? (
<ConsultTransferPopoverComponent
heading={button.menuType}
buttonIcon={button.icon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {CallControlButton, MEDIA_CHANNEL as MediaChannelType, MediaTypeInfo
import type {TaskUIControls} from '@webex/cc-store';
import {getMediaTypeInfo} from '../../../utils';
import {DestinationType, ILogger, ITask} from '@webex/cc-store';

import {
RESUME_CALL,
HOLD_CALL,
Expand All @@ -15,6 +16,11 @@ import {
UNMUTE_CALL,
} from '../constants';

/** SDK P0 keypad control — may exist on main leg before InteractionUIControls ships keypad. */
type TaskMainControlsWithKeypad = TaskUIControls['main'] & {
keypad?: {isVisible: boolean; isEnabled: boolean};
};

/**
* Handles toggle hold functionality
*/
Expand Down Expand Up @@ -204,7 +210,7 @@ export const buildCallControlButtons = (
conferenceEnabled = true
): CallControlButton[] => {
try {
const mainCtrl = controls?.main;
const mainCtrl = controls?.main as TaskMainControlsWithKeypad | undefined;
const isTransferConferenceVisible = mainCtrl?.transferConference?.isVisible ?? false;
const isTransferConferenceEnabled = mainCtrl?.transferConference?.isEnabled ?? false;
const isTransferVisible = mainCtrl?.transfer?.isVisible ?? false;
Expand All @@ -229,6 +235,16 @@ export const buildCallControlButtons = (
isVisible: mainCtrl?.mute?.isVisible ?? false,
dataTestId: 'call-control:mute-toggle',
},
{
id: 'keypad',
icon: 'dialpad-bold',
tooltip: 'Keypad',
className: 'call-control-button',
disabled: !(mainCtrl?.keypad?.isEnabled ?? false),
isVisible: mainCtrl?.keypad?.isVisible ?? false,
menuType: 'Keypad',
dataTestId: 'call-control:keypad',
},
{
id: 'switchToConsult',
icon: 'call-swap-bold',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ export interface ControlProps {
*/
toggleMute: () => void;

/**
* Sends a DTMF tone on wxApp engaged telephony calls.
*/
sendDtmf: (digit: string) => void;

/**
* Function to handle ending the call.
*/
Expand Down Expand Up @@ -525,6 +530,7 @@ export type CallControlComponentProps = Pick<
| 'toggleHold'
| 'toggleRecording'
| 'toggleMute'
| 'sendDtmf'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the new DTMF callback backward-compatible

Any external consumer that directly renders the published CallControlComponent or CallControlCADComponent now fails type checking unless it supplies sendDtmf, even for calls where the keypad is never visible, because CallControlComponentProps is exported and the newly picked field is required. Make this callback optional with a safe default, or treat and document the change as a breaking major-version contract update.

Useful? React with 👍 / 👎.

| 'isMuted'
| 'endCall'
| 'wrapupCall'
Expand Down Expand Up @@ -709,7 +715,7 @@ export interface CallControlConsultComponentsProps {
/**
* Type representing the possible menu types in call control.
*/
export type CallControlMenuType = 'Consult' | 'Transfer' | 'ExitConference';
export type CallControlMenuType = 'Consult' | 'Transfer' | 'ExitConference' | 'Keypad';

export const MEDIA_CHANNEL = {
EMAIL: 'email',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import '@testing-library/jest-dom';
import CallControlDtmfKeypad from '../../../../src/components/task/CallControl/call-control-dtmf-keypad';
import {KEY_LIST} from '../../../../src/components/task/OutdialCall/constants';
import {mockCC} from '@webex/test-fixtures';

describe('CallControlDtmfKeypad', () => {
const onDigitPress = jest.fn();
const logger = mockCC.LoggerProxy;

beforeEach(() => {
jest.clearAllMocks();
});

it('renders all DTMF keys', async () => {
render(<CallControlDtmfKeypad onDigitPress={onDigitPress} logger={logger} />);

const keypad = await screen.findByTestId('call-control-keypad-keys');
await waitFor(() => {
expect(keypad.querySelectorAll('.call-control-dtmf-key')).toHaveLength(KEY_LIST.length);
});

KEY_LIST.forEach((key) => {
expect(within(keypad).getByText(key)).toBeInTheDocument();
});
});

it('calls onDigitPress and logs when a digit is pressed', async () => {
render(<CallControlDtmfKeypad onDigitPress={onDigitPress} logger={logger} />);

const keypad = await screen.findByTestId('call-control-keypad-keys');
fireEvent.click(within(keypad).getByText('5'));

expect(onDigitPress).toHaveBeenCalledWith('5');
expect(logger.info).toHaveBeenCalledWith('CC-Widgets: CallControl: DTMF digit pressed', {
module: 'call-control-dtmf-keypad.tsx',
method: 'handleDigitPress',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('CallControlComponent Snapshots', () => {
toggleHold: jest.fn(),
toggleRecording: jest.fn(),
toggleMute: jest.fn(),
sendDtmf: jest.fn(),
isMuted: false,
endCall: jest.fn(),
wrapupCall: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe('CallControlComponent', () => {
toggleHold: jest.fn(),
toggleRecording: jest.fn(),
toggleMute: jest.fn(),
sendDtmf: jest.fn(),
isMuted: false,
endCall: jest.fn(),
wrapupCall: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ describe('CallControl Utils', () => {
jest.fn() // mergeConference
);

expect(buttons).toHaveLength(10); // Updated to 10 to include switchToConsult, transferConsult, and conference buttons
expect(buttons).toHaveLength(11); // Includes keypad (WXCC-6026), switchToConsult, transferConsult, and conference buttons

// Check mute button
const muteButton = buttons.find((b) => b.id === 'mute');
Expand Down Expand Up @@ -474,6 +474,76 @@ describe('CallControl Utils', () => {
});
});

it('includes visible keypad button when main keypad control is enabled (WXCC-6026)', () => {
const controlsWithKeypad = {
...mockControls,
main: {
...mockControls.main,
keypad: {isVisible: true, isEnabled: true},
},
};

const buttons = buildCallControlButtons(
false,
false,
false,
mockMediaTypeInfo,
controlsWithKeypad,
false,
mockFunctions.handleMuteToggleFunc,
mockFunctions.handleToggleHoldFunc,
mockFunctions.toggleRecording,
mockFunctions.endCall,
mockFunctions.exitConference,
mockFunctions.switchToConsult,
jest.fn(),
jest.fn()
);

const keypadButton = buttons.find((b) => b.id === 'keypad');
expect(keypadButton).toEqual({
id: 'keypad',
icon: 'dialpad-bold',
tooltip: 'Keypad',
className: 'call-control-button',
disabled: false,
isVisible: true,
menuType: 'Keypad',
dataTestId: 'call-control:keypad',
});
});

it('hides keypad button when main keypad control is not visible (WXCC-6026)', () => {
const controlsWithoutKeypad = {
...mockControls,
main: {
...mockControls.main,
keypad: {isVisible: false, isEnabled: false},
},
};

const buttons = buildCallControlButtons(
false,
false,
false,
mockMediaTypeInfo,
controlsWithoutKeypad,
false,
mockFunctions.handleMuteToggleFunc,
mockFunctions.handleToggleHoldFunc,
mockFunctions.toggleRecording,
mockFunctions.endCall,
mockFunctions.exitConference,
mockFunctions.switchToConsult,
jest.fn(),
jest.fn()
);

const keypadButton = buttons.find((b) => b.id === 'keypad');
expect(keypadButton?.isVisible).toBe(false);
expect(keypadButton?.disabled).toBe(true);
});

it('should build buttons with correct configuration when not muted and held', () => {
const heldControls = createEnabledMainTaskUIControls({
wrapup: enabledControl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ describe('CallControlCADComponent Snapshots', () => {
toggleHold: jest.fn(),
toggleRecording: jest.fn(),
toggleMute: jest.fn(),
sendDtmf: jest.fn(),
isMuted: false,
endCall: jest.fn(),
wrapupCall: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe('CallControlCADComponent', () => {
toggleHold: jest.fn(),
toggleRecording: jest.fn(),
toggleMute: jest.fn(),
sendDtmf: jest.fn(),
isMuted: false,
endCall: jest.fn(),
wrapupCall: jest.fn(),
Expand Down
6 changes: 3 additions & 3 deletions packages/contact-center/store/ai-docs/store-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ as approved unknowns only when the human explicitly defers or does not know.
|---|---|---|---|
| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/AGENTS.md` | overview / API / usage | migrated | Overview, Purpose, Public Surface, Use Cases; usage snippets condensed to behavior. |
| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/ARCHITECTURE.md` | architecture / sequence diagrams | reconciled | Design Overview, Data Flow, Sequence Diagram(s), Pitfalls. Diagrams re-derived from current `store.ts` / `storeEventsWrapper.ts`; see Conflicts note below for drift corrected. |
| `packages/contact-center/ai-docs/features/thick-client-answer/intake.md` | wxApp thick-client answer (WXCC-6026) | reference-only (implemented) | Mercury mute sync → `TASK_WXAPP_MUTE_STATE_UPDATED`; see Design Overview event handling |
| `@webex/contact-center` package types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | SDK API reference (installed `.d.ts`) | reference-only | Linked as the authoritative source for SDK-shaped types/methods consumed via `store.cc.*`. |

## Overview
`@webex/cc-store` is the single shared MobX store for every Webex Contact Center widget. It is the sole boundary between widgets and the `@webex/contact-center` SDK: widgets never import the SDK directly — they read observables and call methods on the store, which proxies to `store.cc.*`. The package is structured in two layers. `Store` (`src/store.ts`) is a `makeAutoObservable` singleton (`Store.getInstance()`) that holds raw observable state and owns initialization/registration with the SDK. `StoreWrapper` (`src/storeEventsWrapper.ts`) is the default export — it wraps the singleton, getter-proxies every observable, owns all SDK event wiring (CC + task events), exposes mutators (all writes funnel through `runInAction`), list-fetch helpers, callback registration, and task-lifecycle handling.

`src/index.ts` re-exports the `StoreWrapper` instance as the default export plus everything from `store.types.ts` (types, the `CC_EVENTS` / `TASK_EVENTS` enums, login/consult/campaign constants) and `task-utils.ts` (pure selectors over SDK `ITask` objects). `util.ts` extracts a fixed allow-list of feature flags from the agent `Profile` at registration time.
Expand Down Expand Up @@ -111,13 +110,14 @@ 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` | Per-task listener on **`TASK_WXAPP_MUTE_STATE_UPDATED`** (guarded by `wxAppMuteStateListeners` map) calls **`handleWxAppMuteStateUpdated`** → `setIsMuted(payload.muted)` only when the task matches `currentTask`; detached in **`handleTaskRemove`** | Webex App mute/unmute must sync embed UI without widgets calling Mercury; prevent duplicate listeners | `src/storeEventsWrapper.ts:507-509,942-946,999-1003` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | SDK must emit event; store does not call telephony REST | 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.

Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`). With a host-supplied `webex`, the wrapper wires event listeners and registers synchronously. Without one, the store calls `Webex.init()`, arms a 6000ms timeout, and waits for the `ready` event before wiring listeners and registering; the timeout guards against an SDK that never becomes ready. Registration maps the agent `Profile` into observables once.

Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`.
Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. **WxApp mute sync:** when the SDK emits **`TASK_WXAPP_MUTE_STATE_UPDATED`** (Mercury path in SDK), the store updates **`isMuted`** for the current task via **`handleWxAppMuteStateUpdated`** — widgets read `store.isMuted`; they never subscribe to Mercury directly. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`.

Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`.

Expand Down
Loading
Loading