diff --git a/packages/contact-center/ai-docs/migration/call-control-hook-migration.md b/packages/contact-center/ai-docs/migration/call-control-hook-migration.md index 7df987410..22aec043d 100644 --- a/packages/contact-center/ai-docs/migration/call-control-hook-migration.md +++ b/packages/contact-center/ai-docs/migration/call-control-hook-migration.md @@ -2,31 +2,44 @@ ## Summary -`useCallControl` is the largest and most complex hook in CC Widgets. It orchestrates hold, mute, recording, consult, transfer, conference, wrapup, and auto-wrapup flows. This migration replaces widget-side control computation with `task.uiControls` and simplifies event-driven state updates. +**Status: Done.** `useCallControl` in [`helper.ts`](../../task/src/helper.ts) reads SDK-computed `TaskUIControls` (per-leg: `main`, `consult`, `activeLeg`) instead of `getControlsVisibility()`. Action methods (`task.hold()`, `task.end()`, etc.) are unchanged. -### Dead code removed by this migration +### Dual refresh path for `uiControls` -The following functions are deleted — their only consumer (`getControlsVisibility`) is being removed: +1. **Store:** `TASK_UI_CONTROLS_UPDATED` → `handleUIControlsUpdated` → `refreshTaskList()` → MobX re-render +2. **Hook:** Direct subscription on `currentTask.on(TASK_UI_CONTROLS_UPDATED)` → `setControls(updatedControls)` for immediate button updates -| Function | Why dead | -|----------|----------| -| `getControlsVisibility` + 22 `get*ButtonVisibility` functions | Replaced by `task.uiControls` | -| `findHoldStatus(task, mType, agentId)` | SDK tracks hold state internally in `TaskContext`. Get from task object. | -| `getConsultStatus` / `getTaskStatus` / `getConsultMPCState` | Entire chain consumed only by `getControlsVisibility` (see [store-task-utils-migration.md](./store-task-utils-migration.md)) | +### Per-leg control access -### Props removed +```typescript +const [controls, setControls] = useState( + currentTask?.uiControls ?? getDefaultUIControls() +); -| Old prop | Why removed | -|----------|-------------| -| `deviceType` | SDK handles via `UIControlConfig` | -| `featureFlags` | SDK handles via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled` | -| `conferenceEnabled` | SDK computes conference/mergeToConference/exitConference visibility based on task state and config | +// Main leg buttons +controls.main.hold +controls.main.end +controls.main.wrapup + +// Consult panel +controls.consult.endConsult +controls.consult.mergeToConference +controls.activeLeg // 'main' | 'consult' — hold/switch UI during consult +``` + +`buildCallControlButtons()` in [`call-control.utils.ts`](../../cc-components/src/components/task/CallControl/call-control.utils.ts) maps `controls.main.*` and optional consult panel from `controls.consult.*`. + +### Props -### Props retained +| Prop | Status | +|------|--------| +| `deviceType`, `featureFlags` | **Removed** — SDK `UIControlConfig` handles gating | +| `conferenceEnabled` | **Retained** — app-level override in button builders | +| `agentId` | **Retained** — timers, buddy agents, participant lookup | + +### Dead code removed -| Prop | Why kept | -|------|----------| -| `agentId` | Timer utils need it for participant lookup | +`getControlsVisibility` + 22 `get*ButtonVisibility` functions deleted from `task-util.ts`. See [store-task-utils-migration.md](./store-task-utils-migration.md). --- @@ -114,30 +127,28 @@ The following functions are deleted — their only consumer (`getControlsVisibil ## Old → New Mapping Table -### Control Properties +### Control Properties (per-leg) + +Access via `controls.main.*` or `controls.consult.*`: | Old Property | New Property | Change | |-------------|-------------|--------| -| `accept` | `controls.accept` | Nested under `controls` | -| `decline` | `controls.decline` | Nested under `controls` | -| `end` | `controls.end` | Nested under `controls` | -| `muteUnmute` | `controls.mute` | **Renamed** + nested | -| `holdResume` | `controls.hold` | **Renamed** + nested | -| `pauseResumeRecording` | `controls.recording` | **Renamed** — toggle button (pause/resume) | -| `recordingIndicator` | `controls.recording` | **Same SDK control** — widget must keep separate UI for recording status badge vs toggle. Use `recording.isVisible` for badge, `recording.isEnabled` for toggle interactivity | -| `transfer` | `controls.transfer` | Nested | -| `conference` | `controls.conference` | Nested | -| `exitConference` | `controls.exitConference` | Nested | -| `mergeConference` | `controls.mergeToConference` | **Renamed** + nested | -| `consult` | `controls.consult` | Nested | -| `endConsult` | `controls.endConsult` | Nested | -| `consultTransfer` | **Use `controls.transfer` or `controls.transferConference`** for consult/conference transfer button visibility | `controls.consultTransfer` is always hidden in new SDK — do not wire UI to it | -| `consultTransferConsult` | `controls.transfer` / `controls.transferConference` | **Split** — `transfer` for consult transfer, `transferConference` for conference transfer | -| `mergeConferenceConsult` | `controls.mergeToConference` | **Merged** | -| `muteUnmuteConsult` | `controls.mute` | **Merged** | -| `switchToMainCall` | `controls.switchToMainCall` | Nested | -| `switchToConsult` | `controls.switchToConsult` | Nested | -| `wrapup` | `controls.wrapup` | Nested | +| `accept` | `controls.main.accept` | Per-leg | +| `decline` | `controls.main.decline` | Per-leg | +| `end` | `controls.main.end` | Per-leg | +| `muteUnmute` | `controls.main.mute` | **Renamed** | +| `holdResume` | `controls.main.hold` | **Renamed** | +| `pauseResumeRecording` | `controls.main.recording` | **Renamed** | +| `recordingIndicator` | `controls.main.recording` | Same control — badge vs toggle in UI | +| `transfer` | `controls.main.transfer` | Per-leg | +| `conference` | `controls.main.conference` / `controls.consult.conference` | Per-leg | +| `exitConference` | `controls.main.exitConference` | Per-leg | +| `mergeConference` | `controls.main.mergeToConference` | **Renamed** | +| `consult` | `controls.main.consult` | Initiate consult button | +| `endConsult` | `controls.consult.endConsult` | Consult panel | +| `consultTransfer` | `controls.main.transfer` / `controls.consult.transfer` | `consultTransfer` hidden in SDK | +| `switchToMainCall` / `switchToConsult` | `controls.main.switch` / `controls.consult.switch` | **Renamed** to `switch` | +| `wrapup` | `controls.main.wrapup` | Per-leg | ### State Flags @@ -205,44 +216,34 @@ export function useCallControl(props: useCallControlProps) { } ``` -### After +### After (current implementation) ```typescript export function useCallControl(props: useCallControlProps) { const task = props.currentTask; - - // NEW: Read SDK-computed controls directly + const [controls, setControls] = useState( task?.uiControls ?? getDefaultUIControls() ); - // Subscribe to UI control updates useEffect(() => { if (!task) { setControls(getDefaultUIControls()); return; } - setControls(task.uiControls); + setControls(task.uiControls ?? getDefaultUIControls()); const onControlsUpdated = (updatedControls: TaskUIControls) => { setControls(updatedControls); }; - // Event name: SDK may expose TASK_EVENTS.TASK_UI_CONTROLS_UPDATED later; until then use literal - task.on('task:ui-controls-updated', onControlsUpdated); + task.on(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated); return () => { - task.off('task:ui-controls-updated', onControlsUpdated); + task.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated); }; }, [task]); - // Keep event callbacks for actions that need hook-level side effects - // (hold timer, mute state, recording state) - useEffect(() => { - if (!task) return; - store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, task.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, task.data.interactionId); - // ... recording callbacks - return () => { /* cleanup */ }; - }, [task]); + // isHeld: isInteractionOnHold + consult activeLeg + conference hold flags + // ... event callbacks for hold, recording, wrapup host notifications ... - return { controls, isMuted, isRecording, holdTime, /* ... actions */ }; + return { controls, isHeld, isMuted, isRecording, conferenceEnabled, /* actions */ }; } ``` @@ -415,7 +416,7 @@ export function calculateStateTimerData( ## Migration Gotchas -1. **`UIControlConfig` is built by SDK:** Widgets do NOT provide it. The SDK handles feature-flag gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled`. Widget props `deviceType`, `featureFlags`, and `conferenceEnabled` can be **removed**. There is no `applyFeatureGates` function. **Retain `agentId`** — timer utils need it for participant lookup. +1. **`UIControlConfig` is built by SDK:** Widgets do NOT provide it. The SDK handles feature-flag gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled`. Widget props `deviceType` and `featureFlags` can be **removed**. **`conferenceEnabled` is RETAINED** — it is an application-level config (not a feature flag) that gates conference UI at the consumer level. There is no `applyFeatureGates` function. **Retain `agentId`** — timer utils need it for participant lookup. 2. **`isHeld` derivation:** Hold control can be `VISIBLE_DISABLED` in conference/consulting states without meaning the call is held. Do NOT derive from `controls.hold.isEnabled` — it is an action flag (button clickability), not hold state. Get hold state from the task object (SDK tracks hold state internally). `findHoldStatus()` is dead code and will be removed (see [store-task-utils-migration.md](./store-task-utils-migration.md)). @@ -441,18 +442,82 @@ export function calculateStateTimerData( ## Validation Criteria -- [ ] All 17 SDK controls render correctly in CallControl UI -- [ ] Hold toggle works (CONNECTED ↔ HELD) -- [ ] Mute toggle works (local WebRTC state) -- [ ] Recording toggle works (pause/resume) -- [ ] Consult flow: initiate → switch calls → end/transfer/conference -- [ ] Conference flow: merge → exit → transfer conference -- [ ] Wrapup flow: end → wrapup → complete -- [ ] Auto-wrapup timer works -- [ ] Hold timer displays correctly -- [ ] Digital channel shows only accept/end/transfer/wrapup -- [ ] All action methods still call correct SDK methods +| Criterion | Status | +|-----------|--------| +| SDK controls render in CallControl UI (main + consult legs) | **Done** | +| Hold / mute / recording / consult / conference / wrapup flows | **Done** | +| Auto-wrapup and hold timers | **Done** | +| `conferenceEnabled` app-level gating | **Done** | +| `getControlsVisibility` removed | **Done** | +| All actions call correct SDK methods | **Done** | --- _Parent: [migration-overview.md](./migration-overview.md)_ +_Updated: 2026-05-20_ + +--- + +## Migration Fix Log + +### Fix: `isHeld` Reactivity — Hold Button State and Multi-Login Sync + +- **Issue**: After migration, the hold button icon/tooltip did not toggle on click, and multi-login hold/resume did not sync across systems. +- **Root Cause**: The old `controlVisibility.isHeld` was removed. `controls.hold.isEnabled` is an action flag, not state. `task.data.isOnHold` is not populated by SDK at runtime. The SDK state machine also lacked `HOLD_SUCCESS`/`UNHOLD_SUCCESS` transitions for multi-login scenarios. +- **SDK Source of Truth**: `uiControlsComputer.ts` derives `isHeld` from `serverHold ?? state === TaskState.HELD`. `controls.hold` is `VISIBLE_ENABLED` in both `CONNECTED` and `HELD` states — it's an action flag, not a state indicator. +- **Fix Pattern** (in `useCallControl` hook — `helper.ts`): + ```typescript + import { isInteractionOnHold } from '@webex/cc-store'; + + const [isHeld, setIsHeld] = useState(() => + currentTask ? isInteractionOnHold(currentTask) : false + ); + + useEffect(() => { + setIsHeld(currentTask ? isInteractionOnHold(currentTask) : false); + }, [currentTask]); + + // In holdCallback: setIsHeld(true); + // In resumeCallback: setIsHeld(false); + // Return isHeld from hook + ``` +- **SDK Fix**: Added `HOLD_SUCCESS` handler to `CONNECTED` state and `UNHOLD_SUCCESS` handler to `HELD` state in `TaskStateMachine.ts` for multi-login sync. + +### Fix: Restore `conferenceEnabled` Prop — Application-Level Conference Gating + +- **Issue**: During the task-refactor migration, the `conferenceEnabled` prop was removed from the widget APIs. This prop is **not a feature flag** — it is an application-level configuration passed from `App.tsx` that controls whether conference-related UI controls should be available to the agent. Without it, applications cannot disable conference features regardless of SDK `uiControls`. +- **Root Cause**: The migration assumed all UI visibility is driven exclusively by `task.uiControls` from the SDK state machine. However, `conferenceEnabled` is an application-level override that gates conference availability at the consumer level, independent of the SDK's computed state. +- **Design Decision (Option A — Widget-Side Override at Button Level)**: `conferenceEnabled` is applied directly in the button builder functions (`buildCallControlButtons` and `createConsultButtons`) where conference-related buttons are defined. When `false`, the `isVisible` property of conference buttons (`conference`, `exitConference`, `merge`) is forced to `false` regardless of SDK `uiControls`. When `true` (default), SDK controls pass through unchanged. +- **Gating Pattern** (in button builder functions): + ```typescript + // call-control.utils.ts — buildCallControlButtons + // conferenceEnabled param defaults to true + { + id: 'conference', + isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false) && !!handleConsultConferencePress, + }, + { + id: 'exitConference', + isVisible: conferenceEnabled && (controls?.exitConference?.isVisible ?? false), + }, + + // call-control-custom.utils.ts — createConsultButtons + { + key: 'conference', + isVisible: conferenceEnabled && (controls?.mergeToConference?.isVisible ?? false), + }, + ``` +- **Prop Flow**: `App.tsx` → `CallControl`/`CallControlCAD` → `useCallControl` hook → returned as prop → `CallControlComponent` → `buildCallControlButtons()` / `CallControlConsultComponent` → `createConsultButtons()` +- **Files Changed**: + - `cc-components/…/task.types.ts`: Added `conferenceEnabled: boolean` to `ControlProps`, `CallControlComponentProps`, `CallControlConsultComponentsProps` + - `cc-components/…/call-control.utils.ts`: Added `conferenceEnabled` param to `buildCallControlButtons`, gated `conference` and `exitConference` buttons + - `cc-components/…/call-control-custom.utils.ts`: Added `conferenceEnabled` param to `createConsultButtons`, gated `conference` (merge) button + - `cc-components/…/call-control.tsx`: Destructured `conferenceEnabled`, passed to `buildCallControlButtons` + - `cc-components/…/call-control-consult.tsx`: Destructured `conferenceEnabled`, passed to `createConsultButtons` + - `cc-components/…/call-control-cad.tsx`: Destructured `conferenceEnabled`, passed to `CallControlConsultComponent` + - `task/src/task.types.ts`: Added `conferenceEnabled` to `CallControlProps` and `useCallControlProps` + - `task/src/helper.ts`: Destructured `conferenceEnabled` (default `true`), returned from hook + - `task/src/CallControl/index.tsx` and `CallControlCAD/index.tsx`: Pass `conferenceEnabled` to `useCallControl` + - `cc-widgets/src/wc.ts`: Exposed `conferenceEnabled` as r2wc `boolean` prop on `WebCallControl` and `WebCallControlCAD` +- **Consumer Usage**: Apps pass `conferenceEnabled={true|false}` as a prop to `` or ``. Web component consumers set the `conference-enabled` attribute. Defaults to `true` if not provided. +- **Result**: Conference buttons (merge, exit conference) are hidden when `conferenceEnabled` is `false`, while all other SDK-driven controls remain unaffected. diff --git a/packages/contact-center/ai-docs/migration/component-layer-migration.md b/packages/contact-center/ai-docs/migration/component-layer-migration.md index 5083a2313..ae113d243 100644 --- a/packages/contact-center/ai-docs/migration/component-layer-migration.md +++ b/packages/contact-center/ai-docs/migration/component-layer-migration.md @@ -2,60 +2,47 @@ ## Summary -The `cc-components` package contains the presentational React components for task widgets. These components receive control visibility as props. The prop interface must be updated to match the new `TaskUIControls` shape from SDK (renamed controls, merged controls, removed state flags). +**Status: Done.** Presentational components consume SDK `TaskUIControls` with **per-leg** structure (`main`, `consult`, `activeLeg`). The old flat `ControlVisibility` interface (22 controls + 7 state flags) is replaced by `TaskUIControls` imported from `@webex/cc-store`. ### Source of truth — the task object (`ITask`) -After the task-refactor, **everything comes from the SDK task object** (`ITask`). Widgets should not derive state with helper functions like `getControlsVisibility()` or `findHoldStatus()`, and should have zero awareness of the SDK's internal state machine. +- **Control visibility/enablement:** `task.uiControls.main.*` and `task.uiControls.consult.*` +- **Active leg during consult:** `task.uiControls.activeLeg` (`'main'` | `'consult'`) +- **Hold state:** `isHeld` prop from hook (`isInteractionOnHold` + consult/conference logic) — not `controls.main.hold.isEnabled` +- **Conference state:** interaction `state === 'conference'` / task data — not `exitConference.isVisible` alone -- **Control visibility and enablement:** `task.uiControls` — a property on the `ITask` object. Each of the 17 controls has `{ isVisible: boolean, isEnabled: boolean }`. The widget hook reads `store.currentTask.uiControls` and passes it to the component. Do not derive from `deviceType`, `featureFlags`, `conferenceEnabled`, or the legacy `getControlsVisibility()`. -- **Hold state (`isHeld`, `consultCallHeld`):** Provided by the task object. Do not use the legacy `findHoldStatus()` helper or `controls.hold.isEnabled` (those are action flags, not hold state). -- **Conference state (`isConferenceInProgress`):** Provided by the task object (e.g. `task.data.isConferenceInProgress`). Do not use `controls.exitConference.isVisible` as sole source — it can be false when consult is active even if conference is in progress. +Widgets do not call `getControlsVisibility()` or `findHoldStatus()`. --- -## ControlVisibility Interface — Delete and Replace +## ControlVisibility Interface — Replaced by `TaskUIControls` **File:** `cc-components/src/components/task/task.types.ts` -The old `ControlVisibility` interface (22 controls + 7 state flags) must be replaced with `TaskUIControls` from the SDK. All new control values come from `task.uiControls` — a property on the `ITask` object provided by the SDK task-refactor branch. - ```typescript -// OLD — DELETE this interface -export interface ControlVisibility { - accept: Visibility; // → task.uiControls.accept (same name) - decline: Visibility; // → task.uiControls.decline (same name) - end: Visibility; // → task.uiControls.end (same name) - muteUnmute: Visibility; // → task.uiControls.mute (renamed) - muteUnmuteConsult: Visibility; // → REMOVE — use task.uiControls.mute (single mute control covers both main and consult) - holdResume: Visibility; // → task.uiControls.hold (renamed) - consult: Visibility; // → task.uiControls.consult (same name) - transfer: Visibility; // → task.uiControls.transfer (same name) - conference: Visibility; // → task.uiControls.conference; SDK also has task.uiControls.mergeToConference — use mergeToConference for Merge action - wrapup: Visibility; // → task.uiControls.wrapup (same name) - pauseResumeRecording: Visibility; // → task.uiControls.recording (renamed) - endConsult: Visibility; // → task.uiControls.endConsult (same name) - recordingIndicator: Visibility; // → REMOVE — merged into task.uiControls.recording (use recording.isVisible for badge, recording.isEnabled for toggle) - exitConference: Visibility; // → task.uiControls.exitConference (same name) - mergeConference: Visibility; // → task.uiControls.mergeToConference (renamed) - consultTransfer: Visibility; // → task.uiControls.consultTransfer — NOTE: always hidden in new SDK; use task.uiControls.transfer or task.uiControls.transferConference instead - mergeConferenceConsult: Visibility; // → REMOVE — use task.uiControls.mergeToConference (single control covers both main and consult merge) - consultTransferConsult: Visibility; // → REMOVE — use task.uiControls.transfer for consult transfer, task.uiControls.transferConference for conference transfer - switchToMainCall: Visibility; // → task.uiControls.switchToMainCall (same name) - switchToConsult: Visibility; // → task.uiControls.switchToConsult (same name) - isConferenceInProgress: boolean; // → use `task.data.isConferenceInProgress` (SDK provides this directly); do NOT use controls.exitConference.isVisible as sole source — it can be false when consult is active even if conference is in progress - isConsultInitiated: boolean; // → Do NOT use endConsult.isVisible as "initiated only"; it covers both initiated and accepted. Use `task.data.consultStatus` if you need that distinction (e.g. `consultInitiated` vs `consultAccepted`). - isConsultInitiatedAndAccepted: boolean; // → REMOVE - isConsultReceived: boolean; // → REMOVE - isConsultInitiatedOrAccepted: boolean; // → REMOVE - isHeld: boolean; // → get from task object (SDK provides hold state). Do NOT use controls.hold.isEnabled (that is an action flag, not hold state). - consultCallHeld: boolean; // → get from task object. Do NOT use controls.switchToConsult.isVisible (that is button visibility, not hold state). -} - -// NEW — import via store to preserve layering (cc-components → store → SDK). Store re-exports TaskUIControls from SDK. -import type { TaskUIControls } from '@webex/cc-store'; +import type { TaskUIControls, InteractionUIControls, TaskUILeg } from '@webex/cc-store'; + +// TaskUIControls shape (SDK): +// { +// main: InteractionUIControls; +// consult: InteractionUIControls; +// activeLeg: 'main' | 'consult'; +// } ``` +### Per-leg control mapping (main leg) + +| Old flat prop | New path | Notes | +|---------------|----------|-------| +| `holdResume` | `controls.main.hold` | Renamed | +| `muteUnmute` | `controls.main.mute` | Renamed | +| `pauseResumeRecording` / `recordingIndicator` | `controls.main.recording` | Single control; UI splits badge vs toggle | +| `mergeConference` | `controls.main.mergeToConference` | Renamed | +| `switchToMainCall` / `switchToConsult` | `controls.main.switch` / `controls.consult.switch` | Renamed to `switch` | +| State flags (`isConsultInitiated`, etc.) | Removed from props | Use `controls.consult.endConsult`, `task.data`, or hook `isHeld` | + +CallControl passes `controls: TaskUIControls` to `buildCallControlButtons(controls.main, ...)` and consult panel via `createConsultButtons(controls.consult, ...)`. + --- ## Components to Update @@ -83,60 +70,45 @@ import type { TaskUIControls } from '@webex/cc-store'; | `isHeld` | `isHeld` | **Retain** — get from the task object (SDK provides hold state). Do NOT derive from `controls.hold.isEnabled`. | | `consultCallHeld` | — | **Remove** (get from the task object if needed for display) | -#### Proposed New Interface +#### Current Interface ```typescript interface CallControlComponentProps { - controls: TaskUIControls; // All 17 controls from task.uiControls - // Hold state from the task object (SDK provides this). Do NOT use findHoldStatus() or controls.hold.isEnabled. - isHeld: boolean; - isMuted: boolean; - isRecording: boolean; - holdTime: number; - secondsUntilAutoWrapup: number; - buddyAgents: BuddyDetails[]; // Use exported type from @webex/cc-store or task.types (not a generic Agent type) - consultAgentName: string; - // Actions. onToggleHold(hold) — pass intended hold state (true = hold, false = resume); matches toggleHold(hold: boolean) in task.types. - onToggleHold: (hold: boolean) => void; - onToggleMute: () => void; - onToggleRecording: () => void; - onEndCall: () => void; - onWrapupCall: (reason: string, auxCodeId: string) => void; // Invoked from wrap-up UI on submit - onTransferCall: (payload: TransferPayLoad) => void; // Invoked from transfer popover on submit - onConsultCall: (payload: ConsultPayload) => void; // Invoked from consult popover on submit - onEndConsultCall: () => void; - onConsultTransfer: () => void; - onConsultConference: () => void; - onExitConference: () => void; - onSwitchToConsult: () => void; - onSwitchToMainCall: () => void; - onCancelAutoWrapup: () => void; + controls: TaskUIControls; // { main, consult, activeLeg } from task.uiControls + isHeld: boolean; // Hook-derived; not controls.main.hold.isEnabled + conferenceEnabled: boolean; // App-level gating + // ... actions, buddyAgents, consultAgentName, media state } ``` ### CallControlConsult **File:** `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx` -- Update to use `controls.endConsult`, `controls.mergeToConference`, `controls.switchToMainCall`, `controls.switchToConsult` -- Remove separate `consultTransferConsult`, `mergeConferenceConsult`, `muteUnmuteConsult` props +- Uses `controls.consult.endConsult`, `controls.consult.mergeToConference`, `controls.consult.switch` +- Main-leg switch via `controls.main.switch` when on consult leg +- `conferenceEnabled` gates merge/conference buttons ### IncomingTaskComponent -**File:** `packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx` -- Accept: `controls.accept.isVisible` / `controls.accept.isEnabled` -- Decline: `controls.decline.isVisible` / `controls.decline.isEnabled` -- Minimal changes — shape is compatible +- Accept/decline from `acceptControl` / `declineControl` props (from `uiControls.main`) +- `isBrowser` **retained** for outdial accept label text ("Accept" vs "Ringing...") +- `isDeclineButtonEnabled` **retained** as legacy bridge OR'd with SDK decline enablement ### TaskListComponent -**File:** `packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx` -- Per-task accept/decline: use `task.uiControls.accept` / `task.uiControls.decline` -- Task status display: if consult status is needed for labels, use `task.data.consultStatus` (SDK provides directly) +- Same per-task `uiControls.main.accept/decline` via `extractTaskListItemData` +- `isBrowser` retained for outdial label rules + +### CallControlCADComponent + +- Receives `controls: TaskUIControls` and `isHeld` from hook +- **Outdial header number:** `displayNumber` uses `dnis` for outdial, `ani` for inbound (header title) +- **Phone Number label:** continues to use `ani` (PROD parity) +- `conferenceEnabled` passed to consult sub-component ### OutdialCallComponent -**File:** `packages/contact-center/cc-components/src/components/task/OutdialCall/outdial-call.tsx` -- **No changes needed** — OutdialCall does not use task controls +- **No uiControls** — dial UI only; failure popup via host `setOutdialFailed` --- @@ -196,103 +168,44 @@ const CallControlComponent = ({ }; ``` -### After +### After (current implementation) + +CallControl receives full `TaskUIControls` and `buildCallControlButtons` reads **`controls.main`** for the main strip and **`controls.consult`** for the consult panel (via `createConsultButtons`). + ```tsx -// call-control.tsx — new approach +// call-control.tsx — current approach const CallControlComponent = ({ - controls, // TaskUIControls — all 17 controls from task.uiControls - isHeld, // From task object (SDK provides hold state) - isMuted, isRecording, holdTime, - onToggleHold, onToggleMute, onEndCall, onEndConsultCall, - onConsultTransfer, onConsultConference, onExitConference, - onSwitchToMainCall, onSwitchToConsult, ... + controls, // TaskUIControls { main, consult, activeLeg } + isHeld, // From hook (isInteractionOnHold + consult/conference logic) + isMuted, isRecording, holdTime, conferenceEnabled, + onToggleHold, onToggleMute, onEndCall, ... }: CallControlComponentProps) => { - // Implement openTransferPopover / openConsultPopover / openWrapupPopover (e.g. set state to show popover); popover on submit calls onTransferCall(payload) / onConsultCall(payload) / onWrapupCall(reason, auxCodeId). - // Derive display-only flags from controls (replaces old state flag props) - const isConsulting = controls.endConsult.isVisible; - // Get from task object; do not use controls.exitConference.isVisible as sole source - const isConferencing = currentTask.data.isConferenceInProgress; - - // isHeld comes from the task object (SDK provides hold state). Do NOT use controls.hold.isEnabled for toggle — that is an action flag, not hold state. - return ( -
- {controls.hold.isVisible && ( - - )} - {controls.mute.isVisible && ( - - )} - {controls.end.isVisible && ( - - )} - {/* Transfer and Consult: buttons open popover/menu; popover invokes onTransferCall(payload) / onConsultCall(payload) on confirm */} - {controls.transfer.isVisible && ( - - )} - {controls.consult.isVisible && ( - - )} - {/* Active consult controls */} - {controls.endConsult.isVisible && ( - - )} - {controls.mergeToConference.isVisible && ( - - )} - {controls.switchToMainCall.isVisible && ( - - )} - {controls.switchToConsult.isVisible && ( - - )} - {/* Conference controls */} - {controls.exitConference.isVisible && ( - - )} - {controls.transferConference.isVisible && ( - - )} - {/* Recording */} - {controls.recording.isVisible && ( - - )} - {/* Wrap Up: button opens wrap-up UI; UI on submit calls onWrapupCall(reason, auxCodeId) */} - {controls.wrapup.isVisible && ( - - )} -
+ const buttons = buildCallControlButtons( + isMuted, isRecording, isMuteButtonDisabled, currentMediaType, + controls, isHeld, ..., conferenceEnabled ); + const filteredButtons = filterButtonsForConsultation(buttons, controls); + // Consult strip: createConsultButtons(controls.consult, ...) }; ``` +Inside `buildCallControlButtons`, main-leg buttons use `controls.main.*`: + +```typescript +const mainCtrl = controls?.main; +// mainCtrl.hold, mainCtrl.mute, mainCtrl.transfer, mainCtrl.switch, etc. +``` + --- ## Deriving State Flags from Controls -Components that previously relied on state flags can derive them: - -```typescript -// Old: isConferenceInProgress (boolean prop) -// New: get from the task object (e.g. task.data.isConferenceInProgress). -// Do NOT use controls.exitConference.isVisible — it can be false when consult is active -// even if conference is in progress. -const isConferenceInProgress = currentTask.data.isConferenceInProgress; - -// Old: isConsultInitiatedOrAccepted (boolean prop) -// New: derive from controls -const isConsulting = controls.endConsult.isVisible; - -// Old: isHeld (boolean state flag from getControlsVisibility, derived via findHoldStatus) -// New: get from the task object — SDK provides hold state directly. -// Do NOT use findHoldStatus() (legacy widget-side derivation) or controls.hold.isEnabled (action flag). -const isHeld = /* from task object */; -``` +| Old flag | Current source | +|----------|----------------| +| `isConferenceInProgress` | Interaction `state === 'conference'` or task data | +| `isConsultInitiatedOrAccepted` | `controls.consult.endConsult.isVisible` or `controls.main.endConsult.isVisible` | +| `isHeld` | Hook prop from `isInteractionOnHold` + consult/conference hold events — **not** `controls.main.hold.isEnabled` | +| `activeLeg` | `controls.activeLeg` (`'main'` \| `'consult'`) for switch/hold UI | --- @@ -300,34 +213,31 @@ const isHeld = /* from task object */; ### 1. `buildCallControlButtons()` — call-control.utils.ts -This function builds the main call control button array. It references 12 old control names and 2 state flags: +Takes full `TaskUIControls`; reads **`controls.main`** for the main button strip. | Old Reference | New Equivalent | |--------------|---------------| -| `controlVisibility.muteUnmute.isVisible` | `controls.mute.isVisible` | -| `controlVisibility.isHeld` | Get from the task object (SDK provides hold state). Do NOT use `findHoldStatus()`. | -| `controlVisibility.holdResume.isEnabled` | `controls.hold.isEnabled` | -| `controlVisibility.holdResume.isVisible` | `controls.hold.isVisible` | -| `controlVisibility.consult.isEnabled` | `controls.consult.isEnabled` | -| `controlVisibility.consult.isVisible` | `controls.consult.isVisible` | -| `controlVisibility.isConferenceInProgress` | Use `task.data.isConferenceInProgress` (SDK provides this directly). Do not use `controls.exitConference.isVisible` as sole source — it can be false when consult is active | -| `controlVisibility.consultTransfer.isEnabled` / `.isVisible` | Use **`controls.transfer`** or **`controls.transferConference`** (consult vs conference). Do NOT use `controls.consultTransfer` — always hidden in new SDK. | -| `controlVisibility.mergeConference.isEnabled` | `controls.mergeToConference.isEnabled` | -| `controlVisibility.transfer.isEnabled` | `controls.transfer.isEnabled` | -| `controlVisibility.pauseResumeRecording.isEnabled` | `controls.recording.isEnabled` | -| `controlVisibility.exitConference.isEnabled` | `controls.exitConference.isEnabled` | -| `controlVisibility.end.isEnabled` | `controls.end.isEnabled` | +| `controlVisibility.muteUnmute` | `controls.main.mute` | +| `controlVisibility.isHeld` | `isHeld` param (hook-derived) | +| `controlVisibility.holdResume` | `controls.main.hold` | +| `controlVisibility.consult` | `controls.main.consult` | +| `controlVisibility.transfer` | `controls.main.transfer` | +| `controlVisibility.mergeConference` | `controls.main.mergeToConference` / `controls.main.conference` | +| `controlVisibility.pauseResumeRecording` | `controls.main.recording` | +| `controlVisibility.exitConference` | `controls.main.exitConference` (gated by `conferenceEnabled`) | +| Consult transfer during active consult | Shown when `controls.consult.endConsult` or `controls.main.endConsult` visible | ### 2. `createConsultButtons()` — call-control-custom.utils.ts +Reads **`controls.consult`** for the consult strip. + | Old Reference | New Equivalent | |--------------|---------------| -| `controlVisibility.muteUnmuteConsult` | `controls.mute` | -| `controlVisibility.switchToMainCall` | `controls.switchToMainCall` | -| `controlVisibility.isConferenceInProgress` | Use `task.data.isConferenceInProgress` (SDK provides this directly). Do not use `controls.exitConference.isVisible` as sole source | -| `controlVisibility.consultTransferConsult` | `controls.transfer` / `controls.transferConference` | -| `controlVisibility.mergeConferenceConsult` | `controls.mergeToConference` | -| `controlVisibility.endConsult` | `controls.endConsult` | +| `controlVisibility.muteUnmuteConsult` | `controls.consult.mute` | +| `controlVisibility.switchToMainCall` / `switchToConsult` | `controls.consult.switch` / `controls.main.switch` | +| `controlVisibility.consultTransferConsult` | `controls.consult.transfer` / `controls.consult.consultTransfer` | +| `controlVisibility.mergeConferenceConsult` | `controls.consult.mergeToConference` | +| `controlVisibility.endConsult` | `controls.consult.endConsult` | ### 3. `filterButtonsForConsultation()` — call-control.utils.ts @@ -370,11 +280,11 @@ This function builds the main call control button array. It references 12 old co - `isHeld: boolean` → get from the task object (SDK provides hold state); remove `findHoldStatus` derivation - `deviceType: string` → REMOVE (SDK handles) - `featureFlags: {[key: string]: boolean}` → REMOVE (SDK handles) -- `conferenceEnabled: boolean` → REMOVE (SDK handles) +- ~~`conferenceEnabled: boolean` → REMOVE~~ **RESTORED** — application-level config (not a feature flag), applied at button builder level - `agentId: string` → RETAIN (needed for timer participant lookup) ### `CallControlCAD` — task package and cc-components view -- **task/src/CallControlCAD/index.tsx:** `deviceType`, `featureFlags`, `conferenceEnabled` are used today in `getControlsVisibility` (task-util.ts lines 421–525). The **SDK** handles feature-flag-like gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled` from agent profile and `callProcessingDetails`. Since widgets will read `task.uiControls` instead of calling `getControlsVisibility`, these props can be **removed** — the SDK has already computed them. **Retain `agentId`** for timer participant lookup. +- **task/src/CallControlCAD/index.tsx:** `deviceType` and `featureFlags` are used today in `getControlsVisibility` (task-util.ts lines 421–525). The **SDK** handles feature-flag-like gating internally via `config.isEndTaskEnabled`, `config.isEndConsultEnabled`, `config.isRecordingEnabled` from agent profile and `callProcessingDetails`. Since widgets will read `task.uiControls` instead of calling `getControlsVisibility`, `deviceType` and `featureFlags` can be **removed** — the SDK has already computed them. **`conferenceEnabled` is RETAINED** — it is an application-level configuration (not a feature flag) passed from the consumer app. **Retain `agentId`** for timer participant lookup. - **cc-components/.../CallControlCAD/call-control-cad.tsx:** This view consumes `controlVisibility` (and related state flags such as `isConferenceInProgress`, `isHeld`, `isConsultReceived`, `recordingIndicator`, `isConsultInitiatedOrAccepted`). It must be updated to use `TaskUIControls` and the new prop shape when replacing `ControlVisibility`; otherwise migration will leave stale references and break at compile or runtime. ### Files NOT Impacted (Confirmed) @@ -394,129 +304,60 @@ This function builds the main call control button array. It references 12 old co ## Files to Modify -**Utils and Web Component layer:** Accept/decline and task-display logic live in **task-list.utils.ts** and **incoming-task.utils.tsx** (they today take `isBrowser` and/or `isDeclineButtonEnabled`). These must be updated when moving to per-task `task.uiControls`. The **wc.ts** file defines r2wc props for the Web Component build; when React props drop `isBrowser`, the WC layer must drop the attribute so consumers stay in sync. +**Status: Done.** Utils and WC layer updated for per-leg `uiControls`. -### Before/After: Utils (accept/decline and task list data) +### Current: Utils (accept/decline and task list data) #### `extractIncomingTaskData` (incoming-task.utils.tsx) -**Before:** Signature and logic use `isBrowser` and `isDeclineButtonEnabled`; accept/decline text and disable state are gated by device type and store flag. +**Current:** Uses `uiControls.main.accept/decline` (or caller-passed `acceptControl`/`declineControl`). **`isBrowser` retained** for outdial accept label ("Accept" vs "Ringing..."). **`isDeclineButtonEnabled` retained** as legacy bridge OR'd with SDK decline enablement. ```typescript export const extractIncomingTaskData = ( incomingTask: ITask, - isBrowser: boolean, logger?, - isDeclineButtonEnabled?: boolean -): IncomingTaskData => { - // ... - const acceptText = !incomingTask.data.wrapUpRequired - ? isTelephony && !isBrowser ? 'Ringing...' : 'Accept' - : undefined; - const declineText = !incomingTask.data.wrapUpRequired && isTelephony && isBrowser ? 'Decline' : undefined; - const disableAccept = (isTelephony && !isBrowser) || isAutoAnswering; - const disableDecline = (isTelephony && !isBrowser) || (isAutoAnswering && !isDeclineButtonEnabled); - // ... -}; -``` - -**After:** Remove `isBrowser` and `isDeclineButtonEnabled` from the signature. Derive accept/decline text and disable state from `task.uiControls?.accept` / `task.uiControls?.decline` (or caller-passed visibility) so the util no longer depends on device type or store flag. - -```typescript -export const extractIncomingTaskData = ( - incomingTask: ITask, - logger? + acceptControl?: {isVisible: boolean; isEnabled: boolean}, + declineControl?: {isVisible: boolean; isEnabled: boolean}, + isDeclineButtonEnabled?: boolean, + isBrowser?: boolean ): IncomingTaskData => { - // Use task.uiControls for button visibility and enablement when available - const accept = incomingTask.uiControls?.accept ?? { isVisible: false, isEnabled: false }; - const decline = incomingTask.uiControls?.decline ?? { isVisible: false, isEnabled: false }; - // acceptText: 'Accept' when accept.isVisible, 'Ringing...' for extension telephony if needed from task state - // declineText: 'Decline' when decline.isVisible - // disableAccept: !accept.isEnabled or isAutoAnswering - // disableDecline: !decline.isEnabled or (isAutoAnswering && !decline.isEnabled) + const accept = acceptControl ?? incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; + const sdkDecline = declineControl ?? incomingTask?.uiControls?.main?.decline ?? {...}; + const decline = { ...sdkDecline, isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled }; + const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); + const acceptText = accept.isVisible ? (showRinging ? 'Ringing...' : 'Accept') : undefined; // ... }; ``` #### `extractTaskListItemData` (task-list.utils.ts) -**Before:** Signature takes `isBrowser`; uses `store.isDeclineButtonEnabled` for disable state; accept/decline text gated by `isBrowser`. - -```typescript -export const extractTaskListItemData = ( - task: ITask, - isBrowser: boolean, - agentId: string, - logger?: ILogger -): TaskListItemData => { - // ... - const acceptText = isTaskIncoming ? (isTelephony && !isBrowser ? 'Ringing...' : 'Accept') : undefined; - const declineText = isTaskIncoming && isTelephony && isBrowser ? 'Decline' : undefined; - const disableDecline = - (isTaskIncoming && isTelephony && !isBrowser) || (isAutoAnswering && !store.isDeclineButtonEnabled); - // ... -}; -``` - -**After:** Remove `isBrowser` param and `store.isDeclineButtonEnabled` usage. Use `task.uiControls?.accept` and `task.uiControls?.decline` for button text and disable state. +**Current:** Same per-leg controls + legacy decline bridge + `isBrowser` for outdial label rules. ```typescript export const extractTaskListItemData = ( task: ITask, agentId: string, - logger?: ILogger + logger?: ILogger, + isDeclineButtonEnabled?: boolean, + isBrowser?: boolean ): TaskListItemData => { - const accept = task.uiControls?.accept ?? { isVisible: false, isEnabled: false }; - const decline = task.uiControls?.decline ?? { isVisible: false, isEnabled: false }; - // acceptText from accept.isVisible / task state; declineText from decline.isVisible - // disableAccept: !accept.isEnabled or isAutoAnswering - // disableDecline: !decline.isEnabled or (isAutoAnswering && !decline.isEnabled) - // ... + const accept = task.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; + const decline = { ...sdkDecline, isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled }; + // Same showRinging / acceptText logic as IncomingTask }; ``` -### Before/After: CallControlCAD view (call-control-cad.tsx) +### Current: CallControlCAD view (call-control-cad.tsx) -**Before:** Component receives `controlVisibility: ControlVisibility` and reads legacy state flags and control shapes. +**Current:** Receives `controls: TaskUIControls`, `isHeld` from hook, and `conferenceEnabled`. ```tsx -// call-control-cad.tsx -{controlVisibility.isConferenceInProgress && !controlVisibility.wrapup.isVisible && ( - // ... -)} -{controlVisibility.isHeld && !controlVisibility.isConsultReceived && !controlVisibility.consultCallHeld && ( - // ... -)} -{controlVisibility.recordingIndicator.isVisible && ( - // ... -)} -{controlVisibility.isConsultInitiatedOrAccepted && ( - // ... -)} - -``` - -**After:** Component receives `controls: TaskUIControls` (from `task.uiControls`), `isHeld` from the task object, and **consult-state flags** (`isConsultReceived`, `consultCallHeld`) from the task object — do NOT use `controls.consult.isVisible` for hold-chip gating, as that is an action-availability flag, not consult state. Derive conference display from the task object (not solely `controls.exitConference.isVisible`). Pass `controls` to CallControlComponent. +// Outdial header uses dnis; inbound uses ani +const displayNumber = isOutdial ? dnis || ani : ani; -```tsx -// call-control-cad.tsx -// Conference: get from task object (e.g. task.data.isConferenceInProgress), not controls.exitConference.isVisible -{isConferenceInProgress && !controls.wrapup.isVisible && ( - // ... -)} -// Hold chip: keep consult-state gating; parent passes isConsultReceived and consultCallHeld from the task object -{isHeld && !isConsultReceived && !consultCallHeld && ( - // ... -)} -{controls.recording?.isVisible && ( - // ... -)} -{controls.endConsult?.isVisible && ( - // isConsultInitiatedOrAccepted replaced by controls.endConsult.isVisible (covers both initiated and accepted) - // For phase distinction, use task.data.consultStatus - // ... -)} - +// Hold chip, recording badge, consult panel use controls.main / controls.consult + ``` ### Before/After: Web Component layer (wc.ts) @@ -544,62 +385,93 @@ const WebTaskList = r2wc(TaskListComponent, { }); ``` -**After:** Remove `isBrowser` from both r2wc prop definitions so WC consumers do not pass it; accept/decline visibility comes from per-task `task.uiControls` supplied by the widget layer. +**Current:** `isBrowser` is **retained** on Web IncomingTask and Web TaskList for outdial accept label text. Visibility comes from `uiControls.main`; `isBrowser` is not used to gate button visibility. ```typescript const WebIncomingTask = r2wc(IncomingTaskComponent, { props: { incomingTask: 'json', + isBrowser: 'boolean', // Outdial label text only + acceptControl: 'json', + declineControl: 'json', + isDeclineButtonEnabled: 'boolean', accept: 'function', reject: 'function', }, }); -const WebTaskList = r2wc(TaskListComponent, { - props: { - currentTask: 'json', - taskList: 'json', - acceptTask: 'function', - declineTask: 'function', - logger: 'function', - }, -}); ``` +`conferenceEnabled` exposed on `WebCallControl` and `WebCallControlCAD`. + --- -| File | Action | Impact | -|------|--------|--------| -| `cc-components/.../task/task.types.ts` | Replace `ControlVisibility` with `TaskUIControls`; update `ControlProps`, `CallControlComponentProps`; remove `isBrowser` / `isDeclineButtonEnabled` from `IncomingTaskComponentProps` and TaskList-related prop types when using per-task uiControls | **HIGH** | -| `cc-components/.../CallControl/call-control.tsx` | Update to use `controls` prop | **HIGH** | -| `cc-components/.../CallControl/call-control.utils.ts` | Update `buildCallControlButtons()` and `filterButtonsForConsultation()` | **HIGH** | -| `cc-components/.../CallControlCustom/call-control-custom.utils.ts` | Update `createConsultButtons()` and `getConsultStatusText()` | **HIGH** | -| `cc-components/.../CallControlCustom/call-control-consult.tsx` | Update consult control props | **MEDIUM** | -| `cc-components/.../CallControlCustom/consult-transfer-popover.tsx` | Update `isConferenceInProgress` prop | **LOW** | -| `cc-components/.../IncomingTask/incoming-task.tsx` | Minor prop updates (remove `isBrowser`, `isDeclineButtonEnabled` when using uiControls) | **LOW** | -| `cc-components/.../IncomingTask/incoming-task.utils.tsx` | Update `extractIncomingTaskData()`: remove `isBrowser` and `isDeclineButtonEnabled` params; derive accept/decline text and disable state from task or passed visibility (e.g. `task.uiControls` or caller-provided flags) | **MEDIUM** | -| `cc-components/.../TaskList/task-list.tsx` | Minor prop updates (remove `isBrowser`; pass task or controls for accept/decline) | **LOW** | -| `cc-components/.../TaskList/task-list.utils.ts` | Update `extractTaskListItemData()`: remove `isBrowser` param and `store.isDeclineButtonEnabled` usage; use `task.uiControls?.accept` / `task.uiControls?.decline` for button text and disable state | **MEDIUM** | -| `cc-components/.../CallControlCAD/call-control-cad.tsx` | Replace `ControlVisibility` / legacy control-shape usage with `TaskUIControls`; update props (`controlVisibility.isConferenceInProgress`, `isHeld`, `isConsultReceived`, `recordingIndicator`, `isConsultInitiatedOrAccepted`, etc.) | **MEDIUM** | -| `cc-components/src/wc.ts` | Update Web Component prop definitions: remove `isBrowser` from `WebIncomingTask` and `WebTaskList` r2wc props when migrating to per-task uiControls; align with React prop changes so WC consumers do not pass obsolete attributes | **LOW** | -| `task/src/CallControlCAD/index.tsx` | **Remove** `deviceType`, `featureFlags`, `conferenceEnabled` (SDK handles via `task.uiControls`); retain `agentId` for timer participant lookup | **MEDIUM** | -| All test files for above | Update mocks and assertions | **HIGH** | +| File | Status | Notes | +|------|--------|-------| +| `task.types.ts` | **Done** | `TaskUIControls` replaces `ControlVisibility` | +| `CallControl/call-control.tsx` | **Done** | Uses `controls: TaskUIControls` | +| `CallControl/call-control.utils.ts` | **Done** | `buildCallControlButtons` reads `controls.main` | +| `CallControlCustom/call-control-custom.utils.ts` | **Done** | `createConsultButtons` reads `controls.consult` | +| `IncomingTask/incoming-task.utils.tsx` | **Done** | `uiControls.main` + `isBrowser` + decline bridge | +| `TaskList/task-list.utils.ts` | **Done** | Same pattern as IncomingTask | +| `CallControlCAD/call-control-cad.tsx` | **Done** | Outdial `displayNumber` from `dnis` | +| `wc.ts` | **Done** | `isBrowser` retained for outdial labels; `conferenceEnabled` on CallControl | +| Component tests | **Done** | Mocks updated for `TaskUIControls` | --- ## Validation Criteria -- [ ] CallControl renders all 17 controls correctly -- [ ] Consult sub-controls (endConsult, merge, switch) render correctly -- [ ] Conference sub-controls (exit, transfer conference) render correctly -- [ ] State flag derivation works for conditional rendering -- [ ] IncomingTask accept/decline render correctly -- [ ] TaskList per-task controls render correctly -- [ ] CallControlCAD works with simplified props -- [ ] `buildCallControlButtons()` returns correct buttons for all states -- [ ] `createConsultButtons()` returns correct buttons for consult state -- [ ] No TypeScript compilation errors -- [ ] All component tests pass +| Criterion | Status | +|-----------|--------| +| CallControl uses `TaskUIControls` (main + consult legs) | **Done** | +| `buildCallControlButtons` / `createConsultButtons` | **Done** | +| IncomingTask / TaskList per-task main controls | **Done** | +| CallControlCAD outdial `dnis` header display | **Done** | +| `conferenceEnabled` app-level gating | **Done** | +| `isBrowser` for outdial labels (WC + React) | **Done** | +| Component tests updated | **Done** | + +--- + +_Parent: [migration-overview.md](./migration-overview.md)_ +_Updated: 2026-05-20_ --- -_Part of the task refactor migration doc set (overview in PR 1/4)._ +## Migration Fix Log + +### Fix: Duplicate Transfer Button — Wrong `uiControls` Field Mapping + +- **Issue**: After accepting a call, both "Transfer" and "Transfer Call" buttons appeared simultaneously. +- **Fix**: `transferConsult` button visibility gated on active consult (`controls.consult.endConsult` or `controls.main.endConsult`) and uses `controls.main.transfer` — not the main blind-transfer button alone. +- **Result**: Main "Transfer" shows in `CONNECTED`; consult-strip transfer only during active consultation. + +### Fix: Hold Button Icon/Tooltip Not Toggling & Multi-Login Hold State Not Syncing + +- **Issue**: (1) After clicking Hold, the button icon stayed as pause and tooltip stayed as "Hold the call" instead of changing to play/"Resume the call". (2) In multi-login scenarios, holding/resuming on one system did not reflect on the other system. +- **Root Cause**: + - The old `controlVisibility.isHeld` was removed during migration. The replacement `controls.hold.isEnabled` is an **action flag** (can the user click hold?), not the current hold state. `task.data.isOnHold` exists in SDK types but is not populated at runtime. + - For multi-login: The SDK's `TaskStateMachine.ts` `CONNECTED` state had no handler for `HOLD_SUCCESS` (another system held), and `HELD` state had no handler for `UNHOLD_SUCCESS` (another system resumed). These events were silently dropped. +- **Fix (Widgets)**: + - `helper.ts` (`useCallControl` hook): Added `useState(isHeld)` initialized from `isInteractionOnHold(currentTask)`. Updated `holdCallback` to `setIsHeld(true)` and `resumeCallback` to `setIsHeld(false)`. Added `useEffect([currentTask])` to re-sync from `isInteractionOnHold` on task reference changes (covers multi-login `refreshTaskList`). + - `call-control.utils.ts`: Added `isHeld: boolean` parameter to `buildCallControlButtons()`. Hold button uses `isHeld ? 'play-bold' : 'pause-bold'` for icon and `isHeld ? RESUME_CALL : HOLD_CALL` for tooltip. + - `call-control.tsx`: Destructured `isHeld` from props, passed to `buildCallControlButtons()` and `handleToggleHoldUtil()`. + - `task.types.ts`: Added `'isHeld'` to `CallControlComponentProps` pick list. +- **Fix (SDK)**: Added `HOLD_SUCCESS` transition in `CONNECTED` state and `UNHOLD_SUCCESS` transition in `HELD` state of `TaskStateMachine.ts`, both with actions `['updateTaskData', 'setHoldState', 'emitTaskHold'/'emitTaskResume']`. +- **Result**: Hold button icon/tooltip toggles correctly on click. Multi-login hold/resume state syncs across systems via SDK state machine transitions. + +### Fix: Restore `conferenceEnabled` Prop — Application-Level Conference Gating + +- **Issue**: The `conferenceEnabled` prop was removed from widget APIs during migration. This is an application-level configuration (not a feature flag) passed from the consumer app that controls whether conference-related UI controls are available to the agent. +- **Root Cause**: The migration assumed all UI visibility is exclusively SDK-driven. However, `conferenceEnabled` is a consumer-level override independent of SDK state. +- **Design Decision**: Option A — widget-side override applied directly at the button builder level. When `conferenceEnabled` is `false`, the `isVisible` property of conference-related buttons (`conference`, `exitConference`, `merge`) is forced to `false` in `buildCallControlButtons()` and `createConsultButtons()`. Defaults to `true`. +- **Component-Layer Changes**: + - `task.types.ts`: Added `conferenceEnabled: boolean` to `ControlProps`, `CallControlComponentProps`, `CallControlConsultComponentsProps` + - `call-control.utils.ts`: Added `conferenceEnabled` param to `buildCallControlButtons()`, gated `conference` and `exitConference` buttons via `conferenceEnabled && (controls?.…isVisible)` + - `call-control-custom.utils.ts`: Added `conferenceEnabled` param to `createConsultButtons()`, gated `conference` (merge) button + - `call-control.tsx`: Destructured `conferenceEnabled` from props, passed to `buildCallControlButtons()` + - `call-control-consult.tsx`: Destructured `conferenceEnabled`, passed to `createConsultButtons()` + - `call-control-cad.tsx`: Destructured `conferenceEnabled`, passed to `CallControlConsultComponent` + - `cc-widgets/src/wc.ts`: Exposed `conferenceEnabled` as r2wc `boolean` prop on `WebCallControl` and `WebCallControlCAD` +- **No SDK changes required**: Gating is applied at the widget component layer directly on button definitions. +- **Result**: Conference merge and exit buttons are hidden when `conferenceEnabled={false}`. All other SDK-driven controls remain unaffected. diff --git a/packages/contact-center/ai-docs/migration/incoming-task-migration.md b/packages/contact-center/ai-docs/migration/incoming-task-migration.md index 00ac01662..b2d1fdbcf 100644 --- a/packages/contact-center/ai-docs/migration/incoming-task-migration.md +++ b/packages/contact-center/ai-docs/migration/incoming-task-migration.md @@ -2,247 +2,126 @@ ## Summary -The IncomingTask widget handles task offer/accept/reject flows. The state machine changes are minimal here since accept/decline SDK methods are unchanged. The main change is that the OFFERED → CONNECTED/TERMINATED transitions are now explicit state machine states, and `task.uiControls.accept`/`decline` can drive button visibility instead of widget-side logic. +**Status: Done.** IncomingTask accept/decline visibility and enablement come from `task.uiControls.main.accept` and `task.uiControls.main.decline`. Widget-side `getAcceptButtonVisibility` / `getDeclineButtonVisibility` are removed. ---- +Host app shows the incoming popup via `store.setIncomingTaskCb`; widgets dismiss it via `onAccepted` / `onRejected` callbacks. -## Old Approach +--- -### Entry Point -**File:** `packages/contact-center/task/src/helper.ts` -**Hook:** `useIncomingTask(props: UseTaskProps)` +## Current Implementation -### How It Works (Old) -1. Store sets `incomingTask` observable on `TASK_INCOMING` event -2. Widget (observer) re-renders when `incomingTask` changes -3. Hook registers per-task callbacks: `TASK_ASSIGNED`, `TASK_CONSULT_ACCEPTED`, `TASK_END`, `TASK_REJECT`, `TASK_CONSULT_END` -4. Accept → `task.accept()` → SDK → `TASK_ASSIGNED` → `onAccepted` callback -5. Reject → `task.decline()` → SDK → `TASK_REJECT` → `onRejected` callback -6. Timer expiry (RONA) → `reject()` → `task.decline()` -7. Accept/Decline button visibility computed by `getAcceptButtonVisibility()` / `getDeclineButtonVisibility()` in task-util.ts +### Entry points ---- +| Layer | File | +|-------|------| +| Hook | `task/src/helper.ts` — `useIncomingTask` | +| Widget wrapper | `task/src/IncomingTask/index.tsx` | +| Component | `cc-components/.../IncomingTask/incoming-task.tsx` | +| Utils | `cc-components/.../IncomingTask/incoming-task.utils.tsx` | -## New Approach +### Control source -### What Changes -1. **Accept/Decline visibility** → now available via `task.uiControls.accept` / `task.uiControls.decline` -2. **State machine states**: IDLE → OFFERED → (CONNECTED on accept | TERMINATED on reject/RONA) -3. **SDK methods unchanged**: `task.accept()`, `task.decline()` still work the same -4. **Events unchanged**: `TASK_ASSIGNED`, `TASK_REJECT` still emitted +```typescript +// helper.ts — useIncomingTask +const acceptControl = incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; +const sdkDeclineControl = incomingTask?.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; +const declineControl = { + ...sdkDeclineControl, + isEnabled: sdkDeclineControl.isEnabled || store.isDeclineButtonEnabled, // Legacy bridge +}; +``` -### Minimal Changes Required -- Replace `getAcceptButtonVisibility()` / `getDeclineButtonVisibility()` with `task.uiControls.accept` / `task.uiControls.decline` -- Optionally subscribe to `task:ui-controls-updated` for reactive updates -- Fix callback registration to use **named callbacks** so `removeTaskCallback` (which calls `task.off`) gets the same function reference (see After example below) +### Per-task event callbacks (dismiss popup) ---- +Registered via `store.setTaskCallback` with **named** callbacks (required for correct `.off()` cleanup): -## Old → New Mapping +| Event | Callback | Effect | +|-------|----------|--------| +| `TASK_ASSIGNED` | `taskAssignCallback` | `onAccepted` — dismiss popup | +| `TASK_CONSULT_ACCEPTED` | `taskAssignCallback` | Same | +| `TASK_END` | `taskRejectCallback` | `onRejected` — dismiss popup | +| `TASK_REJECT` | `taskRejectCallback` | Same | +| `TASK_CONSULT_END` | `taskRejectCallback` | Same | +| `TASK_OUTDIAL_FAILED` | `taskRejectCallback` | Dismiss popup on outdial failure | -| Aspect | Old | New | -|--------|-----|-----| -| Accept visible | `getAcceptButtonVisibility(isBrowser, isPhone, webRtc, isCall, isDigital)` | `task.uiControls.accept.isVisible` | -| Decline visible | `getDeclineButtonVisibility(isBrowser, webRtc, isCall)` | `task.uiControls.decline.isVisible` | -| Accept action | `task.accept()` | `task.accept()` (unchanged) | -| Decline action | `task.decline()` | `task.decline()` (unchanged) | -| Task assigned event | `TASK_EVENTS.TASK_ASSIGNED` | `TASK_EVENTS.TASK_ASSIGNED` (unchanged) | -| Task rejected event | `TASK_EVENTS.TASK_REJECT` | `TASK_EVENTS.TASK_REJECT` (unchanged) | -| Timer/RONA | Widget-managed timer | Widget-managed timer (unchanged) | +Actions unchanged: `incomingTask.accept()` → SDK → `TASK_ASSIGNED`; `incomingTask.decline()` → `TASK_REJECT`. ---- +### Outdial-specific UI (widgets layer) -## Refactor Pattern +SDK sets accept disabled and decline `VISIBLE_DISABLED` for WebRTC outdial. Widgets add label text rules in `incoming-task.utils.tsx`: -### Before -```typescript -// In IncomingTask component or hook -const { isBrowser, isPhoneDevice } = getDeviceTypeFlags(store.deviceType); -const acceptVisibility = getAcceptButtonVisibility( - isBrowser, isPhoneDevice, webRtcEnabled, isCall, isDigitalChannel -); -const declineVisibility = getDeclineButtonVisibility(isBrowser, webRtcEnabled, isCall); -``` +| Mode | Accept label | Condition | +|------|--------------|-----------| +| Desktop outdial | "Accept" (disabled) | `isBrowser && isOutdial && !accept.isEnabled` | +| Extension outdial | "Ringing..." | `!isBrowser && isOutdial && !accept.isEnabled` | +| Extension inbound | "Ringing..." | `!isBrowser && !accept.isEnabled` | +| Desktop inbound | "Accept" | `accept.isVisible` | -### After -```typescript -// In IncomingTask component or hook -const task = store.incomingTask; -const acceptVisibility = task?.uiControls?.accept ?? { isVisible: false, isEnabled: false }; -const declineVisibility = task?.uiControls?.decline ?? { isVisible: false, isEnabled: false }; -``` +`isBrowser` comes from `store.deviceType === 'BROWSER'` in `IncomingTask/index.tsx` — used for **label text only**, not visibility gating. ---- +### Phone number display (outdial) -## Full Before/After: `useIncomingTask` Hook +In `extractIncomingTaskData`, for outdial tasks ANI shown uses `dnis` (destination) over `ani`: -### Before (current code in `helper.ts`) ```typescript -export const useIncomingTask = (props: UseTaskProps) => { - const {onAccepted, onRejected, deviceType, incomingTask, logger} = props; - const isBrowser = deviceType === 'BROWSER'; - const isDeclineButtonEnabled = store.isDeclineButtonEnabled; - - // Event callbacks registered per-task for accept/reject lifecycle - useEffect(() => { - if (!incomingTask) return; - store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, () => { - if (onAccepted) onAccepted({task: incomingTask}); - }, incomingTask.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - - return () => { - store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - }; - }, [incomingTask]); - - const accept = () => { - if (!incomingTask?.data.interactionId) return; - incomingTask.accept().catch((error) => { /* log */ }); - }; - - const reject = () => { - if (!incomingTask?.data.interactionId) return; - incomingTask.decline().catch((error) => { /* log */ }); - }; - - return { - incomingTask, - accept, - reject, - isBrowser, // Used to determine accept/decline button visibility - isDeclineButtonEnabled, // Feature flag for decline button - }; -}; +const isOutdial = incomingTask?.data?.interaction?.outboundType === 'OUTDIAL'; +const dnis = callAssociatedDetails?.dnis || callProcessingDetails?.dnis; +const ani = isOutdial ? dnis || callAssociatedDetails?.ani : callAssociatedDetails?.ani; ``` -**Note:** The `isBrowser` and `isDeclineButtonEnabled` flags are passed to the component, which uses them to decide whether to show accept/decline buttons. This duplicates what `task.uiControls.accept/decline` now provides. +CallControlCAD uses the same pattern for header title (see [component-layer-migration.md](./component-layer-migration.md)). -### After (migrated) -```typescript -export const useIncomingTask = (props: UseTaskProps) => { - const {onAccepted, onRejected, incomingTask, logger} = props; - - // NEW: Read accept/decline visibility from SDK - const acceptControl = incomingTask?.uiControls?.accept ?? {isVisible: false, isEnabled: false}; - const declineControl = incomingTask?.uiControls?.decline ?? {isVisible: false, isEnabled: false}; - - // Event callbacks — use NAMED callbacks so removeTaskCallback(task.off) gets the same reference - const taskAssignCallback = useCallback(() => { - if (onAccepted) onAccepted({task: incomingTask}); - }, [onAccepted, incomingTask]); - - const taskRejectCallback = useCallback(() => { - if (onRejected) onRejected({task: incomingTask}); - }, [onRejected, incomingTask]); - - useEffect(() => { - if (!incomingTask) return; - store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - - return () => { - store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - }; - }, [incomingTask, taskAssignCallback, taskRejectCallback]); - - // Actions — UNCHANGED - const accept = () => { - if (!incomingTask?.data.interactionId) return; - incomingTask.accept().catch((error) => { /* log */ }); - }; - - const reject = () => { - if (!incomingTask?.data.interactionId) return; - incomingTask.decline().catch((error) => { /* log */ }); - }; - - return { - incomingTask, - accept, - reject, - acceptControl, // NEW: { isVisible, isEnabled } from SDK - declineControl, // NEW: { isVisible, isEnabled } from SDK - // REMOVED: isBrowser, isDeclineButtonEnabled (no longer needed) - }; -}; -``` - -### Component-Level Before/After - -#### Before (IncomingTaskComponent) -```tsx -// incoming-task.tsx — old approach -const IncomingTaskComponent = ({ isBrowser, isDeclineButtonEnabled, onAccept, onReject, ... }) => { - // Widget computes visibility from device type and feature flags - const showAccept = isBrowser; // simplified — actual logic in getAcceptButtonVisibility() - const showDecline = isBrowser && isDeclineButtonEnabled; - - return ( -
- {showAccept && } - {showDecline && } -
- ); -}; -``` +### Host popup vs widget dismiss -#### After (IncomingTaskComponent) -```tsx -// incoming-task.tsx — new approach -// acceptControl/declineControl come from task.uiControls.accept / task.uiControls.decline -const IncomingTaskComponent = ({ acceptControl, declineControl, onAccept, onReject, ... }) => { - return ( -
- {acceptControl.isVisible && ( - - )} - {declineControl.isVisible && ( - - )} -
- ); -}; -``` +| Concern | Owner | +|---------|-------| +| Show incoming popup + sound | Host — `setIncomingTaskCb` | +| Outdial failure modal | Host — `setOutdialFailed` | +| Task rejected popup | Host — `setTaskRejected` | +| Dismiss incoming notification | Widget — `onAccepted` / `onRejected` props | --- -## Files to Modify +## Old → New Mapping -| File | Action | -|------|--------| -| `task/src/helper.ts` (`useIncomingTask`) | Use `task.uiControls.accept/decline` instead of visibility functions | -| `task/src/IncomingTask/index.tsx` | Minor: pass new control shape to component | -| `cc-components/.../IncomingTask/incoming-task.tsx` | Update accept/decline prop names if needed | -| `task/tests/IncomingTask/index.tsx` | Update tests | +| Aspect | Old | New (current) | +|--------|-----|---------------| +| Accept visible | `getAcceptButtonVisibility(...)` | `task.uiControls.main.accept.isVisible` | +| Decline visible | `getDeclineButtonVisibility(...)` | `task.uiControls.main.decline.isVisible` | +| Decline enabled | `store.isDeclineButtonEnabled` only | SDK `decline.isEnabled` **OR** `store.isDeclineButtonEnabled` | +| Accept action | `task.accept()` | Unchanged | +| Decline action | `task.decline()` | Unchanged | +| Device type for buttons | `isBrowser` gates visibility | `isBrowser` for outdial **label text** only | --- ## Validation Criteria -- [ ] Accept button visible for WebRTC voice tasks -- [ ] Accept button visible for digital channel tasks (chat/email) -- [ ] Decline button visible for WebRTC voice tasks only -- [ ] Accept action calls `task.accept()` and triggers `TASK_ASSIGNED` -- [ ] Decline action calls `task.decline()` and triggers `TASK_REJECT` -- [ ] RONA timer triggers reject correctly -- [ ] Consult incoming (OFFER_CONSULT) shows accept/decline correctly -- [ ] Cleanup on unmount removes callbacks +| Criterion | Status | +|-----------|--------| +| Accept/decline from `uiControls.main` | **Done** | +| Named callbacks for cleanup | **Done** | +| `TASK_OUTDIAL_FAILED` dismisses popup | **Done** | +| Outdial accept/decline labels (Desktop vs Extension) | **Done** | +| Outdial ANI uses `dnis` | **Done** | +| Legacy `isDeclineButtonEnabled` bridge | **Done** (pending full SDK-only decline enablement) | +| RONA timer → `task.decline()` | **Done** | + +--- + +## Migration Fix Log + +### Fix: Restore `isDeclineButtonEnabled` bridge + +Store `handleAutoAnswer` still sets `isDeclineButtonEnabled`. Hook and utils OR this with `uiControls.main.decline.isEnabled` so decline enables after auto-answer when SDK timing lags. + +### Fix: Outdial accept label and phone number + +- Desktop outdial: show "Accept" disabled (not "Ringing...") +- Extension outdial: show "Ringing..." +- Display customer number via `dnis` for outdial in incoming task header --- _Parent: [migration-overview.md](./migration-overview.md)_ +_Updated: 2026-05-20_ diff --git a/packages/contact-center/ai-docs/migration/migration-overview.md b/packages/contact-center/ai-docs/migration/migration-overview.md index 5b6097f4f..833cfacf4 100644 --- a/packages/contact-center/ai-docs/migration/migration-overview.md +++ b/packages/contact-center/ai-docs/migration/migration-overview.md @@ -2,7 +2,78 @@ ## Purpose -Guide for migrating CC Widgets from ad-hoc task state management to the new SDK state-machine-driven architecture (`task-refactor` branch). This is the single entry point — it tells you what changed, which docs to follow in what order, and what to watch out for. +Architecture reference for CC Widgets on the SDK state-machine-driven task model (`task-refactor` branch). This is the single entry point — it describes what changed, how SDK → store → widgets consumption works today, and links to per-area docs. + +--- + +## Current State (Migration Complete) + +The widget migration to `task.uiControls` is **largely complete**. Widgets no longer call `getControlsVisibility()`. Control visibility and enablement come from the SDK; the store mirrors task inventory and fires host callbacks; hooks and components read `task.uiControls` and invoke task methods. + +| Area | Status | Primary file | +|------|--------|--------------| +| Store event wiring | **Done** | `store/src/storeEventsWrapper.ts` | +| Store task utils (dead code removal) | **Done** | `store/src/task-utils.ts` | +| CallControl hook | **Done** | `task/src/helper.ts` (`useCallControl`) | +| IncomingTask / TaskList | **Done** | `task/src/helper.ts`, `cc-components/.../IncomingTask`, `TaskList` | +| Component layer | **Done** | `cc-components/src/components/task/` | +| Legacy bridge: `isDeclineButtonEnabled` | **Open** | Still OR'd into decline enablement after auto-answer | +| Outdial double-popup (SDK `emitTaskEnd` vs `emitTaskReject`) | **Planned** | Widgets-side dedup if SDK reverts to `emitTaskReject` | + +--- + +## SDK → Widgets Consumption Model + +```mermaid +flowchart TD + subgraph sdk [SDK Task State Machine] + SM[TaskStateMachine] + UIC[uiControlsComputer] + SM --> UIC + UIC --> TaskObj["ITask.uiControls\n(main/consult/activeLeg)"] + SM --> Events["TASK_* events"] + end + + subgraph store [StoreWrapper] + Reg[registerTaskEventListeners] + Refresh[refreshTaskList] + HostCb["Host callbacks\n(onIncomingTask, onOutdialFailed, onTaskRejected)"] + Reg --> Refresh + Events --> Reg + end + + subgraph widgets [Widgets] + Hooks["helper.ts hooks\nuseCallControl, useIncomingTask, useTaskList"] + Components["cc-components\nCallControl, IncomingTask, TaskList, CAD"] + Hooks --> Components + end + + TaskObj --> Reg + Refresh --> Hooks + HostCb --> HostApp["Sample app popups/modals"] + Hooks -->|"task.accept/decline/hold/end"| TaskObj +``` + +### Three layers + +1. **SDK** — Owns task state machine, `task.data`, and `task.uiControls`. Recomputes controls after every transition and emits `TASK_UI_CONTROLS_UPDATED`. +2. **Store** (`storeEventsWrapper.ts`) — Registers per-task listeners, calls `refreshTaskList()` to sync MobX `taskList` / `currentTask`, and invokes optional host callbacks. Does **not** compute control visibility. +3. **Widgets** — Read `task.uiControls.main.*` / `task.uiControls.consult.*` and call `task.accept()`, `task.hold()`, etc. **Popups and toasts are owned by the host app**, not the widget package. + +--- + +## Popup / Notification Model + +Widgets do not render failure or rejection popups. The host app wires store callbacks (see `widgets-samples/cc/samples-cc-react-app/src/App.tsx`): + +| Store callback | SDK event (via handler) | Host UI | +|----------------|-------------------------|---------| +| `setIncomingTaskCb` | `TASK_INCOMING` | Incoming task popup + notification sound | +| `setTaskRejected` | `TASK_REJECT` → `handleTaskReject` | "Task Rejected" popup | +| `setOutdialFailed` | `TASK_OUTDIAL_FAILED` → `handleOutdialFailed` | "Outdial Failed" modal | +| Widget `onRejected` / `onAccepted` props | Per-task callbacks on `TASK_END`, `TASK_REJECT`, `TASK_OUTDIAL_FAILED`, etc. | Dismiss incoming notification only | + +**Outdial failure:** SDK emits `TASK_OUTDIAL_FAILED` (reason string) and `TASK_END` (via `emitTaskEnd`, not `emitTaskReject`) to avoid a duplicate "Task Rejected" popup. IncomingTask dismisses on `TASK_OUTDIAL_FAILED` via `taskRejectCallback`. --- @@ -10,121 +81,103 @@ Guide for migrating CC Widgets from ad-hoc task state management to the new SDK ``` ┌─────────────────────────────────────────────────────────────────────────────────┐ -│ OLD (Current Widgets) │ NEW (After Migration) │ -│ │ │ -│ SDK emits 27 task events │ SDK state machine transitions │ -│ │ │ │ │ -│ ▼ │ ▼ │ -│ Store: refreshTaskList() │ SDK: computes TaskUIControls │ -│ + update observables manually │ from (TaskState + TaskContext) │ -│ │ │ │ │ -│ ▼ │ ▼ │ -│ Hooks: getControlsVisibility( │ SDK emits │ -│ deviceType, featureFlags, │ 'task:ui-controls-updated' │ -│ task, agentId, conferenceEnabled) │ │ │ -│ │ │ ▼ │ -│ ▼ │ Widgets read task.uiControls │ -│ Components: flat ControlVisibility │ │ │ -│ (22 controls + 7 state flags) │ ▼ │ -│ │ Components: TaskUIControls │ -│ Logic spread across: │ (17 controls, each │ -│ task-util.ts, task-utils.ts, │ { isVisible, isEnabled }) │ -│ timer-utils.ts, component utils │ │ -│ │ Single source of truth: │ -│ │ task.uiControls │ +│ OLD (Pre-refactor) │ NEW (Current) │ +│ │ │ +│ SDK emits task events │ SDK state machine transitions │ +│ │ │ │ │ +│ ▼ │ ▼ │ +│ Store: refreshTaskList() │ SDK: computes TaskUIControls │ +│ + manual observables │ from (TaskState + TaskContext) │ +│ │ │ │ │ +│ ▼ │ ▼ │ +│ Hooks: getControlsVisibility() │ SDK emits TASK_UI_CONTROLS_UPDATED │ +│ (deviceType, featureFlags, task) │ │ │ +│ │ │ ▼ │ +│ ▼ │ Widgets read task.uiControls │ +│ Components: flat ControlVisibility │ .main / .consult / .activeLeg │ +│ (22 controls + 7 state flags) │ │ │ +│ │ ▼ │ +│ Logic in task-util.ts, task-utils.ts │ Components: TaskUIControls per leg │ +│ │ Single source of truth: task.uiControls │ └─────────────────────────────────────────────────────────────────────────────────┘ ``` -> The events themselves have not changed — they are the same events, now emitted via the SDK state machine. The key difference is that task state updates (including UI control computation) are handled by the SDK, not by widgets. - -### What Gets Removed (Dead Code) +> Task event names are largely unchanged; they are now emitted from the SDK state machine. UI control computation moved from widgets to SDK. -The following widget-side logic is entirely replaced by `task.uiControls` and `task.data`: +### Removed (Dead Code) -- **`getControlsVisibility()`** (task-util.ts) + all 22 `get*ButtonVisibility` helper functions — replaced by `task.uiControls` -- **`getConsultStatus()`, `getTaskStatus()`, `getConsultMPCState()`** (store/task-utils.ts) — dead code. These are only called inside `getControlsVisibility()`. Once it is removed, the entire chain is unused and should be deleted. If consult status is needed for display, use `task.data.consultStatus` (SDK provides directly). -- **`findHoldStatus()`** (store/task-utils.ts) — removed. The SDK state machine tracks hold state internally; widgets get hold state from the task object. Do NOT derive from `controls.hold.isEnabled` (that is an action flag — disabled during conference/consulting even when call is held). +- **`getControlsVisibility()`** and 22 `get*ButtonVisibility` helpers — **removed** from `task-util.ts` +- **`getConsultStatus()`, `getTaskStatus()`, `getConsultMPCState()`, `findHoldStatus()`** — **removed** from store `task-utils.ts` +- Local flat `ControlVisibility` interface — **replaced** by SDK `TaskUIControls` -### Task Object as Source of Truth for State Flags - -After migration, state flags come from the task object (`ITask`), not from widget-side helper functions: +### Task Object as Source of Truth | State | Source | Do NOT use | |-------|--------|------------| -| Control visibility/enablement | `task.uiControls` (17 controls, each `{ isVisible, isEnabled }`) | `getControlsVisibility()`, `deviceType`, `featureFlags` | -| Hold state (`isHeld`) | Task object (SDK tracks internally) | `findHoldStatus()`, `controls.hold.isEnabled` | -| Conference in progress | `task.data.isConferenceInProgress` | `controls.exitConference.isVisible` (can be false during consult even if conference is active) | -| Consult status (display) | `task.data.consultStatus` (e.g. `consultInitiated`, `consultAccepted`) | `getConsultStatus()`, `getTaskStatus()` | +| Control visibility/enablement | `task.uiControls.main.*` / `task.uiControls.consult.*` | `getControlsVisibility()`, device-type gating for buttons | +| Active consult leg | `task.uiControls.activeLeg` (`'main'` \| `'consult'`) | Guessing from button visibility alone | +| Hold state (`isHeld`) | `isInteractionOnHold(task)` + CallControl hook logic (activeLeg, conference flags) | `controls.main.hold.isEnabled` as hold indicator | +| Conference in progress | `task.data` / interaction `state === 'conference'` | `controls.main.exitConference.isVisible` alone | +| Consult status (display) | `task.data.consultStatus` | Removed `getConsultStatus()` chain | + +### Known Legacy Bridges + +- **`store.isDeclineButtonEnabled`** — Set on `TASK_AUTO_ANSWERED`. Still OR'd into decline enablement in `useIncomingTask` and IncomingTask/TaskList utils until SDK `uiControls.main.decline.isEnabled` fully covers post-offer timing. +- **`isBrowser` prop** — Retained for **outdial accept label text** ("Accept" vs "Ringing..."), not for control visibility gating. --- -## CC Widgets Files Affected +## CC Widgets Files | Area | Path | |------|------| | Store event wrapper | `packages/contact-center/store/src/storeEventsWrapper.ts` | | Store task utils | `packages/contact-center/store/src/task-utils.ts` | -| Store constants | `packages/contact-center/store/src/constants.ts` | -| Store types | `packages/contact-center/store/src/store.types.ts` | +| Store types (SDK re-exports) | `packages/contact-center/store/src/store.types.ts` | | Task hooks | `packages/contact-center/task/src/helper.ts` | -| Task UI utils (to be removed) | `packages/contact-center/task/src/Utils/task-util.ts` | -| Task types | `packages/contact-center/task/src/task.types.ts` | -| CC Components — CallControl | `packages/contact-center/cc-components/src/components/task/CallControl/` | -| CC Components — CallControlCAD | `packages/contact-center/cc-components/src/components/task/CallControlCAD/` | -| CC Components types | `packages/contact-center/cc-components/src/components/task/task.types.ts` | -| CC Components — WC wrapper | `packages/contact-center/cc-components/src/wc.ts` | - -> **Not listed:** `timer-utils.ts` and `useHoldTimer.ts` are not directly affected by the task-refactor SDK changes. Timer signature updates (if any) are tracked separately in the hook migration doc. +| Task UI utils (timers only) | `packages/contact-center/task/src/Utils/task-util.ts` | +| CC Components | `packages/contact-center/cc-components/src/components/task/` | +| Sample app (host callbacks) | `widgets-samples/cc/samples-cc-react-app/src/App.tsx` | --- -## Execution Order - -Follow these docs in order. Each doc has old vs new code, before/after examples, and files to modify. +## Documentation Index -| Order | Document | What to Do | -|-------|----------|------------| -| 1 | [store-event-wiring-migration.md](./store-event-wiring-migration.md) | Update 27 event handlers — switch to SDK `TASK_EVENTS` enum, keep `refreshTaskList()`, add `TASK_UI_CONTROLS_UPDATED` subscription, fix `handleConsultEnd` wiring, replace `isDeclineButtonEnabled` with `task.uiControls.decline.isEnabled` | -| 2 | [store-task-utils-migration.md](./store-task-utils-migration.md) | Remove dead code (`getControlsVisibility` chain, `findHoldStatus`), delete associated constants; keep `findHoldTimestamp` (timers) and `isIncomingTask` | -| 3 | [call-control-hook-migration.md](./call-control-hook-migration.md) | Replace `getControlsVisibility()` with `task.uiControls` in `useCallControl` + update timer utils | -| 4 | [incoming-task-migration.md](./incoming-task-migration.md) | Use `task.uiControls.accept/decline` instead of visibility functions | -| 5 | [task-list-migration.md](./task-list-migration.md) | Per-task `uiControls` for accept/decline | -| 6 | [component-layer-migration.md](./component-layer-migration.md) | Update `cc-components` props — `ControlVisibility` → `TaskUIControls`, rename control props | +| Document | Focus | Status | +|----------|-------|--------| +| [store-event-wiring-migration.md](./store-event-wiring-migration.md) | Store event handlers, host callbacks, outdial flow | Reference | +| [store-task-utils-migration.md](./store-task-utils-migration.md) | Remaining store utils vs removed dead code | Reference | +| [call-control-hook-migration.md](./call-control-hook-migration.md) | `useCallControl`, dual uiControls refresh, hold logic | Reference | +| [incoming-task-migration.md](./incoming-task-migration.md) | Accept/decline, outdial text, dismiss callbacks | Reference | +| [task-list-migration.md](./task-list-migration.md) | Per-task accept/decline in list rows | Reference | +| [component-layer-migration.md](./component-layer-migration.md) | `TaskUIControls` per-leg props, CAD outdial display | Reference | --- -## SDK Pending Exports (Prerequisites) +## SDK Consumption (via `@webex/cc-store`) -**What the SDK does not export today** (from the package entry point `src/index.ts`): the items in the table below. They exist in SDK source but are not re-exported from the public package, so widget code cannot import them until they are added to the package. +Widgets import SDK task types through the store package, which re-exports from `@webex/contact-center`: -**Before implementing:** Check whether each required export is available from the SDK — i.e. whether you can import it from the package. If an item is not yet exported, delay the work that depends on it or implement only the parts that do not need it. Full completion of the migration requires these exports. +| Export | Purpose | +|--------|---------| +| `TASK_EVENTS` | Event enum (local store enum removed) | +| `TaskUIControls`, `InteractionUIControls`, `TaskUILeg` | Per-leg control shape | +| `TaskUIControlState` | `{ isVisible, isEnabled }` | +| `getDefaultUIControls()` | Fallback when no task | +| `ITask.uiControls` | Getter on task instances | -| Item | SDK Change Needed | -|------|---| -| `TaskUIControls` type | Add to `src/index.ts` | -| `getDefaultUIControls()` | Add to `src/index.ts` | -| `TaskState` enum | Add to `src/index.ts` (needed for consult timer labeling) | -| `uiControls` on `ITask` | Add getter to `ITask` interface (currently only on concrete `Task` class) | -| `IVoice`, `IDigital`, `IWebRTC` | Add to `src/index.ts` (optional — for type narrowing) | +Import pattern: `import { TaskUIControls, TASK_EVENTS, getDefaultUIControls } from '@webex/cc-store';` --- ## Key Types from SDK -| Type | Purpose | -|------|---------| -| `TaskUIControls` | Pre-computed control states (17 controls, each `{ isVisible, isEnabled }`) | -| `TaskUIControlState` | Shape: `{ isVisible: boolean; isEnabled: boolean }` | -| `getDefaultUIControls()` | Fallback when no task: `task?.uiControls ?? getDefaultUIControls()` | -| `TASK_EVENTS` | Import from SDK — delete local enum in `store.types.ts` | -| `TaskState` | SDK state machine states — needed for consult timer labeling | - -### `TaskUIControls` Structure +### `TaskUIControls` Structure (per-leg) ```typescript type TaskUIControlState = { isVisible: boolean; isEnabled: boolean }; -type TaskUIControls = { +type InteractionUIControls = { accept: TaskUIControlState; decline: TaskUIControlState; hold: TaskUIControlState; @@ -133,21 +186,33 @@ type TaskUIControls = { end: TaskUIControlState; recording: TaskUIControlState; mute: TaskUIControlState; - consultTransfer: TaskUIControlState; endConsult: TaskUIControlState; conference: TaskUIControlState; exitConference: TaskUIControlState; transferConference: TaskUIControlState; mergeToConference: TaskUIControlState; wrapup: TaskUIControlState; - switchToMainCall: TaskUIControlState; - switchToConsult: TaskUIControlState; + switch: TaskUIControlState; // switch between main and consult leg +}; + +type TaskUILeg = 'main' | 'consult'; + +type TaskUIControls = { + main: InteractionUIControls; + consult: InteractionUIControls; + activeLeg: TaskUILeg; }; ``` -Widgets no longer compute control visibility — `task.uiControls` is the single source of truth. +**Usage:** Read offer/accept controls from `task.uiControls.main`. Consult panel uses `task.uiControls.consult`. Use `activeLeg` for hold/switch UI during consult. -> Specific constants to delete/keep, event name mappings, and ordering constraints (e.g. "do not delete constant X until helper Y is rewritten") are documented in each migration doc listed in the [Execution Order](#execution-order) table. +Example: + +```typescript +const accept = task.uiControls.main.accept; +const consultEnd = task.uiControls.consult.endConsult; +const controls = currentTask?.uiControls ?? getDefaultUIControls(); +``` --- @@ -155,7 +220,7 @@ Widgets no longer compute control visibility — `task.uiControls` is the single | Old | New | Notes | |-----|-----|-------| -| `task.consultTransfer()` | `task.transfer()` | `consultTransfer` is no longer a separate public method; a single `.transfer()` is used for all transfer types | +| `task.consultTransfer()` | `task.transfer()` | Single `.transfer()` for consult transfer | --- @@ -163,15 +228,46 @@ Widgets no longer compute control visibility — `task.uiControls` is the single > **Repo:** [webex/webex-js-sdk (task-refactor)](https://github.com/webex/webex-js-sdk/tree/task-refactor) - - | File | Purpose | |------|---------| -| `uiControlsComputer.ts` | Computes `TaskUIControls` from `TaskState` + `TaskContext` — the single source of truth | -| `Task.ts` | Task service exposing `task.uiControls` getter and `task:ui-controls-updated` event | -| `constants.ts` | `TaskState` and `TaskEvent` enums | +| `state-machine/uiControlsComputer.ts` | Computes `TaskUIControls` from `TaskState` + `TaskContext` | +| `state-machine/TaskStateMachine.ts` | State transitions (including OUTBOUND_FAILED, wrapup guards) | +| `TaskManager.ts` | Maps CC backend events to state machine events | +| `voice/Voice.ts` | Voice task methods; `emitTaskOutdialFailed` emits reason string | +| `Task.ts` | `task.uiControls` getter; emits `TASK_UI_CONTROLS_UPDATED` | + +--- + +## Migration Fix Log + +### 2026-05 — Outdial Flow ([SDK PR #4987](https://github.com/webex/webex-js-sdk/pull/4987)) + +**Issues:** React crash on outdial failure, missing wrapup after failure, double popup (Outdial Failed + Task Rejected), incorrect accept/decline for outdial, race when `AGENT_OUTBOUND_FAILED` arrives in IDLE. + +**SDK fixes:** +- `TaskManager.ts`: Pass `taskData: payload` on `OUTBOUND_FAILED` for `shouldWrapUp` guard +- `Voice.ts`: `emitTaskOutdialFailed` emits failure `reason` string (not Task object) +- `TaskStateMachine.ts`: `OUTBOUND_FAILED` handler in IDLE; OFFERED uses `shouldWrapUp` → WRAPPING_UP or TERMINATED; `emitTaskEnd` instead of `emitTaskReject` to avoid duplicate rejection popup +- `uiControlsComputer.ts`: Outdial accept disabled (`isWebrtc && !isOutdial`); decline `VISIBLE_DISABLED` for WebRTC outdial + +**Widgets fixes:** +- `helper.ts`: `TASK_OUTDIAL_FAILED` on `taskRejectCallback` (dismiss incoming notification); outdial failure via `store.handleOutdialFailed` +- IncomingTask / TaskList: `isBrowser` for outdial "Ringing..." vs "Accept" label; read `uiControls.main.accept/decline` +- CallControlCAD: Outdial header uses `dnis`; phone label keeps `ani` (PROD parity) + +**Planned follow-up:** If SDK reverts to `emitTaskReject` on outdial failure, add widgets-side dedup to suppress duplicate rejection popup when `TASK_OUTDIAL_FAILED` already handled. + +--- + +### 2026-03-30 — Dial Number Transfer Wrapup Visibility + +**Issue:** After dial number consult transfers, wrapup button not appearing (SET_6 E2E failures). + +**Root cause:** Backend sends `AgentConsultEnded` before `AgentConsultTransferred`; CONSULT_END cleared `consultInitiator` before TRANSFER_SUCCESS evaluated wrapup. + +**Fix:** SDK `transferRequested` flag + `clearConsultStatePreservingTransfer` in state machine. Widgets consume `task.uiControls.main.wrapup.isVisible` — no widget code change. --- _Created: 2026-03-09_ -_Updated: 2026-03-24 (added dead code removal and task-object source of truth sections; aligned with PR #648 decisions)_ +_Updated: 2026-05-20 (migration complete reference; per-leg TaskUIControls; SDK→store→widgets flow; outdial fix log; popup model)_ diff --git a/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md b/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md index 08ac77fb1..1166f7fa4 100644 --- a/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md +++ b/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md @@ -2,13 +2,45 @@ ## Summary -The store's `storeEventsWrapper.ts` currently registers 27 individual task event handlers via `registerTaskEventListeners()` that manually call `refreshTaskList()` and update observables. A companion method `handleTaskRemove()` unregisters them. With the SDK task-refactor, the SDK handles state transitions internally via a state machine. The core migration is: +**Status: Done.** The store's [`storeEventsWrapper.ts`](../../store/src/storeEventsWrapper.ts) registers per-task SDK event handlers via `registerTaskEventListeners()`. The SDK state machine owns transitions and `task.uiControls`; the store mirrors task inventory through `refreshTaskList()` and fires optional host callbacks. -1. **Switch event names** — use SDK `TASK_EVENTS` enum (delete local copy) -2. **Keep `refreshTaskList()`** — the store does not observe `task.data` directly; `refreshTaskList()` is needed so the store re-syncs observables and the UI re-renders -3. **Add `TASK_UI_CONTROLS_UPDATED`** subscription to trigger widget re-renders -4. **Replace `isDeclineButtonEnabled`** store property with `task.uiControls.decline.isEnabled` -5. **Fix `TASK_CONSULT_END` wiring** — wire the existing (dead) `handleConsultEnd` method +Core behaviors implemented: + +1. **SDK `TASK_EVENTS` enum** — imported via `@webex/cc-store` (local enum removed) +2. **`refreshTaskList()`** — kept in handlers; re-syncs MobX `taskList` / `currentTask` +3. **`TASK_UI_CONTROLS_UPDATED`** — `handleUIControlsUpdated` → `refreshTaskList()` +4. **`handleConsultEnd`** — wired to `TASK_CONSULT_END` (no longer dead code) +5. **`TASK_SWITCH_CALL`** — `handleSwitchCall` → `refreshTaskList()` +6. **Legacy bridge:** `handleAutoAnswer` still sets `isDeclineButtonEnabled`; widgets also read `task.uiControls.main.decline.isEnabled` + +--- + +## Current Implementation + +Entry point: `registerTaskEventListeners(task)` in `storeEventsWrapper.ts`. Cleanup: `handleTaskRemove(taskToRemove)`. + +### Lifecycle and host callbacks + +| Event | Handler | Behavior | +|-------|---------|----------| +| `TASK_END` | `handleTaskEnd` | `setIsDeclineButtonEnabled(false)` + `refreshTaskList()` | +| `TASK_ASSIGNED` | `handleTaskAssigned` | Set ENGAGED state, `setCurrentTask`, `onTaskAssigned` | +| `TASK_REJECT` | `handleTaskReject` | `onTaskRejected(task, reason)` + `refreshTaskList()` | +| `TASK_OUTDIAL_FAILED` | `handleOutdialFailed` | `onOutdialFailed(reason)` only — **no** `refreshTaskList()` in handler | +| `TASK_UI_CONTROLS_UPDATED` | `handleUIControlsUpdated` | `refreshTaskList()` | + +### Outdial event flow + +| SDK event | Store handler | Host / widget effect | +|-----------|---------------|----------------------| +| `TASK_OUTDIAL_FAILED` | `handleOutdialFailed` | Host failure modal via `setOutdialFailed(reason)` | +| `TASK_OUTDIAL_FAILED` | (widget) `taskRejectCallback` | IncomingTask dismiss via `setTaskCallback` in `useIncomingTask` | +| `TASK_REJECT` | `handleTaskReject` | Host "Task Rejected" popup via `setTaskRejected` | +| `TASK_END` | `handleTaskEnd` | Cleanup + refresh — **no** rejection popup | + +**Double-popup context:** SDK uses `emitTaskEnd` (not `emitTaskReject`) on `OUTBOUND_FAILED` so only `TASK_OUTDIAL_FAILED` shows the failure modal. A planned widgets-side dedup exists if SDK reverts to `emitTaskReject`. + +CC-level handlers (after agent login): `TASK_INCOMING` → `handleIncomingTask`, `TASK_HYDRATE`, `TASK_MERGED`. --- @@ -417,10 +449,10 @@ handleAutoAnswer = () => { }; ``` -#### After +#### Current (legacy bridge retained) ```typescript handleAutoAnswer = () => { - // setIsDeclineButtonEnabled removed — use task.uiControls.decline.isEnabled instead. + this.setIsDeclineButtonEnabled(true); // Legacy bridge — widgets also use uiControls.main.decline.isEnabled this.refreshTaskList(); }; ``` @@ -429,18 +461,26 @@ handleAutoAnswer = () => { ## Validation Criteria -- [ ] All event names switched to SDK `TASK_EVENTS` enum (`TASK_WRAPPEDUP`, `TASK_CONSULT_CREATED`, `TASK_OFFER_CONTACT`, `TASK_RECORDING_PAUSED`, `TASK_RECORDING_RESUMED`) -- [ ] Local `TASK_EVENTS` enum deleted from `store.types.ts`; imported from `@webex/contact-center` -- [ ] `refreshTaskList()` still called in all existing handlers (no removal in this migration) -- [ ] `TASK_UI_CONTROLS_UPDATED` handler added; triggers widget re-renders -- [ ] `handleConsultEnd` is properly wired to `TASK_CONSULT_END` and resets consult state -- [ ] `handleAutoAnswer` no longer calls `setIsDeclineButtonEnabled` — widget layer uses `task.uiControls.decline.isEnabled` -- [ ] `handleConsultAccepted` still registers `TASK_MEDIA` listener on consult task (browser) -- [ ] Task list stays in sync on all lifecycle events (incoming, assigned, end, reject, wrapup) -- [ ] No regression in consult/conference/hold flows -- [ ] `handleTaskRemove` unregisters all listeners correctly (no listener leaks) -- [ ] Task-layer consumers (`task/src/helper.ts`) updated to use SDK event names +| Criterion | Status | +|-----------|--------| +| SDK `TASK_EVENTS` enum used (`TASK_WRAPPEDUP`, `TASK_CONSULT_CREATED`, etc.) | **Done** | +| Local `TASK_EVENTS` removed; imported from SDK via `@webex/cc-store` | **Done** | +| `refreshTaskList()` retained in handlers | **Done** | +| `TASK_UI_CONTROLS_UPDATED` → `handleUIControlsUpdated` | **Done** | +| `handleConsultEnd` wired to `TASK_CONSULT_END` | **Done** | +| `handleAutoAnswer` uses only `uiControls.decline.isEnabled` | **Legacy** — store flag still set; widgets OR with SDK control | +| `handleConsultAccepted` registers `TASK_MEDIA` (browser) | **Done** | +| Task list sync on lifecycle events | **Done** | +| Consult/conference/hold flows | **Done** | +| `handleTaskRemove` listener cleanup | **Open** — `TASK_CONFERENCE_TRANSFERRED` `.off()` uses wrong handler ref (listener leak) | +| Task-layer SDK event names in `helper.ts` | **Done** | + +--- + +## Known Issues (Open) + +- **`handleTaskRemove` listener mismatch:** `registerTaskEventListeners` wires `TASK_CONFERENCE_TRANSFERRED → refreshTaskList`, but `handleTaskRemove` calls `.off(TASK_CONFERENCE_TRANSFERRED, handleConferenceEnded)` — listener never removed. --- -_Parent: [migration-overview.md](./migration-overview.md) — overview doc is added in PR 1/4; link resolves once that PR is merged._ +_Parent: [migration-overview.md](./migration-overview.md)_ diff --git a/packages/contact-center/ai-docs/migration/store-task-utils-migration.md b/packages/contact-center/ai-docs/migration/store-task-utils-migration.md index a5266735c..271064ea7 100644 --- a/packages/contact-center/ai-docs/migration/store-task-utils-migration.md +++ b/packages/contact-center/ai-docs/migration/store-task-utils-migration.md @@ -2,179 +2,85 @@ ## Summary -The store's `task-utils.ts` contains 16 exported utility functions. With the SDK task-refactor, `task.uiControls` is the single source of truth for UI control states, and `task.data` provides state flags directly (hold state, conference status, consult status). Most store utils become **dead code** because their only consumer — `getControlsVisibility()` — is being deleted. - -**What gets removed:** 10 functions (the `getControlsVisibility` → `getConsultStatus` → `getTaskStatus` → `getConsultMPCState` chain, plus helper functions consumed only within that chain). Delete them along with their associated constants and types. - -**What stays:** 6 functions that serve widget-layer concerns unrelated to control visibility (task routing, participant display, media resource lookup, hold timestamp for timers). - -**Barrel export:** `store/src/index.ts` has `export * from './task-utils'`. Before removing, confirm with downstream consumers (Epic) that these utils are unused externally. Exported does not mean used. - ---- - -## Files to Modify - -| File | Action | -|------|--------| -| `store/src/task-utils.ts` | Remove 10 dead-code functions, keep 6 | -| `store/src/store.types.ts` | Delete `ConsultStatus` enum | -| `store/src/constants.ts` | Delete 12 task/interaction/consult state constants (all consumers are being removed). Keep 7 participant/media constants. | -| `task/src/Utils/task-util.ts` | Delete `getControlsVisibility` + all 22 `get*ButtonVisibility` functions; keep `findHoldTimestamp(interaction, mType)` | -| `store/tests/task-utils.ts` | Remove tests for 10 deleted functions | -| `task/tests/utils/task-util.ts` | Remove tests for deleted visibility functions | -| All consumers of removed functions | Update imports | +**Status: Done.** The store's [`task-utils.ts`](../../store/src/task-utils.ts) was trimmed to widget-layer helpers that are **not** replaced by `task.uiControls`. The `getControlsVisibility()` dependency chain (`getConsultStatus`, `getTaskStatus`, `findHoldStatus`, etc.) has been **removed**. --- -## Dead Code — Functions to Remove (10 functions) - -These functions form a dependency chain rooted at `getControlsVisibility()`. Once `getControlsVisibility()` is deleted (replaced by `task.uiControls`), the entire chain is unused and should be deleted. - -### Primary chain (consumed only by `getControlsVisibility`) - -| # | Function | Why dead | -|---|----------|----------| -| 1 | `getConsultStatus(task, agentId)` | Only consumer is `getControlsVisibility` | -| 2 | `getIsConferenceInProgress(task)` | Only consumer is `getControlsVisibility` (tests only — production code uses `task?.data?.isConferenceInProgress` directly) | -| 3 | `getConferenceParticipantsCount(task)` | Only consumer is `getControlsVisibility` | -| 4 | `getIsCustomerInCall(task)` | Only consumer is `getControlsVisibility` | -| 5 | `getIsConsultInProgress(task)` | Only consumer is `getControlsVisibility` | - -### Secondary chain (consumed only by functions above) - -| # | Function | Why dead | -|---|----------|----------| -| 6 | `getTaskStatus(task, agentId)` | Only consumer is `getConsultStatus()` — no external consumer | -| 7 | `getConsultMPCState(task, agentId)` | Only consumer is `getTaskStatus()` | -| 8 | `isSecondaryEpDnAgent(task)` | Only consumers are `getTaskStatus()` and `getConsultStatus()` | -| 9 | `isSecondaryAgent(task)` | Only consumer is internal `task-utils.ts` logic | +## Removed Functions (Historical) -### Hold status (replaced by SDK) +These were deleted because their only consumer was `getControlsVisibility()` (also removed from `task-util.ts`): -| # | Function | Why removed | -|---|----------|-------------| -| 10 | `findHoldStatus(task, mType, agentId)` | SDK state machine tracks hold state internally. Widgets get hold state from the task object. Do NOT derive from `controls.hold.isEnabled` (that is an action flag — disabled during conference/consulting even when call is held). | +| Function | SDK replacement | +|----------|-----------------| +| `getConsultStatus`, `getTaskStatus`, `getConsultMPCState` | `task.data.consultStatus` for display; controls from `task.uiControls` | +| `getIsConferenceInProgress`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount` | SDK computes via `uiControlsComputer` | +| `findHoldStatus` | `isInteractionOnHold(task)` + CallControl hook hold logic | -### SDK replacements for removed functions - -| Old function | SDK replacement | -|---|---| -| `getConsultStatus` / `getTaskStatus` (for display) | `task.data.consultStatus` (e.g. `consultInitiated`, `consultAccepted`) | -| `getIsConferenceInProgress` | `task.data.isConferenceInProgress` | -| `getIsConsultInProgress` / `getIsCustomerInCall` / `getConferenceParticipantsCount` | SDK computes internally via `task.uiControls` | -| `findHoldStatus` | Task object (SDK tracks hold state in `TaskContext`) | +Associated constants (`ConsultStatus` enum, interaction/consult state constants used only by removed functions) were deleted per original migration plan. --- -## Functions to Keep (6 functions) +## Functions That Remain -| # | Function | Why kept | -|---|----------|----------| -| 1 | `isIncomingTask(task, agentId)` | Store task routing — not related to control visibility | -| 2 | `getConferenceParticipants(task, agentId)` | CallControl UI participant list display. Uses `EXCLUDED_PARTICIPANT_TYPES`. | -| 3 | `isInteractionOnHold(task)` | Timer logic | -| 4 | `findMediaResourceId(task, mType)` | Switch-call actions need media resource IDs. Uses `RELATIONSHIP_TYPE_CONSULT`, `MEDIA_TYPE_CONSULT`. | -| 5 | `findHoldTimestamp(task, mType)` | Hold timer needs timestamp | -| 6 | `setmTypeForEPDN(task, mType)` | Media type for EP-DN agents, used by CallControl hook | +| Function | Purpose | +|----------|---------| +| `isIncomingTask(task, agentId)` | Store routing — incoming tasks not set as `currentTask` | +| `getConferenceParticipants(task, agentId)` | CallControl participant list UI | +| `isInteractionOnHold(task)` | Hold indicator — checks main-call media only | +| `findMediaResourceId(task, mType)` | Switch-call / hold actions need media resource IDs | +| `findHoldTimestamp(task, mType)` | Hold timer timestamp | +| `setmTypeForEPDN(task, mType)` | EP-DN agent media type for CallControl hook | +| `isSecondaryAgent(task)` | EP-DN / consult secondary agent detection | +| `isSecondaryEpDnAgent(task)` | Telephony secondary EP-DN check | -### `findHoldTimestamp` Dual Signatures +### `findHoldTimestamp` dual signatures -Two different `findHoldTimestamp` functions exist: -- **`store/src/task-utils.ts`:** `findHoldTimestamp(task: ITask, mType: string)` — takes full task object -- **`task/src/Utils/task-util.ts`:** `findHoldTimestamp(interaction: Interaction, mType: string)` — takes interaction only +- **`store/task-utils.ts`:** `findHoldTimestamp(task, mType)` — used by timer utils via `@webex/cc-store` +- **`task/Utils/task-util.ts`:** `findHoldTimestamp(interaction, mType)` — used by `useHoldTimer.ts` -`timer-utils.ts` imports from `@webex/cc-store` (task version). `useHoldTimer.ts` imports from `task-util` (interaction version). Both are kept. Do not confuse them during migration. +Do not confuse the two signatures. --- -## Constants and Types to Delete - -Since the functions that depend on these constants are all being deleted, there is **no ordering constraint** — delete constants and functions together. - -| Delete | File | Reason | -|--------|------|--------| -| Local `TASK_EVENTS` enum | `store/src/store.types.ts` | SDK exports this (see [store-event-wiring-migration.md](./store-event-wiring-migration.md)) | -| `ConsultStatus` enum | `store/src/store.types.ts` | All consumers (`getConsultStatus`, `getControlsVisibility`) are being deleted | -| `TASK_STATE_CONSULT` | `store/src/constants.ts` | Consumers (`getConsultMPCState`, `findHoldStatus`) are being deleted | -| `TASK_STATE_CONSULTING` | `store/src/constants.ts` | Consumer (`getConsultMPCState`) is being deleted | -| `TASK_STATE_CONSULT_COMPLETED` | `store/src/constants.ts` | Consumer (`getConsultMPCState`) is being deleted | -| `INTERACTION_STATE_WRAPUP` | `store/src/constants.ts` | Consumer (`getTaskStatus`) is being deleted | -| `INTERACTION_STATE_POST_CALL` | `store/src/constants.ts` | Consumer (`getTaskStatus`) is being deleted | -| `INTERACTION_STATE_CONNECTED` | `store/src/constants.ts` | Consumer (`getTaskStatus`) is being deleted | -| `INTERACTION_STATE_CONFERENCE` | `store/src/constants.ts` | Consumer (`getTaskStatus`) is being deleted | -| `CONSULT_STATE_INITIATED` | `store/src/constants.ts` | Consumer (`getConsultMPCState`) is being deleted | -| `CONSULT_STATE_COMPLETED` | `store/src/constants.ts` | Consumer (`getConsultMPCState`) is being deleted | -| `CONSULT_STATE_CONFERENCING` | `store/src/constants.ts` | Consumer (`getConsultMPCState`) is being deleted | - -**Consult string alias:** `TASK_STATE_CONSULT`, `RELATIONSHIP_TYPE_CONSULT`, and `MEDIA_TYPE_CONSULT` all resolve to `'consult'`. After deletion, only `RELATIONSHIP_TYPE_CONSULT` and `MEDIA_TYPE_CONSULT` remain (used by `findMediaResourceId`). - -## Constants to Keep - -Used by retained functions — do not delete. - -| Keep | File | Used by | -|------|------|---------| -| `RELATIONSHIP_TYPE_CONSULT` | `store/src/constants.ts` | `findMediaResourceId` | -| `MEDIA_TYPE_CONSULT` | `store/src/constants.ts` | `findMediaResourceId` | -| `AGENT` | `store/src/constants.ts` | `getConferenceParticipants` | -| `CUSTOMER` | `store/src/constants.ts` | `EXCLUDED_PARTICIPANT_TYPES` | -| `SUPERVISOR` | `store/src/constants.ts` | `EXCLUDED_PARTICIPANT_TYPES` | -| `VVA` | `store/src/constants.ts` | `EXCLUDED_PARTICIPANT_TYPES` | -| `EXCLUDED_PARTICIPANT_TYPES` | `store/src/constants.ts` | `getConferenceParticipants` | +## Hold State Guidance ---- +**Do NOT** use `controls.main.hold.isEnabled` as the hold-state indicator — it is an action flag (disabled during conference/consult even when call is held). -## `getControlsVisibility` Deletion Scope +**Use instead:** +- `isInteractionOnHold(currentTask)` for main-leg hold chip +- CallControl hook logic for consult (`activeLeg`, `controls.consult.endConsult.isVisible`) and conference (`conferenceHoldParticipant`, explicit hold/unhold event types) -> **Note:** `getControlsVisibility` lives in `task/src/Utils/task-util.ts` (hook layer, not store). It appears here because it is the primary consumer of the store functions being removed. Full hook-layer migration is covered in [call-control-hook-migration.md](./call-control-hook-migration.md). +--- -### What gets deleted from `task-util.ts` +## `getControlsVisibility` Deletion -`getControlsVisibility` + all 22 `get*ButtonVisibility` functions: -`getAcceptButtonVisibility`, `getDeclineButtonVisibility`, `getEndButtonVisibility`, `getMuteUnmuteButtonVisibility`, `getHoldResumeButtonVisibility`, `getPauseResumeRecordingButtonVisibility`, `getRecordingIndicatorVisibility`, `getTransferButtonVisibility`, `getConferenceButtonVisibility`, `getExitConferenceButtonVisibility`, `getMergeConferenceButtonVisibility`, `getConsultButtonVisibility`, `getEndConsultButtonVisibility`, `getConsultTransferButtonVisibility`, `getMergeConferenceConsultButtonVisibility`, `getConsultTransferConsultButtonVisibility`, `getMuteUnmuteConsultButtonVisibility`, `getSwitchToMainCallButtonVisibility`, `getSwitchToConsultButtonVisibility`, `getWrapupButtonVisibility` +Removed from `task/src/Utils/task-util.ts` along with all 22 `get*ButtonVisibility` helpers. -### What replaces it +**Replacement:** ```typescript -import { getDefaultUIControls } from '@webex/contact-center'; +import { getDefaultUIControls } from '@webex/cc-store'; const controls = currentTask?.uiControls ?? getDefaultUIControls(); +// Access per-leg: controls.main.hold, controls.consult.endConsult, controls.activeLeg ``` -`getDefaultUIControls()` is exported by the SDK from `uiControlsComputer.ts` — it returns a `TaskUIControls` object with all 17 controls set to `{ isVisible: false, isEnabled: false }`. Used as a safe fallback when `currentTask` is null. - -### Feature-flag gating — handled by SDK - -The old `getControlsVisibility` applied integrator-provided widget props (`featureFlags`, `conferenceEnabled`, `deviceType`) to gate controls. The SDK now handles this internally via `UIControlConfig` (derived from agent profile and `callProcessingDetails`): - -| Old widget prop | SDK equivalent | -|-----------------|---------------| -| `featureFlags.isEndCallEnabled` | `config.isEndTaskEnabled` | -| `featureFlags.isEndConsultEnabled` | `config.isEndConsultEnabled` | -| `featureFlags.webRtcEnabled` (recording gate) | `config.isRecordingEnabled` | -| `conferenceEnabled` | SDK computes conference/mergeToConference/exitConference visibility based on task state and config | - -Since `task.uiControls` already reflects these gates, the widget layer can **remove** the `featureFlags`, `conferenceEnabled`, and `deviceType` props — no widget-side overlay is needed. - -```typescript -const controls = currentTask?.uiControls ?? getDefaultUIControls(); -``` +Feature-flag gating (`isEndTaskEnabled`, `isEndConsultEnabled`, `isRecordingEnabled`) is applied inside SDK `uiControlsComputer` via `UIControlConfig`. **`conferenceEnabled`** remains an application-level prop at the widget/component layer. --- ## Validation Criteria -- [ ] 10 dead-code functions deleted with no remaining consumers (compile check) -- [ ] 6 kept functions still work correctly -- [ ] `ConsultStatus` enum removed -- [ ] 12 state constants deleted; 7 participant/media constants kept -- [ ] `getControlsVisibility` + 22 visibility functions deleted from `task-util.ts` -- [ ] `findHoldTimestamp` dual-signature (task vs interaction) not confused -- [ ] Widget props `featureFlags`, `conferenceEnabled`, `deviceType` removed (SDK handles via `UIControlConfig`) -- [ ] No regression in conference participant display, hold timers, or switch-call actions -- [ ] Downstream (Epic) confirmed unused before removing barrel exports +| Criterion | Status | +|-----------|--------| +| Dead-code chain removed | **Done** | +| Kept functions still used | **Done** | +| `ConsultStatus` enum and unused constants removed | **Done** | +| `getControlsVisibility` + 22 helpers removed from task-util | **Done** | +| Hold state not derived from `controls.hold.isEnabled` | **Done** | +| `findHoldTimestamp` dual-signature preserved | **Done** | --- _Parent: [migration-overview.md](./migration-overview.md)_ -_Updated: 2026-03-24 (aligned with PR #648 decisions — dead code removal, SDK source of truth, feature-flag gating moved to SDK per bhabalan review)_ +_Updated: 2026-05-20_ diff --git a/packages/contact-center/ai-docs/migration/task-list-migration.md b/packages/contact-center/ai-docs/migration/task-list-migration.md index 8ff230831..8c00876cd 100644 --- a/packages/contact-center/ai-docs/migration/task-list-migration.md +++ b/packages/contact-center/ai-docs/migration/task-list-migration.md @@ -2,158 +2,84 @@ ## Summary -The TaskList widget displays all active tasks and allows accept/decline/select. Changes are minimal since task list management (add/remove tasks) stays in the store, and SDK methods are unchanged. The main change is using `task.uiControls` (from the task object) for per-task accept/decline visibility instead of `deviceType`/`isBrowser`. +**Status: Done.** TaskList displays accept/decline per task using `task.uiControls.main.accept` and `task.uiControls.main.decline`. Task list membership stays store-managed via `refreshTaskList()`. --- -## Old Approach +## Current Implementation -### Entry Point -**File:** `packages/contact-center/task/src/helper.ts` -**Hook:** `useTaskList(props: UseTaskListProps)` +### Entry points -### How It Works (Old) -1. Store maintains `taskList: Record` observable -2. Store maintains `currentTask: ITask | null` observable -3. Hook provides `acceptTask(task)`, `declineTask(task)`, `onTaskSelect(task)` actions -4. Accept/decline visibility gated by `deviceType` / `isBrowser` -5. Task display data extracted by `cc-components/task/Task/task.utils.ts` +| Layer | File | +|-------|------| +| Hook | `task/src/helper.ts` — `useTaskList` | +| Widget wrapper | `task/src/TaskList/index.tsx` | +| Component | `cc-components/.../TaskList/task-list.tsx` | +| Utils | `cc-components/.../TaskList/task-list.utils.ts` | ---- - -## New Approach +### Per-task controls -### What Changes -1. **Accept/decline visibility** — replace `deviceType`/`isBrowser` gating with per-task `task.uiControls.accept` / `task.uiControls.decline` -2. **Task list management** (add/remove) stays the same — store-managed -3. **SDK methods unchanged**: `task.accept()`, `task.decline()` -4. **Store callbacks unchanged**: `setTaskAssigned`, `setTaskRejected`, `setTaskSelected` +```typescript +// task-list.utils.ts — extractTaskListItemData +const accept = acceptControl ?? task.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; +const sdkDecline = declineControl ?? task.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; +const decline = { + ...sdkDecline, + isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled, +}; +``` -> **Note:** Widgets do not know about the SDK's internal state machine. All state information comes from the task object properties. +`TaskList/index.tsx` passes `store.deviceType === 'BROWSER'` as `isBrowser` for outdial label text (same rules as IncomingTask). -### Minimal Changes Required -- Accept/decline button visibility per task: use `task.uiControls?.accept` and `task.uiControls?.decline` (each has `isVisible`, `isEnabled`) -- Remove `deviceType` / `isBrowser` from hook props and component -- Task selection logic unchanged -- Optional: if the list must react to control updates without task replacement, subscribe to `'task:ui-controls-updated'` per task (event name; enum `TASK_UI_CONTROLS_UPDATED` may not exist in store yet — use literal) +### Re-render path -### Dead Code Removal +List rows update when store calls `refreshTaskList()` — including on `TASK_UI_CONTROLS_UPDATED`. No per-task `uiControls` subscription is required in TaskList today. -`getTaskStatus()` and `getConsultStatus()` (in `store/src/task-utils.ts`) are **only** used inside `getControlsVisibility()` (call chain: `getControlsVisibility() → getConsultStatus() → getTaskStatus() → getConsultMPCState()`). Since `getControlsVisibility()` is being removed entirely (replaced by `task.uiControls`), the entire chain becomes dead code and should be deleted. +### Store callbacks (unchanged) -If widgets ever need consult status for **display purposes** (e.g., a status label like "Consulting" or "Consult requested"), the SDK provides `task.data.consultStatus` with the same values (`CONSULT_INITIATED`, `CONSULT_ACCEPTED`, `BEING_CONSULTED`, `BEING_CONSULTED_ACCEPTED`, `CONNECTED`, `CONFERENCE`, `CONSULT_COMPLETED`). +- `setTaskAssigned` → host `onTaskAccepted` +- `setTaskRejected` → host `onTaskDeclined` +- `setTaskSelected` → `setCurrentTask(task, isClicked)` --- ## Old → New Mapping -| Aspect | Old | New | -|--------|-----|-----| -| Task list source | `store.taskList` observable | `store.taskList` observable (unchanged) | -| Current task | `store.currentTask` observable | `store.currentTask` observable (unchanged) | -| Accept/decline visibility | `isBrowser` (from `deviceType`) | `task.uiControls.accept` / `task.uiControls.decline` (per-task, from SDK) | -| Accept action | `task.accept()` | `task.accept()` (unchanged) | -| Decline action | `task.decline()` | `task.decline()` (unchanged) | -| Select action | `store.setCurrentTask(task, isClicked)` | Unchanged | -| Consult status (display) | `getConsultStatus(task, agentId)` via `getControlsVisibility()` | Dead code — remove. If needed for display, use `task.data.consultStatus` (SDK provides) | +| Aspect | Old | New (current) | +|--------|-----|---------------| +| Task list source | `store.taskList` | Unchanged | +| Accept/decline visibility | `isBrowser` from `deviceType` | `task.uiControls.main.accept/decline` | +| Decline enabled | Device type only | SDK control OR `isDeclineButtonEnabled` | +| Accept label (outdial) | N/A | `isBrowser` + outdial rules in utils | +| Select action | `store.setCurrentTask` | Unchanged | --- -## Before/After: Per-Task Accept/Decline in TaskList - -### Before (TaskList component renders accept/decline per task) -```tsx -// task-list.tsx — old approach -const TaskListComponent = ({ taskList, isBrowser, onAccept, onDecline, onSelect }) => { - return taskList.map((task) => { - // Accept/decline visibility computed per-task from device type - const showAccept = isBrowser; // simplified - return ( - onSelect(task)}> - {showAccept && } - {showAccept && } - - ); - }); -}; -``` - -### After (use per-task `uiControls`) -```tsx -// task-list.tsx — new approach -const TaskListComponent = ({ taskList, onAccept, onDecline, onSelect }) => { - return taskList.map((task) => { - // SDK provides per-task control visibility - const acceptControl = task.uiControls?.accept ?? {isVisible: false, isEnabled: false}; - const declineControl = task.uiControls?.decline ?? {isVisible: false, isEnabled: false}; - return ( - onSelect(task)}> - {acceptControl.isVisible && ( - - )} - {declineControl.isVisible && ( - - )} - - ); - }); -}; -``` - -### Before/After: `useTaskList` Hook +## Outdial label rules (mirrors IncomingTask) -#### Before -```typescript -// helper.ts — useTaskList (abbreviated) -export const useTaskList = (props: UseTaskListProps) => { - const {deviceType, onTaskAccepted, onTaskDeclined, onTaskSelected, logger, taskList} = props; - const isBrowser = deviceType === 'BROWSER'; // Used for accept/decline visibility - - // ... store callbacks and actions unchanged ... - - return {taskList, acceptTask, declineTask, onTaskSelect, isBrowser}; - // ^^^^^^^^^ passed to component -}; -``` +In `extractTaskListItemData`: -#### After ```typescript -// helper.ts — useTaskList (migrated) -export const useTaskList = (props: UseTaskListProps) => { - const {onTaskAccepted, onTaskDeclined, onTaskSelected, logger, taskList} = props; - // REMOVED: deviceType, isBrowser — no longer needed, SDK handles per-task visibility - - // ... store callbacks and actions unchanged ... - - return {taskList, acceptTask, declineTask, onTaskSelect}; - // REMOVED: isBrowser — each task.uiControls.accept/decline provides visibility -}; +const isOutdial = task?.data?.interaction?.outboundType === 'OUTDIAL'; +const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); +const acceptText = accept.isVisible ? (showRinging ? 'Ringing...' : 'Accept') : undefined; ``` ---- - -## Files to Modify - -| File | Action | -|------|--------| -| `task/src/helper.ts` (`useTaskList`) | Remove `isBrowser`, use per-task `uiControls` for accept/decline | -| `task/src/TaskList/index.tsx` | Remove `isBrowser` prop pass-through | -| `cc-components/.../TaskList/task-list.tsx` | Use `task.uiControls.accept/decline` per task | -| `cc-components/.../TaskList/task-list.utils.ts` | Update `extractTaskListItemData()` to use `task.uiControls.accept/decline`; remove `isBrowser`-based gating | -| `cc-components/.../Task/task.utils.ts` | Update task data extraction if needed | -| `store/src/task-utils.ts` | **Remove** `getTaskStatus`, `getConsultStatus`, `getConsultMPCState` — dead code once `getControlsVisibility()` is removed. Retain `findHoldTimestamp`, `isIncomingTask`, and other utils still used elsewhere | +Phone number for outdial rows uses `dnis` over `ani` (same as IncomingTask). --- ## Validation Criteria -- [ ] Task list displays all active tasks -- [ ] Task selection works (sets `currentTask`) -- [ ] Accept/decline per task works -- [ ] Task status displays correctly (connected, held, wrapup, etc.) -- [ ] Tasks removed from list on end/reject -- [ ] New incoming tasks appear in list +| Criterion | Status | +|-----------|--------| +| Per-task `uiControls.main.accept/decline` | **Done** | +| `isBrowser` for outdial text only | **Done** | +| `isDeclineButtonEnabled` bridge | **Done** | +| List sync via `refreshTaskList` + `TASK_UI_CONTROLS_UPDATED` | **Done** | +| `deviceType` removed as visibility gate | **Done** | --- -_Part of the task refactor migration doc set (overview in PR 1/4)._ +_Parent: [migration-overview.md](./migration-overview.md)_ +_Updated: 2026-05-20_ diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx index 65df644b3..9785df737 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx @@ -15,8 +15,9 @@ const CallControlConsultComponent: React.FC = switchToMainCall, logger, isMuted, - controlVisibility, + controls, toggleConsultMute, + conferenceEnabled = true, }) => { // Use the label and timestamp calculated in helper.ts // Stable key based on timestamp to prevent timer resets @@ -27,13 +28,14 @@ const CallControlConsultComponent: React.FC = const buttons = createConsultButtons( isMuted, - controlVisibility, + controls, consultTransfer, toggleConsultMute, endConsultCall, consultConference, switchToMainCall, - logger + logger, + conferenceEnabled ); // Filter buttons that should be shown, then map them 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 3180414db..1bc2f21f9 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 @@ -1,6 +1,6 @@ -import {BuddyDetails, ContactServiceQueue, ILogger} from '@webex/cc-store'; +import {BuddyDetails, ContactServiceQueue, ILogger, TaskUIControls} from '@webex/cc-store'; import {MUTE_CALL, UNMUTE_CALL} from '../../constants'; -import {ButtonConfig, ControlVisibility} from '../../task.types'; +import {ButtonConfig} from '../../task.types'; /** * Interface for list item data @@ -15,15 +15,26 @@ export interface ListItemData { */ export const createConsultButtons = ( isMuted: boolean, - controlVisibility: ControlVisibility, + controls: TaskUIControls, consultTransfer: () => void, toggleConsultMute: () => void, endConsultCall: () => void, consultConference: () => void, switchToMainCall: () => void, - logger? + logger?, + conferenceEnabled = true ): ButtonConfig[] => { try { + const consultCtrl = controls?.consult; + const mainCtrl = controls?.main; + const isConsultLegActive = controls?.activeLeg === 'consult'; + const consultTransferCtrl = consultCtrl?.transfer; + const consultTransferConferenceCtrl = consultCtrl?.transferConference; + const mainTransferConferenceCtrl = mainCtrl?.transferConference; + const isTransferConferenceVisible = + (consultTransferConferenceCtrl?.isVisible ?? false) || (mainTransferConferenceCtrl?.isVisible ?? false); + const isTransferConferenceEnabled = + (consultTransferConferenceCtrl?.isEnabled ?? false) || (mainTransferConferenceCtrl?.isEnabled ?? false); return [ { key: 'mute', @@ -31,26 +42,28 @@ export const createConsultButtons = ( onClick: toggleConsultMute, tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, - disabled: !controlVisibility.muteUnmuteConsult.isEnabled, - isVisible: controlVisibility.muteUnmuteConsult.isVisible, + // Consult mute should only be interactive while consult leg is active. + disabled: !isConsultLegActive || !(consultCtrl?.mute?.isEnabled ?? false), + isVisible: consultCtrl?.mute?.isVisible ?? false, }, { key: 'switchToMainCall', icon: 'call-swap-bold', - tooltip: controlVisibility.isConferenceInProgress ? 'Switch to Conference Call' : 'Switch to Call', + tooltip: 'Switch to Call', onClick: switchToMainCall, className: 'call-control-button', - disabled: !controlVisibility.switchToMainCall.isEnabled, - isVisible: controlVisibility.switchToMainCall.isVisible, + disabled: !(consultCtrl?.switch?.isEnabled ?? false), + isVisible: consultCtrl?.switch?.isVisible ?? false, }, { key: 'transfer', icon: 'next-bold', - tooltip: controlVisibility.isConferenceInProgress ? 'Transfer Conference' : 'Transfer', + tooltip: isTransferConferenceVisible ? 'Transfer Conference' : 'Transfer', onClick: consultTransfer, className: 'call-control-button', - disabled: !controlVisibility.consultTransferConsult.isEnabled, - isVisible: controlVisibility.consultTransferConsult.isVisible, + // Keep consult actions disabled while main leg is active. + disabled: !isConsultLegActive || !((consultTransferCtrl?.isEnabled ?? false) || isTransferConferenceEnabled), + isVisible: (consultTransferCtrl?.isVisible ?? false) || isTransferConferenceVisible, }, { key: 'conference', @@ -58,8 +71,8 @@ export const createConsultButtons = ( tooltip: 'Merge', onClick: consultConference, className: 'call-control-button', - disabled: !controlVisibility.mergeConferenceConsult.isEnabled, - isVisible: controlVisibility.mergeConferenceConsult.isVisible, + disabled: !(consultCtrl?.mergeToConference?.isEnabled ?? false), + isVisible: conferenceEnabled && (consultCtrl?.mergeToConference?.isVisible ?? false), }, { key: 'cancel', @@ -67,7 +80,7 @@ export const createConsultButtons = ( tooltip: 'End Consult', onClick: endConsultCall, className: 'call-control-consult-button-cancel', - isVisible: controlVisibility.endConsult.isVisible, + isVisible: (consultCtrl?.endConsult?.isVisible ?? false) || (mainCtrl?.endConsult?.isVisible ?? false), }, ]; } catch (error) { @@ -595,7 +608,7 @@ export const debounce = unknown>( logger? ): ((...args: Parameters) => void) => { try { - let timeout: NodeJS.Timeout; + let timeout: ReturnType; return (...args: Parameters) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); 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 7dfb1b137..41501e7ad 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 @@ -35,6 +35,7 @@ function CallControlComponent(props: CallControlComponentProps) { const { currentTask, + isHeld, toggleHold, toggleRecording, toggleMute, @@ -57,7 +58,7 @@ function CallControlComponent(props: CallControlComponentProps) { setConsultAgentName, allowConsultToQueue, setLastTargetType, - controlVisibility, + controls, logger, secondsUntilAutoWrapup, cancelAutoWrapup, @@ -65,6 +66,7 @@ function CallControlComponent(props: CallControlComponentProps) { getEntryPoints, getQueuesFetcher, consultTransferOptions, + conferenceEnabled = true, } = props; useEffect(() => { @@ -72,7 +74,7 @@ function CallControlComponent(props: CallControlComponentProps) { }, [currentTask, logger]); const handletoggleHold = () => { - handleToggleHoldUtil(controlVisibility.isHeld, toggleHold, logger); + handleToggleHoldUtil(isHeld, toggleHold, logger); }; const handleMuteToggle = () => { @@ -128,7 +130,8 @@ function CallControlComponent(props: CallControlComponentProps) { isRecording, isMuteButtonDisabled, currentMediaType, - controlVisibility, + controls, + isHeld, handleMuteToggle, handletoggleHold, toggleRecording, @@ -136,15 +139,13 @@ function CallControlComponent(props: CallControlComponentProps) { exitConference, switchToConsult, consultTransfer, - consultConference + consultConference, + logger, + conferenceEnabled ); - const filteredButtons = filterButtonsForConsultation( - buttons, - controlVisibility.isConsultInitiatedOrAccepted, - isTelephony, - logger - ); + const isConsulting = (controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) ?? false; + const filteredButtons = filterButtonsForConsultation(buttons, isConsulting, isTelephony, logger); if (!currentTask) return null; @@ -156,7 +157,7 @@ function CallControlComponent(props: CallControlComponentProps) { autoPlay >
- {!controlVisibility.isConsultReceived && !controlVisibility.wrapup.isVisible && ( + {!controls?.main?.wrapup?.isVisible && (
{filteredButtons.map((button, index) => { if (!button.isVisible) return null; @@ -249,7 +250,7 @@ function CallControlComponent(props: CallControlComponentProps) { showEntryPointTab: false, } } - isConferenceInProgress={controlVisibility.isConferenceInProgress} + isConferenceInProgress={controls?.main?.exitConference?.isVisible ?? false} logger={logger} /> ) : null} @@ -283,7 +284,7 @@ function CallControlComponent(props: CallControlComponentProps) { })}
)} - {controlVisibility.wrapup.isVisible && ( + {controls?.main?.wrapup?.isVisible && (
void, handleToggleHoldFunc: () => void, toggleRecording: () => void, @@ -203,9 +200,17 @@ export const buildCallControlButtons = ( switchToConsult: () => void, onTransferConsult: () => void, handleConsultConferencePress: () => void, - logger?: ILogger + logger?: ILogger, + conferenceEnabled = true ): CallControlButton[] => { try { + const mainCtrl = controls?.main; + const isTransferConferenceVisible = mainCtrl?.transferConference?.isVisible ?? false; + const isTransferConferenceEnabled = mainCtrl?.transferConference?.isEnabled ?? false; + const isTransferVisible = mainCtrl?.transfer?.isVisible ?? false; + const isTransferEnabled = mainCtrl?.transfer?.isEnabled ?? false; + const isConsulting = (controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) ?? false; + const shouldPrioritizeTransferConference = isTransferConferenceVisible; return [ { id: 'mute', @@ -213,8 +218,9 @@ export const buildCallControlButtons = ( onClick: handleMuteToggleFunc, tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, - disabled: isMuteButtonDisabled, - isVisible: controlVisibility.muteUnmute.isVisible, + // Respect SDK state and temporary click-guard state. + disabled: isMuteButtonDisabled || !(mainCtrl?.mute?.isEnabled ?? false), + isVisible: mainCtrl?.mute?.isVisible ?? false, dataTestId: 'call-control:mute-toggle', }, { @@ -223,19 +229,18 @@ export const buildCallControlButtons = ( tooltip: 'Switch to Consult Call', className: 'call-control-button', onClick: switchToConsult, - disabled: !controlVisibility.switchToConsult.isEnabled, - isVisible: controlVisibility.switchToConsult.isVisible, + disabled: !(mainCtrl?.switch?.isEnabled ?? false), + isVisible: mainCtrl?.switch?.isVisible ?? false, dataTestId: 'call-control:switch-to-consult', }, - { id: 'hold', - icon: controlVisibility.isHeld ? 'play-bold' : 'pause-bold', + icon: isHeld ? 'play-bold' : 'pause-bold', onClick: handleToggleHoldFunc, - tooltip: controlVisibility.isHeld ? RESUME_CALL : HOLD_CALL, + tooltip: isHeld ? RESUME_CALL : HOLD_CALL, className: 'call-control-button', - disabled: !controlVisibility.holdResume.isEnabled, - isVisible: controlVisibility.holdResume.isVisible, + disabled: !(mainCtrl?.hold?.isEnabled ?? false), + isVisible: mainCtrl?.hold?.isVisible ?? false, dataTestId: 'call-control:hold-toggle', }, { @@ -243,19 +248,19 @@ export const buildCallControlButtons = ( icon: 'headset-bold', tooltip: CONSULT_AGENT, className: 'call-control-button', - disabled: !controlVisibility.consult.isEnabled, + disabled: !(mainCtrl?.consult?.isEnabled ?? false), menuType: 'Consult', - isVisible: controlVisibility.consult.isVisible, + isVisible: mainCtrl?.consult?.isVisible ?? false, dataTestId: 'call-control:consult', }, { id: 'transferConsult', icon: 'next-bold', - tooltip: controlVisibility.isConferenceInProgress ? 'Transfer Conference' : 'Transfer', + tooltip: shouldPrioritizeTransferConference ? 'Transfer Conference' : 'Transfer', onClick: onTransferConsult || (() => {}), className: 'call-control-button', - disabled: !controlVisibility.consultTransfer.isEnabled, - isVisible: controlVisibility.consultTransfer.isVisible && !!onTransferConsult, + disabled: shouldPrioritizeTransferConference ? !isTransferConferenceEnabled : !isTransferEnabled, + isVisible: (isTransferVisible || shouldPrioritizeTransferConference) && isConsulting && !!onTransferConsult, }, { id: 'conference', @@ -263,17 +268,18 @@ export const buildCallControlButtons = ( tooltip: 'conference', onClick: handleConsultConferencePress || (() => {}), className: 'call-control-button', - disabled: !controlVisibility.mergeConference.isEnabled, - isVisible: controlVisibility.mergeConference.isVisible && !!handleConsultConferencePress, + disabled: !(mainCtrl?.conference?.isEnabled ?? false), + isVisible: conferenceEnabled && (mainCtrl?.conference?.isVisible ?? false) && !!handleConsultConferencePress, }, { id: 'transfer', icon: 'next-bold', tooltip: `${TRANSFER} ${currentMediaType.labelName}`, className: 'call-control-button', - disabled: !controlVisibility.transfer.isEnabled, + disabled: !isTransferEnabled, menuType: 'Transfer', - isVisible: controlVisibility.transfer.isVisible, + // When conference-transfer is available, prefer it over blind transfer. + isVisible: isTransferVisible && !shouldPrioritizeTransferConference, dataTestId: 'call-control:transfer', }, { @@ -282,8 +288,8 @@ export const buildCallControlButtons = ( onClick: toggleRecording, tooltip: isRecording ? PAUSE_RECORDING : RESUME_RECORDING, className: 'call-control-button', - disabled: !controlVisibility.pauseResumeRecording.isEnabled, - isVisible: controlVisibility.pauseResumeRecording.isVisible, + disabled: !(mainCtrl?.recording?.isEnabled ?? false), + isVisible: mainCtrl?.recording?.isVisible ?? false, dataTestId: 'call-control:recording-toggle', }, { @@ -292,8 +298,8 @@ export const buildCallControlButtons = ( tooltip: 'Exit Conference', className: 'call-control-button-muted', onClick: exitConference, - disabled: !controlVisibility.exitConference.isEnabled, - isVisible: controlVisibility.exitConference.isVisible, + disabled: !(mainCtrl?.exitConference?.isEnabled ?? false), + isVisible: conferenceEnabled && (mainCtrl?.exitConference?.isVisible ?? false), dataTestId: 'call-control:exit-conference', }, { @@ -302,8 +308,8 @@ export const buildCallControlButtons = ( onClick: endCall, tooltip: `${END} ${currentMediaType.labelName}`, className: 'call-control-button-cancel', - disabled: !controlVisibility.end.isEnabled, - isVisible: controlVisibility.end.isVisible, + disabled: !(mainCtrl?.end?.isEnabled ?? false), + isVisible: mainCtrl?.end?.isVisible ?? false, dataTestId: 'call-control:end-call', }, ]; @@ -320,6 +326,11 @@ export const buildCallControlButtons = ( /** * Filters buttons based on consultation state + * During consulting: + * - Hide: hold, consult, and blind transfer buttons + * - Respect SDK enabled/disabled state for consulting buttons (transferConsult, conference) + * They will be enabled when on main call, disabled when on consult call + * - Show as-is: mute, switchToConsult, recording, exitConference, end */ export const filterButtonsForConsultation = ( buttons: CallControlButton[], @@ -328,9 +339,11 @@ export const filterButtonsForConsultation = ( logger? ): CallControlButton[] => { try { - return consultInitiated && isTelephony - ? buttons.filter((button) => !['hold', 'consult'].includes(button.id)) - : buttons; + if (!consultInitiated || !isTelephony) { + return buttons; + } + + return buttons.filter((button) => !['hold', 'consult', 'transfer', 'record'].includes(button.id)); } catch (error) { logger?.error('CC-Widgets: CallControl: Error in filterButtonsForConsultation', { module: 'cc-components#call-control.utils.ts', diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index 068a103dc..06aaad318 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -20,6 +20,25 @@ font-weight: 250; } } + +} + +.global-variables { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + width: 100%; + max-height: 11.25rem; + overflow-y: auto; + margin-top: 0.75rem; + + .global-variable-item { + flex-basis: 50%; + min-width: 0; + box-sizing: border-box; + padding: 0.125rem 0; + line-height: 1.75rem; + } } /* On-hold chip styling */ diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index b2f31072a..037638787 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx @@ -5,7 +5,8 @@ import {Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/di import './call-control-cad.styles.scss'; import TaskTimer from '../TaskTimer/index'; import CallControlConsultComponent from '../CallControl/CallControlCustom/call-control-consult'; -import {MEDIA_CHANNEL as MediaChannelType, CallControlComponentProps} from '../task.types'; +import {MEDIA_CHANNEL as MediaChannelType, CallControlComponentProps, CallAssociatedDataMap} from '../task.types'; +import {getAgentViewableGlobalVariables} from '../Task/task.utils'; import {getMediaTypeInfo} from '../../../utils'; import { @@ -24,6 +25,7 @@ const CallControlCADComponent: React.FC = (props) => const { currentTask, isRecording, + isHeld, holdTime, consultAgentName, consultTimerLabel, @@ -37,11 +39,12 @@ const CallControlCADComponent: React.FC = (props) => startTimestamp, stateTimerLabel, stateTimerTimestamp, - controlVisibility, + controls, logger, isMuted, toggleMute, conferenceParticipants, + conferenceEnabled = true, } = props; const formatTime = (time: number): string => { @@ -61,13 +64,20 @@ const CallControlCADComponent: React.FC = (props) => const participantsCount = conferenceParticipants?.length || 1; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const ani = currentTask?.data?.interaction?.callAssociatedDetails?.ani; - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - const dn = currentTask?.data?.interaction?.callAssociatedDetails?.dn; + const isOutdial = currentTask?.data?.interaction?.outboundType === 'OUTDIAL'; + const dnis = + currentTask?.data?.interaction?.callAssociatedDetails?.dnis || + currentTask?.data?.interaction?.callProcessingDetails?.dnis; + const displayNumber = isOutdial ? dnis || ani : ani; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const callAssociatedData = (currentTask?.data?.interaction as any)?.callAssociatedData as + | CallAssociatedDataMap + | undefined; + const globalVariables = getAgentViewableGlobalVariables(callAssociatedData); // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; @@ -76,7 +86,7 @@ const CallControlCADComponent: React.FC = (props) => const phoneNumberTooltipId = `phone-number-tooltip-${currentTask.data.interaction.interactionId}`; const renderCustomerName = () => { - const customerText = isSocial ? customerName || NO_CUSTOMER_NAME : ani || NO_CALLER_ID; + const customerText = isSocial ? customerName || NO_CUSTOMER_NAME : displayNumber || NO_CALLER_ID; const textComponent = ( = (props) => }; const renderPhoneNumber = () => { - const phoneText = isSocial ? customerName || NO_CUSTOMER_NAME : dn || NO_PHONE_NUMBER; + const phoneText = isSocial ? customerName || NO_CUSTOMER_NAME : ani || NO_PHONE_NUMBER; const labelText = isSocial ? CUSTOMER_NAME : PHONE_NUMBER; const textComponent = ( @@ -178,7 +188,7 @@ const CallControlCADComponent: React.FC = (props) => )} - {controlVisibility.isConferenceInProgress && !controlVisibility.wrapup.isVisible && ( + {controls?.main?.exitConference?.isVisible && !controls?.main?.wrapup?.isVisible && ( <>
@@ -228,25 +238,22 @@ const CallControlCADComponent: React.FC = (props) => )}
- {!controlVisibility.wrapup.isVisible && - controlVisibility.isHeld && - !controlVisibility.isConsultReceived && - !controlVisibility.consultCallHeld && ( - <> - -
- - - {ON_HOLD} {formatTime(holdTime)} - -
- - )} + {!controls?.main?.wrapup?.isVisible && isHeld && ( + <> + +
+ + + {ON_HOLD} {formatTime(holdTime)} + +
+ + )}
- {!controlVisibility.wrapup.isVisible && controlVisibility.recordingIndicator.isVisible && ( + {!controls?.main?.wrapup?.isVisible && isTelephony && (
@@ -255,34 +262,48 @@ const CallControlCADComponent: React.FC = (props) =>
{QUEUE}{' '} - - { - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - - currentTask?.data?.interaction?.callAssociatedDetails?.virtualTeamName || NO_TEAM_NAME - } - + {currentTask?.data?.interaction?.callAssociatedDetails?.virtualTeamName || NO_TEAM_NAME} {renderPhoneNumber()}
+ {globalVariables.length > 0 && ( +
+ {globalVariables.map((variable) => ( +
+ + {variable.displayName || variable.name} + + + {variable.value || ''} + +
+ ))} +
+ )} - {controlVisibility.isConsultInitiatedOrAccepted && !controlVisibility.wrapup.isVisible && ( -
- -
- )} + {(controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) && + !controls?.main?.wrapup?.isVisible && ( +
+ +
+ )} ); }; diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx index 3b46d68c9..8ea8f33dc 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx @@ -5,13 +5,21 @@ import {withMetrics} from '@webex/cc-ui-logging'; import {extractIncomingTaskData} from './incoming-task.utils'; const IncomingTaskComponent: React.FunctionComponent = (props) => { - const {incomingTask, isBrowser, accept, reject, logger, isDeclineButtonEnabled} = props; + const {incomingTask, accept, reject, logger, acceptControl, declineControl, isDeclineButtonEnabled, isBrowser} = + props; if (!incomingTask) { return <>; // hidden component } // Extract all task data using the utility function - const taskData = extractIncomingTaskData(incomingTask, isBrowser, logger, isDeclineButtonEnabled); + const taskData = extractIncomingTaskData( + incomingTask, + logger, + acceptControl, + declineControl, + isDeclineButtonEnabled, + isBrowser + ); return ( { try { + const accept = acceptControl ?? incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; + const sdkDecline = declineControl ?? + incomingTask?.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; + const decline = { + ...sdkDecline, + isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled, + }; + // Extract basic data from task - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const callAssociationDetails = incomingTask?.data?.interaction?.callAssociatedDetails; - const ani = callAssociationDetails?.ani; + const isOutdial = incomingTask?.data?.interaction?.outboundType === 'OUTDIAL'; + const dnis = callAssociationDetails?.dnis || incomingTask?.data?.interaction?.callProcessingDetails?.dnis; + const ani = isOutdial ? dnis || callAssociationDetails?.ani : callAssociationDetails?.ani; const customerName = callAssociationDetails?.customerName; const virtualTeamName = callAssociationDetails?.virtualTeamName; const ronaTimeout = callAssociationDetails?.ronaTimeout ? Number(callAssociationDetails?.ronaTimeout) : null; @@ -47,23 +58,19 @@ export const extractIncomingTaskData = ( const isSocial = mediaType === MEDIA_CHANNEL.SOCIAL; // Compute button text based on conditions - const acceptText = !incomingTask.data.wrapUpRequired - ? isTelephony && !isBrowser - ? 'Ringing...' - : 'Accept' - : undefined; + // Extension mode (any call): accept visible but disabled → show "Ringing..." + // Desktop/WebRTC outdial: accept visible but disabled → show "Accept" (auto-answer handles it) + // Desktop/WebRTC inbound: accept visible and enabled → show "Accept" + const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); + const acceptText = accept.isVisible ? (showRinging ? 'Ringing...' : 'Accept') : undefined; - const declineText = !incomingTask.data.wrapUpRequired && isTelephony && isBrowser ? 'Decline' : undefined; + const declineText = decline.isVisible ? 'Decline' : undefined; // Compute title based on media type const title = isSocial ? customerName : ani; - // Compute disable state for accept button when auto-answering - const isAutoAnswering = incomingTask.data.isAutoAnswering || false; - // Compute disable state for accept button - const disableAccept = (isTelephony && !isBrowser) || isAutoAnswering; - - const disableDecline = (isTelephony && !isBrowser) || (isAutoAnswering && !isDeclineButtonEnabled); + const disableAccept = !accept.isEnabled; + const disableDecline = !decline.isEnabled; return { ani, diff --git a/packages/contact-center/cc-components/src/components/task/Task/task.utils.ts b/packages/contact-center/cc-components/src/components/task/Task/task.utils.ts index 3f4b9b86b..e37e875ab 100644 --- a/packages/contact-center/cc-components/src/components/task/Task/task.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/Task/task.utils.ts @@ -1,6 +1,43 @@ -import type {MEDIA_CHANNEL as MediaChannelType, TaskComponentData} from '../task.types'; +import type { + MEDIA_CHANNEL as MediaChannelType, + TaskComponentData, + CADVariable, + CallAssociatedDataMap, +} from '../task.types'; import {getMediaTypeInfo} from '../../../utils'; +/** System CAD variable keys that are already displayed elsewhere in the UI. */ +export const SYSTEM_CAD_KEYS = new Set([ + 'ani', + 'dn', + 'customerName', + 'virtualTeamName', + 'ronaTimeout', + 'FC-DESKTOP-VIEW', +]); + +/** + * Returns agent-viewable global variables from a callAssociatedData map, + * excluding system variables that are already rendered elsewhere. + */ +export const getAgentViewableGlobalVariables = ( + callAssociatedData: CallAssociatedDataMap | undefined +): CADVariable[] => { + if (!callAssociatedData || typeof callAssociatedData !== 'object') { + return []; + } + + return Object.entries(callAssociatedData) + .filter(([key, cadVar]) => { + if (!cadVar || !cadVar.name) return false; + if (cadVar.agentViewable === false) return false; + if (!cadVar.global) return false; + if (SYSTEM_CAD_KEYS.has(key)) return false; + return true; + }) + .map(([, cadVar]) => cadVar); +}; + /** * Capitalizes the first word of a string * @param str - The string to capitalize diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx index 73da269c6..4e2556616 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx @@ -12,7 +12,17 @@ import './styles.scss'; import {withMetrics} from '@webex/cc-ui-logging'; const TaskListComponent: React.FunctionComponent = (props) => { - const {currentTask, taskList, acceptTask, declineTask, isBrowser, onTaskSelect, logger, agentId} = props; + const { + currentTask, + taskList, + acceptTask, + declineTask, + onTaskSelect, + logger, + agentId, + isDeclineButtonEnabled, + isBrowser, + } = props; // Early return for empty task list if (isTaskListEmpty(taskList)) { @@ -25,7 +35,7 @@ const TaskListComponent: React.FunctionComponent = (prop
    {tasks.map((task, index) => { // Extract all task data using the utility function - const taskData = extractTaskListItemData(task, isBrowser, agentId, logger); + const taskData = extractTaskListItemData(task, agentId, logger, isDeclineButtonEnabled, isBrowser); // Log task rendering logger.info('CC-Widgets: TaskList: rendering task list', { diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts index aa1591ffc..919d82b1a 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts @@ -1,5 +1,5 @@ import {MEDIA_CHANNEL, TaskListItemData} from '../task.types'; -import store, {isIncomingTask, ILogger, ITask} from '@webex/cc-store'; +import {isIncomingTask, ILogger, ITask} from '@webex/cc-store'; /** * Extracts and processes data from a task for rendering in the task list * @param task - The task object @@ -8,15 +8,24 @@ import store, {isIncomingTask, ILogger, ITask} from '@webex/cc-store'; */ export const extractTaskListItemData = ( task: ITask, - isBrowser: boolean, agentId: string, - logger?: ILogger + logger?: ILogger, + isDeclineButtonEnabled?: boolean, + isBrowser?: boolean ): TaskListItemData => { try { + const accept = task.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; + const sdkDecline = task.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; + const decline = { + ...sdkDecline, + isEnabled: sdkDecline.isEnabled || !!isDeclineButtonEnabled, + }; + // Extract basic data from task - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const callAssociationDetails = task?.data?.interaction?.callAssociatedDetails; - const ani = callAssociationDetails?.ani; + const isOutdial = task?.data?.interaction?.outboundType === 'OUTDIAL'; + const dnis = callAssociationDetails?.dnis || task?.data?.interaction?.callProcessingDetails?.dnis; + const ani = isOutdial ? dnis || callAssociationDetails?.ani : callAssociationDetails?.ani; const customerName = callAssociationDetails?.customerName; const virtualTeamName = callAssociationDetails?.virtualTeamName; @@ -34,20 +43,19 @@ export const extractTaskListItemData = ( const isSocial = mediaType === MEDIA_CHANNEL.SOCIAL; // Compute button text based on conditions - const acceptText = isTaskIncoming ? (isTelephony && !isBrowser ? 'Ringing...' : 'Accept') : undefined; + // Extension mode (any call): accept visible but disabled → show "Ringing..." + // Desktop/WebRTC outdial: accept visible but disabled → show "Accept" (auto-answer handles it) + // Desktop/WebRTC inbound: accept visible and enabled → show "Accept" + const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); + const acceptText = accept.isVisible && isTaskIncoming ? (showRinging ? 'Ringing...' : 'Accept') : undefined; - const declineText = isTaskIncoming && isTelephony && isBrowser ? 'Decline' : undefined; + const declineText = decline.isVisible && isTaskIncoming ? 'Decline' : undefined; // Compute title based on media type const title = isSocial ? customerName : ani; - const isAutoAnswering = task.data.isAutoAnswering || false; - - // Compute disable state for accept button - const disableAccept = (isTaskIncoming && isTelephony && !isBrowser) || isAutoAnswering; - - const disableDecline = - (isTaskIncoming && isTelephony && !isBrowser) || (isAutoAnswering && !store.isDeclineButtonEnabled); + const disableAccept = !accept.isEnabled; + const disableDecline = !decline.isEnabled; const ronaTimeout = isTaskIncoming ? rawRonaTimeout : null; @@ -210,7 +218,7 @@ export const createTaskSelectHandler = ( return () => { try { // Logging moved to helper.ts - const taskData = extractTaskListItemData(task, true, agentId, logger); // Use browser=true for selection logic + const taskData = extractTaskListItemData(task, agentId, logger); if (isTaskSelectable(task, currentTask, taskData, logger)) { onTaskSelect(task); 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 aac63d7ca..ffd9bc201 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 @@ -12,10 +12,34 @@ import { Participant, AddressBookEntrySearchParams, AddressBookEntriesResponse, + TaskUIControls, } from '@webex/cc-store'; type Enum> = T[keyof T]; +/** + * Represents a single Call Associated Data (CAD) variable on an interaction. + * Global variables have `global: true` and are set by flow control. + */ +export interface CADVariable { + name: string; + displayName: string; + value: string; + type: string; + agentEditable: boolean; + agentViewable: boolean; + global: boolean; + isSecure: boolean; + secureKeyId: string; + secureKeyVersion: number; +} + +/** + * Record of CAD variables keyed by variable name. + * This is the shape of `callAssociatedData` on the interaction at runtime. + */ +export type CallAssociatedDataMap = Record; + /** * Target types for consult/transfer operations */ @@ -104,11 +128,6 @@ export interface TaskProps { * Function to handle task selection */ onTaskSelect: (task: ITask) => void; - /** - * Flag to determine if the user is logged in with a browser option - */ - isBrowser: boolean; - /** * Flag to determine if the task is answered */ @@ -119,11 +138,6 @@ export interface TaskProps { */ isEnded: boolean; - /** - * Selected login option - */ - deviceType: string; - /** * List of tasks */ @@ -138,20 +152,24 @@ export interface TaskProps { * Agent ID of the logged-in user */ agentId: string; - /** - * Flag to enable decline button on incoming task component - */ - isDeclineButtonEnabled?: boolean; } -export type IncomingTaskComponentProps = Pick & - Partial>; +export type IncomingTaskComponentProps = Pick & + Partial> & { + acceptControl?: {isVisible: boolean; isEnabled: boolean}; + declineControl?: {isVisible: boolean; isEnabled: boolean}; + isDeclineButtonEnabled?: boolean; + isBrowser?: boolean; + }; export type TaskListComponentProps = Pick< TaskProps, - 'isBrowser' | 'acceptTask' | 'declineTask' | 'onTaskSelect' | 'logger' | 'agentId' + 'acceptTask' | 'declineTask' | 'onTaskSelect' | 'logger' | 'agentId' > & - Partial>; + Partial> & { + isDeclineButtonEnabled?: boolean; + isBrowser?: boolean; + }; /** * Interface representing the properties for control actions on a task. @@ -245,11 +263,6 @@ export interface ControlProps { */ wrapupCall: (wrapupReason: string, wrapupId: string) => void; - /** - * Selected login option - */ - deviceType: string; - /** * Flag to determine if the task is held */ @@ -383,11 +396,6 @@ export interface ControlProps { */ holdTime: number; - /** - * Feature flags for the task. - */ - featureFlags: {[key: string]: boolean}; - /** * Custom CSS ClassName for CallControlCAD component. */ @@ -438,7 +446,7 @@ export interface ControlProps { */ setLastTargetType: (targetType: TargetType) => void; - controlVisibility: ControlVisibility; + controls: TaskUIControls; secondsUntilAutoWrapup?: number; @@ -475,6 +483,7 @@ export interface ControlProps { export type CallControlComponentProps = Pick< ControlProps, | 'currentTask' + | 'isHeld' | 'wrapupCodes' | 'toggleHold' | 'toggleRecording' @@ -509,7 +518,7 @@ export type CallControlComponentProps = Pick< | 'allowConsultToQueue' | 'lastTargetType' | 'setLastTargetType' - | 'controlVisibility' + | 'controls' | 'logger' | 'secondsUntilAutoWrapup' | 'cancelAutoWrapup' @@ -518,6 +527,7 @@ export type CallControlComponentProps = Pick< | 'getEntryPoints' | 'getQueuesFetcher' | 'consultTransferOptions' + | 'conferenceEnabled' >; export type OutdialAniEntry = { @@ -647,8 +657,9 @@ export interface CallControlConsultComponentsProps { switchToMainCall: () => void; logger: ILogger; isMuted: boolean; - controlVisibility: ControlVisibility; + controls: TaskUIControls; toggleConsultMute: () => void; + conferenceEnabled: boolean; } /** diff --git a/packages/contact-center/cc-components/src/wc.ts b/packages/contact-center/cc-components/src/wc.ts index 1553aab77..3f3d81b2a 100644 --- a/packages/contact-center/cc-components/src/wc.ts +++ b/packages/contact-center/cc-components/src/wc.ts @@ -79,7 +79,6 @@ if (!customElements.get('component-cc-call-control')) { const WebIncomingTask = r2wc(IncomingTaskComponent, { props: { incomingTask: 'json', - isBrowser: 'boolean', accept: 'function', reject: 'function', }, @@ -92,7 +91,6 @@ const WebTaskList = r2wc(TaskListComponent, { props: { currentTask: 'json', taskList: 'json', - isBrowser: 'boolean', acceptTask: 'function', declineTask: 'function', logger: 'function', diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/call-control-consult.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/call-control-consult.snapshot.tsx.snap index 7de54104d..5857d5d22 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/call-control-consult.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/call-control-consult.snapshot.tsx.snap @@ -39,7 +39,7 @@ exports[`CallControlConsultComponent Snapshots Interactions should call mockEndC class="consult-buttons consult-buttons-container" > -
    -

    - Mute -

    -
    -

    Switch to Call

    -
    -

    - Mute -

    -
    - -
    -

    - Mute -

    -
    -

    Switch to Call

    +
    +

    + Hold the call +

    +
    + +
    +

    + Consult with another agent +

    +
    +

    - Hold the call + Resume the call

    - Hold the call -

    -
    - -
    -

    - Consult with another agent -

    -
    - -
    -

    - Transfer Call -

    -
    - -
    -

    - Resume Recording -

    -
    - -
    -

    - End Call + End Call

    `; -exports[`CallControlComponent Snapshots Rendering - Tests for UI elements and visual states of CallControl component should render with limited control visibility 1`] = ` -
    -
    -
    -`; - exports[`CallControlComponent Snapshots Rendering - Tests for UI elements and visual states of CallControl component should render with muted state 1`] = `

    - Unmute -

    -
    - -
    -

    - Hold the call -

    -
    - -
    -

    - Consult with another agent -

    -
    - -
    -

    - Transfer Call -

    -
    - -
    -

    - Resume Recording -

    -
    - -
    -

    - End Call -

    -
    -
    - -`; - -exports[`CallControlComponent Snapshots Rendering - Tests for UI elements and visual states of CallControl component should render with recording state 1`] = ` -
    -
    - -
    -

    - Mute -

    -
    - -
    -

    - Hold the call -

    -
    - -
    -

    - Consult with another agent -

    -
    - -
    -

    - Transfer Call -

    -
    - -
    -

    - Pause Recording -

    -
    - -
    -

    - End Call -

    -
    -
    -
    -`; - -exports[`CallControlComponent Snapshots Rendering - Tests for UI elements and visual states of CallControl component should render with wrapup mode 1`] = ` -
    -
    - -
    -

    - Mute + Unmute

    - Unmute + Mute

    - Mute + Unmute

    - Hold the call + Resume the call

    - Resume Recording + Pause Recording

    -

    - End Voice + Transfer

    -
    -
    -
    - - - Queue: - - - - Support Team - - - - - Phone Number: - - - - No Phone Number - - -
    - -
    -
    -
    - -
    - - Consult Agent - - - Consulting -  •  - - 00:00 - - -
    -
    -
    +
    + +
    - No Phone Number + 555-123-4567
    @@ -447,7 +474,7 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control class="button-group" > -

    - End Voice + Transfer

    - - -
    - - - Queue: - - - - Support Team - - - - - Phone Number: - - - - No Phone Number - - -
    - -
    -
    -
    - -
    - - Consult Agent - - - Consulting -  •  - - 00:00 - - -
    -
    -
    -
    - -
    - No Phone Number + 555-123-4567
    @@ -2259,40 +2157,101 @@ exports[`CallControlCADComponent Snapshots should render consultation and wrapup class="md-text-wrapper call-timer" data-testid="cc-cad:call-timer" > - Voice - - - - 00:00 - - - -
    + Voice + - + + 00:00 + + +
    +
    +
    + + +
    + +
    +