diff --git a/.sdd/manifest.json b/.sdd/manifest.json index 17249044a..407b6681e 100644 --- a/.sdd/manifest.json +++ b/.sdd/manifest.json @@ -32,7 +32,7 @@ "package": "@webex/cc-store", "path": "packages/contact-center/store", "spec": "packages/contact-center/store/ai-docs/store-spec.md", - "responsibility": "MobX singleton holding global CC state; proxies SDK events; sole SDK access point.", + "responsibility": "MobX singleton holding global CC state; proxies SDK events; sole SDK access point; owns pure conference Drop roster derivation.", "coverageState": "DRAFT", "tier": 1 }, @@ -41,7 +41,7 @@ "package": "@webex/cc-components", "path": "packages/contact-center/cc-components", "spec": "packages/contact-center/cc-components/ai-docs/cc-components-spec.md", - "responsibility": "Shared presentational React UI primitives consumed by widget packages.", + "responsibility": "Shared presentational React UI primitives, including the CallControlCAD participant Drop experience.", "coverageState": "DRAFT", "tier": 1 }, @@ -50,7 +50,7 @@ "package": "@webex/cc-widgets", "path": "packages/contact-center/cc-widgets", "spec": "packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md", - "responsibility": "r2wc Web Component wrappers aggregating widget packages for framework-agnostic consumption.", + "responsibility": "r2wc Web Component wrappers aggregating widget packages; existing CallControlCAD wrapper inherits participant Drop without new properties.", "coverageState": "DRAFT", "tier": 1 }, @@ -86,7 +86,7 @@ "package": "@webex/cc-task", "path": "packages/contact-center/task", "spec": "packages/contact-center/task/ai-docs/task-spec.md", - "responsibility": "Task widget bundle: CallControl, CallControlCAD, IncomingTask, OutdialCall, TaskList.", + "responsibility": "Task widget bundle: CallControl, CallControlCAD with participant Drop orchestration, IncomingTask, OutdialCall, TaskList.", "coverageState": "DRAFT", "tier": 1, "subWidgets": ["CallControl", "CallControlCAD", "IncomingTask", "OutdialCall", "TaskList"] diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 11862cc93..9f70fe7f5 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -17,7 +17,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.UserState | `@webex/cc-user-state` | `UserState` | React component; custom element `widget-cc-user-state`; prop `onStateChange` (function) | stable semver | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | `packages/contact-center/user-state/src/index.ts` | | cc-widgets.IncomingTask | `@webex/cc-task` | `IncomingTask` | React component; custom element `widget-cc-incoming-task`; props `incomingTask` (json), `onAccepted`, `onRejected` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.CallControl | `@webex/cc-task` | `CallControl` | React component; custom element `widget-cc-call-control`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | -| cc-widgets.CallControlCAD | `@webex/cc-task` | `CallControlCAD` | React component; custom element `widget-cc-call-control-cad`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | +| cc-widgets.CallControlCAD | `@webex/cc-task` | `CallControlCAD` | React component; custom element `widget-cc-call-control-cad`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions); owner-aware participant Drop is store-driven and adds no prop/attribute | stable semver | `packages/contact-center/task/ai-docs/task-spec.md`; `ai-docs/features/participant-drop-intake.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.TaskList | `@webex/cc-task` | `TaskList` | React component; custom element `widget-cc-task-list`; props `onTaskAccepted`, `onTaskDeclined`, `onTaskSelected` (functions), `hasCampaignPreviewEnabled` (boolean) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.OutdialCall | `@webex/cc-task` | `OutdialCall` | React component; custom element `widget-cc-outdial-call`; no declared props | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | @@ -25,14 +25,14 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | | store.types | `@webex/cc-store` | Type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `AgentLogin`, `IStore`, `ILogger`, `InitParams`, `IWebex`, `RealTimeTranscriptionData`, plus ~20 more) | TypeScript `type`/`interface` exports describing the SDK-backed domain surface | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:334-366` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | -| store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | +| store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantDropRoster`, `isInteractionOnHold`, `findHoldStatus`, etc.) | Read-only selectors over SDK task objects; the Drop selector returns an owner-aware main-leg roster plus the current Entry Point/EP-DN consult destination (dialed number while ringing, answering Agent name before merge), or `null` for Customer-only/terminal calls | stable semver | `packages/contact-center/store/ai-docs/store-spec.md`; `ai-docs/features/participant-drop-intake.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | | ui-logging.WidgetMetrics | `@webex/cc-ui-logging` | `WidgetMetrics` (type) | `{ widgetName; event: 'WIDGET_MOUNTED' \| 'ERROR' \| 'WIDGET_UNMOUNTED' \| 'PROPS_UPDATED'; props?; timestamp; additionalContext? }` | stable semver; narrowing the `event` union or removing a field is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store (`packages/contact-center/store/src/storeEventsWrapper.ts`) | Host establishes the authenticated Webex session; SDK assumed reachable | `Store.init()` rejects after a 6s init timeout; widgets stay inert and surface error UI (`packages/contact-center/store/src/store.ts:140-142`) | Pinned by the SDK dependency in each package's `package.json` | +| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, `ITask.dropConferenceParticipant({participantId})`, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store/task object | Host establishes the authenticated Webex session; SDK and routing backend are assumed reachable and authoritative | `Store.init()` rejects after a 6s init timeout; participant Drop rejects and `CallControlCAD` shows generic feedback | Pinned by the SDK dependency in each package's `package.json`; Participant Drop requires the published SDK version recorded in the feature intake | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/SECURITY.md b/ai-docs/SECURITY.md index bf164274d..0b1dd325c 100644 --- a/ai-docs/SECURITY.md +++ b/ai-docs/SECURITY.md @@ -18,7 +18,7 @@ This repo is a client-side React/Web-Component widget library. It hosts no netwo ## Authentication & Authorization Model - **Authentication:** Owned by the host app and the Webex SDK, not this repo. The host supplies either a live `webex` instance or `{webexConfig, access_token}`; the store passes the token to `Webex.init({credentials: {access_token}})` and otherwise treats identity as opaque (`packages/contact-center/store/src/store.ts:144-151`). Token retrieval for downstream SDK features delegates to the SDK: `getAccessToken()` calls `webex.credentials.getUserToken()` (`packages/contact-center/store/src/storeEventsWrapper.ts:988-998`). -- **Authorization:** Owned by the SDK / back end. The store surfaces the agent's capabilities as read-only feature flags derived from the SDK-provided `Profile` (`packages/contact-center/store/src/util.ts:3-36`); widgets use these only to show/hide UI. There is no access-decision logic enforced in this repo. +- **Authorization:** Owned by the SDK / back end. The store surfaces the agent's capabilities as read-only feature flags derived from the SDK-provided `Profile` (`packages/contact-center/store/src/util.ts:3-36`); widgets use these only to show/hide UI. Participant Drop additionally hides actions unless `interaction.owner === currentAgentId`, but that client rule is not an authorization boundary; the backend must authorize every request. - **Default posture:** Widgets are inert until the host completes `Store.init()`; with no valid host-supplied session the SDK never initializes (`Webex.init` rejects after a 6s timeout — `packages/contact-center/store/src/store.ts:140-142`), so no agent data flows. ## Secret & Credential Handling @@ -42,6 +42,7 @@ This repo is a client-side React/Web-Component widget library. It hosts no netwo |---|---|---|---| | `ui-logging` metrics props | Widget props are logged without sanitization — acknowledged in the `havePropsChanged` JSDoc `@remarks` (`metricsLogger.ts:73-76`) | Callers must not pass PII-bearing objects as metrics props; sanitization is noted as a future enhancement | cc-ui-logging maintainers | | `getAccessToken()` SDK gap | `webex.credentials.getUserToken()` is `@ts-expect-error`-typed (SDK API not yet typed) (`storeEventsWrapper.ts:990-992`) | Token value is returned to the caller and never logged; failures log only an error message | cc-store maintainers | +| Participant Drop identity | Target IDs can be agent identifiers or customer ANI/DNIS and therefore may be sensitive | Derive in memory, pass only to `task.dropConferenceParticipant`, render through React text escaping, and log only static context plus generic feedback | cc-store / cc-task maintainers | ## Reporting & Review - Security-relevant changes (anything touching the store init/credential path, the `@webex/contact-center` SDK boundary, logging, or the public export/custom-element surface) require review by the package CODEOWNERS on the `next`-targeted PR, following `.github/PULL_REQUEST_TEMPLATE.md` (FedRAMP/GAI sections). Suspected vulnerabilities: report through the Webex internal security channel, not a public issue. diff --git a/ai-docs/SPEC_INDEX.md b/ai-docs/SPEC_INDEX.md index e8124b429..a7bfdb460 100644 --- a/ai-docs/SPEC_INDEX.md +++ b/ai-docs/SPEC_INDEX.md @@ -9,13 +9,13 @@ ## Module Registry | Module | Responsibility | Manifest coverage state | Start here | |---|---|---|---| -| `store/` | MobX singleton; global CC state; proxies SDK events; sole SDK access point | DRAFT | `packages/contact-center/store/ai-docs/store-spec.md` | -| `cc-components/` | Shared presentational React UI primitives | DRAFT | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | -| `cc-widgets/` | r2wc Web Component wrappers (aggregator) | DRAFT | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | +| `store/` | MobX singleton; global CC state; SDK event proxy; pure conference Drop roster derivation | DRAFT | `packages/contact-center/store/ai-docs/store-spec.md` | +| `cc-components/` | Shared presentational React UI primitives, including the CallControlCAD Drop experience | DRAFT | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | +| `cc-widgets/` | r2wc Web Component wrappers; existing CallControlCAD wrapper inherits Drop behavior | DRAFT | `packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md` | | `cc-digital-channels/` | Digital channels (chat/email/social) widget | DRAFT | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | | `station-login/` | Agent login: team + device selection | DRAFT | `packages/contact-center/station-login/ai-docs/station-login-spec.md` | | `user-state/` | Agent state: state, idle codes, timer | DRAFT | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | -| `task/` | Task widget bundle: CallControl, IncomingTask, OutdialCall, TaskList, CallControlCAD | DRAFT | `packages/contact-center/task/ai-docs/task-spec.md` | +| `task/` | Task widget bundle; CallControlCAD owns participant Drop orchestration | DRAFT | `packages/contact-center/task/ai-docs/task-spec.md` | | `ai-assistant/` | AI Assistant widget: chrome + Real-time Assist requests, transcript, feedback | DRAFT | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | | `ui-logging/` | Metrics/telemetry: `withMetrics`, `metricsLogger` | DRAFT | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | | `test-fixtures/` | Shared test mocks/helpers | DRAFT | `packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md` | @@ -67,5 +67,6 @@ The intake record confirms scope/modules **against the code** and sets the chang | Decision records | `ai-docs/adr/` | Standing ADRs — why the architecture is the way it is | | Review catalog | `ai-docs/REVIEW_CHECKLIST.md` | 6-core + 4-coverage + 3-cross-cutting review checks | | SDK reference | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | installed SDK `.d.ts` surface — verify every SDK call | +| Participant Drop intake | `ai-docs/features/participant-drop-intake.md` | Cross-repository SDK/widget contract, behavior, delivery gates, and verification | _No `DATA_MODEL.md`: this repo owns no persistent datastore (all domain data comes from the SDK at runtime)._ diff --git a/ai-docs/features/participant-drop-intake.md b/ai-docs/features/participant-drop-intake.md new file mode 100644 index 000000000..f3a4b48d3 --- /dev/null +++ b/ai-docs/features/participant-drop-intake.md @@ -0,0 +1,73 @@ +# Participant Drop Cross-Repository Intake + +Status: Implemented and verified locally on `feature/participant-drop`; live widget validation and SDK publication remain release gates. + +## Goal and delivery order + +Add owner-controlled participant removal to the Contact Center conference roster. Delivery is intentionally split into two sequential changes based on `next`: + +1. `webex-js-sdk` adds the SDK task API, AQM correlation, tests, and Contact Center sample behavior. +2. `widgets-1` consumes the published SDK API and adds Drop only to `CallControlCAD`. + +The remote routing/media backend and Agent Desktop are outside these repositories. No SDK initialization flag, widget flag, or new public failure event is introduced. + +## SDK contract + +```ts +export type DropConferenceParticipantPayload = { + participantId: string; +}; + +task.dropConferenceParticipant( + payload: DropConferenceParticipantPayload +): Promise; +``` + +The voice task resolves the latest main interaction ID and delegates to the existing AQM contact service: + +```http +POST /v1/tasks/{interactionId}/conference/participants/{encodeURIComponent(participantId)}/drop +Content-Type: application/json + +{} +``` + +Completion is event-correlated: `ParticipantLeftConference` resolves, `ParticipantDropConferenceFailed` rejects, and the existing 20-second AQM timeout applies. Existing participant-left task handling updates conference state and the participant roster; clients do not remove rows optimistically. Non-voice tasks reject as unsupported. + +For EP-DN cross-channel lifecycle ordering, `ContactMerged` replaces the child task with the main-interaction task and publishes `task:merged`. `ParticipantLeftConference` and `AgentConsultEnded` first use exact task correlation, then one unique `mainInteractionId`/`parentInteractionId` relationship. Updated `mainCall` membership is authoritative for whether the current Agent ended, independent of consult state or initiator role. Widgets consume the existing terminal events and defer their task-list read by one microtask so SDK final cleanup is visible; they never delete or terminate an SDK task locally. + +## Widget behavior + +`@webex/cc-store` derives a Drop-specific roster from the current main-call media leg without changing `getConferenceParticipants` for its existing consumers. The viewing agent must remain active, and any supported non-customer row keeps the roster visible. Main-leg membership remains authoritative when conference state, flags, controls, or wrap-up signals lag behind participant updates. + +- Exclude the viewing agent, departed/not-yet-joined participants, VVA, unsupported types, and consult-only participants. +- Include Agent and joined EP-DN rows; include Supervisor rows as read-only. +- Synthesize Customer only while an active main-leg Customer exists, using inbound ANI or outbound DNIS. +- After Customer leaves, retain Participants while at least one eligible Agent, EP-DN, or Supervisor remains with the viewing agent, even if backend conference state, flags, controls, or wrap-up signals downgrade. +- Customer-only calls use the original 1-to-1 UI. Dropping the final non-customer participant while Customer remains returns to that UI; Agent-to-Agent remains visible so the primary owner can Drop the other Agent. +- Merge only the current active Entry Point/EP-DN consult leg. Classify the destination from both SDK `pType` and `type` because Entry Point IDs can occupy `pType` while `type` carries `EpDn`. While ringing, display `dn` with participant/media ID fallback; once answered, replace the dialed number with the answering Agent name even before merge. Exclude stale legs and ordinary consult-only Agents, deduplicate after main-leg join, and keep the owner action disabled until merge. +- Show Drop only when `interaction.owner === currentAgentId`; Supervisor always remains read-only. +- Disable every Drop action during an active, non-held consult. +- Agent and joined EP-DN drop immediately; a ringing Entry Point/EP-DN or its pre-merge answering Agent is visible but disabled; Customer requires confirmation. +- Permit one request at a time, display `Dropping…` on only the selected row, and wait for SDK task hydration before a row disappears. +- On success announce `Participant removed from the conference.` +- On failure announce `Unable to drop participant from the call. Try again.` and invoke the existing `CallControlCAD` host error callback with a sanitized error. + +Backend authorization remains mandatory. Owner-based UI visibility is not an authorization boundary. + +## Public surfaces and compatibility + +Only the existing React `CallControlCAD` export and `widget-cc-call-control-cad` custom element gain behavior. Standard `CallControl` remains visually unchanged. No widget prop, custom-element attribute/property, initialization option, or callback is added. + +The widget change must use a local, untracked SDK link during development. Before PR readiness, replace `@webex/contact-center@3.12.0-next.96` in `@webex/cc-store` with the exact published SDK `next` version, update `yarn.lock`, remove the local link, and verify from a clean install. Absolute paths, portals, tarballs, and temporary resolutions must never be committed. + +## Verification + +- SDK: build, unit, and style tests plus the Contact Center sample on port 8001. +- Widgets: build, store selector tests, task hook/shell tests, component tests, cc-widgets tests, and style checks. +- Local samples: React on port 3000 and Web Component on port 4000. +- Manual coverage: owner/non-owner, Customer/Agent/EP-DN/Supervisor, confirmation and cancellation, consult gating, global pending lock, failure recovery, owner transfer, and final conference downgrade. + +## Security and privacy + +Participant IDs, names, ANI/DNIS, raw task/routing payloads, request URLs, credentials, and authorization headers must not enter widget logs or metrics. React text rendering is used for participant-derived display values. Failure reporting carries only the generic message and static module/method context. diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 7f8cc7078..60c784de6 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -102,7 +102,7 @@ Consumed as an imported SDK/code API. The React barrel (`src/index.ts`) is the p | `cc-components.StationLoginComponent` | SDK | `StationLoginComponent` (`StationLoginComponentProps`) | Agent login: device/team selection, login/logout, multiple-login alert, profile mode | semver; props are `Pick`ed — adding optional props = minor, removing/renaming a picked prop = major | `src/components/StationLogin/station-login.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.UserStateComponent` | SDK | `UserStateComponent` (`UserStateComponentsProps`) | Agent state dropdown + idle codes + state timer | semver as above | `src/components/UserState/user-state.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlComponent` | SDK | `CallControlComponent` (`CallControlComponentProps`) | Call control buttons: hold/resume, mute, record, end, wrapup, consult/transfer/conference | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header, CAD global variables, and owner-aware conference participant Drop | semver as above; internal Drop props add no widget or Web Component surface | `src/components/task/task.types.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.OutdialCallComponent` | SDK | `OutdialCallComponent` (`OutdialCallComponentProps`) | Outbound dialpad, ANI selection, address-book search | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -149,6 +149,7 @@ Compatibility notes: | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | | `CC-COMPONENTS-R-016` | `E911Modal` gates `Save & Continue` on the acknowledgment checkbox, disables both `Save & Continue` and `Cancel` while `onSaveAndContinue` is in flight (guarding against a double-click firing concurrent saves), shows a user-facing error and re-enables the buttons if the save rejects, and only `Cancel` (not the Dialog's built-in close button or Escape) dismisses the modal; checkbox/saving/error state resets when the modal closes. | An emergency-notification acknowledgment must not be skippable, must not double-submit against the preference API, and must give the agent visible recourse on failure. | `src/components/StationLogin/E911Modal/e911-modal.tsx` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (checkbox gating, save-in-flight button disabling, save-error display, close-only-via-Cancel) | None | PRESENT | +| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. Agent/merged EP-DN Drop immediately, Customer uses a native confirmation dialog, pending/consult state globally protects requests, focus is restored, and exact polite success/assertive failure feedback stays outside the roster. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | ## Design Overview @@ -156,7 +157,7 @@ Every component follows the same shape: a typed function component destructures Logic that is non-trivial or shared is pulled out of the JSX: per-component utils (`station-login.utils.tsx`, `call-control.utils.ts`, `task-list.utils.ts`, etc.) and library-wide utils (`src/utils/index.ts`: `formatTime`, `getMediaTypeInfo`). `task.types.ts` is the shared type hub for the task family — the larger `ControlProps`/`TaskProps` interfaces describe the full data set, and each component's public prop type is a `Pick` of the keys it actually uses, which is why the public surface is intentionally narrower than the interfaces. -Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header + `GlobalVariablesPanel`; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes the same components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual registration into a host app happens in `cc-widgets`. +Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header, `GlobalVariablesPanel`, and the prop-driven participant Drop roster/dialog; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual widget registration into a host app happens in `cc-widgets`. ## Data Flow @@ -342,6 +343,7 @@ Each component is tested in isolation with React Testing Library: render from a | `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | | `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | | `CC-COMPONENTS-R-016` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` | None | +| `CC-COMPONENTS-R-017` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (sections, immediate/confirmed Drop, pending/consult disabled state, read-only rows, focus, live regions) | Native browser dialog behavior receives final manual coverage | ## Traceability 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 19ec06a16..4d05db570 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 @@ -122,6 +122,7 @@ .participants-section { display: flex; align-items: center; + flex: 0 0 auto; gap: 0.25rem; } @@ -139,9 +140,12 @@ .participants-select-button { display: inline-flex; align-items: center; + flex: 0 0 auto; gap: 0.3rem; padding: 0 0.4rem; min-height: 1.5rem; + width: max-content; + white-space: nowrap; border-radius: 999px; background: transparent !important; border: 1px solid var(--mds-color-theme-outline-secondary-normal) !important; @@ -153,15 +157,21 @@ } } -.dropdown-arrow { +.participants-select-button::part(button-text) { + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +.participants-select-button::part(postfix-icon) { width: 0.75rem; height: 0.75rem; color: var(--mds-color-theme-text-secondary-normal); - margin-top: 0.0625rem; + flex: 0 0 auto; transition: transform 0.15s ease; } -.participants-select-button[aria-expanded='true'] .dropdown-arrow { +.participants-select-button[aria-expanded='true']::part(postfix-icon) { transform: rotate(180deg); } @@ -317,42 +327,148 @@ } .participants-menu { - padding: 0.125rem; - max-height: 12.5rem; + padding: 0.5rem; + max-height: 18rem; min-width: 16.125rem; max-width: 16.125rem; overflow-y: auto; overflow-x: hidden; } + .participant-roster-section + .participant-roster-section { + border-top: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); + margin-top: 0.5rem; + padding-top: 0.5rem; + } + + .participant-roster-heading { + margin: 0 0 0.25rem; + padding: 0 0.25rem; + font-weight: 700; + } + + .participant-roster-list { + list-style: none; + margin: 0; + padding: 0; + } + .participant-menu-item { display: flex; align-items: center; gap: 0.5rem; - padding: 0.4rem 0.6rem; + padding: 0.5rem 0.25rem; font-size: 0.875rem; - cursor: pointer; color: var(--mds-color-theme-text-primary-normal, #000000f2); border-radius: 0.5rem; min-width: 0; max-width: 100%; + } + + .participant-menu-identity { + flex: 1 1 auto; + min-width: 0; + } + + .participant-menu-text { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + } - &:hover { - background-color: var(--mds-color-theme-background-secondary-hover); + .participant-drop-button { + background: transparent; + border: 0.0625rem solid var(--mds-color-theme-outline-button-normal); + border-radius: 1rem; + color: var(--mds-color-theme-text-error-normal); + cursor: pointer; + flex: 0 0 auto; + font: inherit; + padding: 0.25rem 0.625rem; + + &:hover:not(:disabled) { + background: var(--mds-color-theme-background-alert-error-normal); } &:focus-visible { - background-color: var(--mds-color-theme-background-secondary-normal); - outline: 0.0625rem solid var(--mds-color-theme-outline-input-focus); - outline-offset: -0.0625rem; + outline: 0.125rem solid var(--mds-color-theme-outline-input-focus); + outline-offset: 0.125rem; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; } } +} - .participant-menu-text { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; +.participant-drop-dialog { + background: var(--mds-color-theme-background-solid-primary-normal); + border: 0.0625rem solid var(--mds-color-theme-outline-secondary-normal); + border-radius: 0.75rem; + color: var(--mds-color-theme-text-primary-normal); + max-width: min(25rem, calc(100vw - 2rem)); + padding: 1.5rem; + + &::backdrop { + background: var(--mds-color-theme-background-overlay, rgb(0 0 0 / 45%)); + } + + h2, + p { + margin: 0; + } + + p { + margin-top: 0.75rem; + } +} + +.participant-drop-dialog-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1.5rem; + + button { + border: 0.0625rem solid var(--mds-color-theme-outline-button-normal); + border-radius: 1rem; + cursor: pointer; + font: inherit; + padding: 0.5rem 1rem; + + &:focus-visible { + outline: 0.125rem solid var(--mds-color-theme-outline-input-focus); + outline-offset: 0.125rem; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + } +} + +.participant-drop-dialog-cancel { + background: var(--mds-color-theme-background-solid-primary-normal); + color: var(--mds-color-theme-text-primary-normal); +} + +.participant-drop-dialog-confirm { + background: var(--mds-color-theme-background-alert-error-normal); + color: var(--mds-color-theme-text-error-normal); +} + +.participant-drop-feedback { + p { + margin: 0.5rem 0 0; + } + + [role='status'] { + color: var(--mds-color-theme-text-success-normal); + } + + [role='alert'] { + color: var(--mds-color-theme-text-error-normal); } } 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 a841f5cb1..481b4b1ce 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 @@ -1,4 +1,4 @@ -import React, {useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import CallControlComponent from '../CallControl/call-control'; import {Text, PopoverNext} from '@momentum-ui/react-collaboration'; import {Avatar, Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; @@ -6,6 +6,7 @@ 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, CallAssociatedDataMap} from '../task.types'; +import {ConferenceParticipantDropTarget} from '@webex/cc-store'; import {getAgentViewableGlobalVariables} from '../Task/task.utils'; import GlobalVariablesPanel from '../GlobalVariablesPanel/global-variables-panel'; @@ -22,7 +23,6 @@ import { CAMPAIGN_CALL, } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; -import {isSecondaryAgent} from '@webex/cc-store'; const CallControlCADComponent: React.FC = (props) => { const { @@ -46,7 +46,10 @@ const CallControlCADComponent: React.FC = (props) => logger, isMuted, toggleMute, - conferenceParticipants, + conferenceParticipantDropRoster = null, + pendingParticipantDropId = null, + participantDropAnnouncement = null, + dropConferenceParticipant = async () => undefined, conferenceEnabled = true, isCampaignCall = false, } = props; @@ -65,16 +68,17 @@ const CallControlCADComponent: React.FC = (props) => const mediaChannel = currentTask.data.interaction.mediaType as MediaChannelType; const isSocial = mediaChannel === MediaChannelType.SOCIAL; const isTelephony = mediaChannel === MediaChannelType.TELEPHONY; - const participantsCount = conferenceParticipants?.length || 1; + const participantsCount = conferenceParticipantDropRoster?.participants.length ?? 0; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; - const interactionState = currentTask?.data?.interaction?.state; - const isConferenceActive = - controls?.main?.exitConference?.isVisible || - currentTask?.data?.isConferenceInProgress === true || - interactionState === 'conference'; - const isConsultOnlyAgent = isSecondaryAgent(currentTask); - const shouldShowParticipantsList = - isConferenceActive && !isConsultOnlyAgent && (conferenceParticipants?.length ?? 0) > 0; + const shouldShowParticipantsList = Boolean(conferenceParticipantDropRoster); + const [customerDropTarget, setCustomerDropTarget] = useState(null); + const customerDropDialogRef = useRef(null); + const customerDropTriggerRef = useRef(null); + const participantMenuTriggerRef = useRef(null); + const customerDropConfirmRef = useRef(null); + const participantDropIsPending = pendingParticipantDropId !== null; + const interactionId = currentTask.data.interaction.interactionId; + const customerDropDialogTitleId = `participant-drop-dialog-title-${interactionId}`; const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; @@ -96,7 +100,6 @@ const CallControlCADComponent: React.FC = (props) => // variables. Variables are never cleared mid-call by the backend. // Reset when the interaction changes so stale CAD from a previous task // is never shown on a new call. - const interactionId = currentTask.data.interaction.interactionId; const globalVariablesRef = useRef(latestGlobalVariables); const prevInteractionIdRef = useRef(interactionId); if (prevInteractionIdRef.current !== interactionId) { @@ -107,6 +110,146 @@ const CallControlCADComponent: React.FC = (props) => } const globalVariables = globalVariablesRef.current; + const latestCustomerDropTarget = + customerDropTarget && conferenceParticipantDropRoster?.customer?.dropTargetId === customerDropTarget.dropTargetId + ? conferenceParticipantDropRoster.customer + : null; + const customerDropConfirmationDisabled = Boolean( + participantDropIsPending || + conferenceParticipantDropRoster?.isDropDisabled || + !latestCustomerDropTarget || + latestCustomerDropTarget.isReadOnly || + latestCustomerDropTarget.isDropDisabled + ); + + const restoreCustomerDropFocus = () => { + const focusTarget = customerDropTriggerRef.current?.isConnected + ? customerDropTriggerRef.current + : participantMenuTriggerRef.current; + + focusTarget?.focus(); + customerDropTriggerRef.current = null; + }; + + const closeCustomerDropDialog = (restoreFocus = true) => { + const dialog = customerDropDialogRef.current; + + if (dialog?.open && typeof dialog.close === 'function') { + dialog.close(); + } else { + dialog?.removeAttribute('open'); + } + + setCustomerDropTarget(null); + + if (restoreFocus) { + restoreCustomerDropFocus(); + } + }; + + useEffect(() => { + const dialog = customerDropDialogRef.current; + + if (!customerDropTarget || !dialog) { + return; + } + + if (!dialog.open) { + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + dialog.setAttribute('open', ''); + } + } + + customerDropConfirmRef.current?.focus(); + }, [customerDropTarget]); + + useEffect(() => { + closeCustomerDropDialog(false); + customerDropTriggerRef.current = null; + }, [interactionId]); + + useEffect(() => { + if (customerDropTarget && !latestCustomerDropTarget) { + closeCustomerDropDialog(); + } + }, [customerDropTarget, latestCustomerDropTarget]); + + const handleParticipantDrop = (target: ConferenceParticipantDropTarget, trigger: HTMLElement) => { + if ( + target.isReadOnly || + target.isDropDisabled || + participantDropIsPending || + conferenceParticipantDropRoster?.isDropDisabled + ) { + return; + } + + if (target.requiresConfirmation) { + customerDropTriggerRef.current = trigger; + setCustomerDropTarget(target); + return; + } + + void dropConferenceParticipant(target); + }; + + const confirmCustomerDrop = () => { + const target = latestCustomerDropTarget; + + if (!target || customerDropConfirmationDisabled) { + return; + } + + closeCustomerDropDialog(); + void dropConferenceParticipant(target); + }; + + const renderParticipantSection = ( + heading: 'Customer' | 'Participants', + targets: ConferenceParticipantDropTarget[] + ) => ( +

+

+ {heading} +

+ +
+ ); + // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; const customerNameTooltipId = `customer-name-tooltip-${currentTask.data.interaction.interactionId}`; @@ -226,7 +369,7 @@ const CallControlCADComponent: React.FC = (props) => )} - {shouldShowParticipantsList && !controls?.main?.wrapup?.isVisible && ( + {shouldShowParticipantsList && ( <>
@@ -243,32 +386,28 @@ const CallControlCADComponent: React.FC = (props) => triggerComponent={ } >
- {conferenceParticipants?.map((participant) => ( -
- - {participant.name} -
- ))} + {conferenceParticipantDropRoster?.customer && + renderParticipantSection('Customer', [conferenceParticipantDropRoster.customer])} + {conferenceParticipantDropRoster?.participants.length + ? renderParticipantSection('Participants', conferenceParticipantDropRoster.participants) + : null}
@@ -306,6 +445,48 @@ const CallControlCADComponent: React.FC = (props) => + {customerDropTarget && ( + { + event.preventDefault(); + closeCustomerDropDialog(); + }} + > +

Drop customer from conference?

+

The customer will be removed from this conference. The remaining participants can continue the call.

+
+ + +
+
+ )} + {participantDropAnnouncement && ( +
+ {participantDropAnnouncement.type === 'success' ? ( +

+ {participantDropAnnouncement.message} +

+ ) : ( +

+ {participantDropAnnouncement.message} +

+ )} +
+ )} {(controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) && !controls?.main?.wrapup?.isVisible && (
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 6192837be..0ef4f4a29 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 @@ -10,6 +10,8 @@ import { EntryPointRecord, FetchPaginatedList, Participant, + ConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, AddressBookEntrySearchParams, AddressBookEntriesResponse, TaskUIControls, @@ -53,6 +55,11 @@ export const TARGET_TYPE = { export type TargetType = (typeof TARGET_TYPE)[keyof typeof TARGET_TYPE]; +export type ParticipantDropAnnouncement = { + type: 'success' | 'error'; + message: string; +}; + /** * Interface representing the TaskProps of a user. */ @@ -497,6 +504,18 @@ export interface ControlProps { */ conferenceParticipants: Participant[]; + /** Owner-aware conference roster used by the CallControlCAD Drop menu. */ + conferenceParticipantDropRoster: ConferenceParticipantDropRoster | null; + + /** Drop target currently waiting for routing-event completion. */ + pendingParticipantDropId: string | null; + + /** Generic screen-reader and visible feedback for the most recent Drop request. */ + participantDropAnnouncement: ParticipantDropAnnouncement | null; + + /** Requests removal of one currently eligible conference participant. */ + dropConferenceParticipant: (target: ConferenceParticipantDropTarget) => Promise; + /** Fetch paginated address book entries for dial numbers */ getAddressBookEntries?: FetchPaginatedList; @@ -565,14 +584,23 @@ export type CallControlComponentProps = Pick< | 'getQueuesFetcher' | 'consultTransferOptions' | 'conferenceEnabled' -> & { - /** - * Whether the current task is an accepted campaign preview call. - * When `true`, the header renders the campaign icon and - * "Campaign call" label instead of the standard media type. - */ - isCampaignCall?: boolean; -}; +> & + Partial< + Pick< + ControlProps, + | 'conferenceParticipantDropRoster' + | 'pendingParticipantDropId' + | 'participantDropAnnouncement' + | 'dropConferenceParticipant' + > + > & { + /** + * Whether the current task is an accepted campaign preview call. + * When `true`, the header renders the campaign icon and + * "Campaign call" label instead of the standard media type. + */ + isCampaignCall?: boolean; + }; export type OutdialAniEntry = { /** Unique identifier for the ANI entry */ diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8363fb2ec..0713ffbe0 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -161,6 +161,18 @@ describe('CallControlComponent', () => { jest.restoreAllMocks(); }); describe('Rendering', () => { + it('does not render the CallControlCAD participant roster surface', () => { + const screen = render( + + ); + + expect(screen.queryByTestId('call-control:participants-trigger')).not.toBeInTheDocument(); + }); + it('renders mute and hold buttons and responds to user interactions', async () => { const modifiedProps = { ...defaultProps, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index dd4888597..d14b12677 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {render} from '@testing-library/react'; +import {fireEvent, render, waitFor} from '@testing-library/react'; import CallControlCADComponent from '../../../../src/components/task/CallControlCAD/call-control-cad'; import {CallControlComponentProps, TARGET_TYPE, OUTBOUND_TYPE} from '../../../../src/components/task/task.types'; import { @@ -8,9 +8,21 @@ import { createMockTaskUIControls, enabledControl, } from '@webex/test-fixtures'; -import {BuddyDetails} from '@webex/cc-store'; +import {BuddyDetails, ConferenceParticipantDropRoster} from '@webex/cc-store'; import '@testing-library/jest-dom'; +jest.mock('@momentum-ui/react-collaboration', () => { + const actual = jest.requireActual('@momentum-ui/react-collaboration'); + const react = jest.requireActual('react'); + + const popover = ({triggerComponent, children}: {triggerComponent: React.ReactNode; children: React.ReactNode}) => + react.createElement(react.Fragment, null, triggerComponent, children); + + return new Proxy(actual, { + get: (target, property) => (property === 'PopoverNext' ? popover : Reflect.get(target, property)), + }); +}); + // Mock MediaStream for testing Object.defineProperty(window, 'MediaStream', { writable: true, @@ -89,6 +101,29 @@ describe('CallControlCADComponent', () => { ]; const mockControls = createEnabledMainTaskUIControls(); + const ownerDropRoster: ConferenceParticipantDropRoster = { + customer: { + participantType: 'Customer', + displayName: '+15551234567', + dropTargetId: '+15551234567', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: true, + }, + participants: [ + { + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent-2', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }; const defaultProps: CallControlComponentProps = { currentTask: mockCurrentTask, @@ -137,6 +172,18 @@ describe('CallControlCADComponent', () => { beforeEach(() => { jest.clearAllMocks(); + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value: function showModal() { + this.setAttribute('open', ''); + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, 'close', { + configurable: true, + value: function close() { + this.removeAttribute('open'); + }, + }); }); it('should render telephony call control with all basic information', () => { @@ -415,6 +462,237 @@ describe('CallControlCADComponent', () => { }); }); + describe('conference participant Drop', () => { + const renderRoster = (overrides: Partial = {}) => { + const dropConferenceParticipant = jest.fn().mockResolvedValue(undefined); + const screen = render( + + ); + + fireEvent.click(screen.getByTestId('call-control:participants-trigger')); + + return {screen, dropConferenceParticipant}; + }; + + it('renders Customer and Participants sections and immediately drops an Agent target', () => { + const {screen, dropConferenceParticipant} = renderRoster(); + const participantsTrigger = screen.getByTestId('call-control:participants-trigger'); + + expect(participantsTrigger).toHaveAttribute('postfix-icon', 'arrow-down-bold'); + expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(screen.getByText('+15551234567')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'Drop agent Agent Two'})); + + expect(dropConferenceParticipant).toHaveBeenCalledWith(ownerDropRoster.participants[0]); + }); + + it('keeps the Participants section when the Customer row is removed', () => { + const {screen} = renderRoster({ + conferenceParticipantDropRoster: { + ...ownerDropRoster, + customer: null, + participants: [ + ...ownerDropRoster.participants, + { + participantType: 'Agent', + displayName: 'Agent Three', + dropTargetId: 'agent-3', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + }, + }); + + expect(screen.queryByRole('heading', {name: 'Customer'})).not.toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + expect(screen.getByText('Agent Three')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeEnabled(); + }); + + it('keeps a valid multiparty roster visible when wrap-up controls appear', () => { + const {screen} = renderRoster({ + controls: createEnabledMainTaskUIControls({wrapup: {isVisible: true, isEnabled: true}}), + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Customer'})).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Participants'})).toBeInTheDocument(); + }); + + it('requires confirmation for Customer Drop and cancel restores focus without invoking the SDK callback', async () => { + const {screen, dropConferenceParticipant} = renderRoster(); + const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); + + fireEvent.click(customerDropButton); + + const dialog = screen.getByTestId('call-control:customer-drop-dialog'); + expect(dialog).toHaveAttribute('open'); + expect(screen.getByRole('heading', {name: 'Drop customer from conference?'})).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); + + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + await waitFor(() => expect(customerDropButton).toHaveFocus()); + }); + + it('confirms Customer Drop and supports Escape cancellation', () => { + const {screen, dropConferenceParticipant} = renderRoster(); + const customerDropButton = screen.getByRole('button', {name: 'Drop customer +15551234567'}); + + fireEvent.click(customerDropButton); + fireEvent.click(screen.getByRole('button', {name: 'Drop'})); + expect(dropConferenceParticipant).toHaveBeenCalledWith(ownerDropRoster.customer); + + dropConferenceParticipant.mockClear(); + fireEvent.click(customerDropButton); + const dialog = screen.getByTestId('call-control:customer-drop-dialog'); + fireEvent(dialog, new Event('cancel', {cancelable: true})); + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + }); + + it('globally disables Drop controls and shows loading only on the selected row', () => { + const {screen} = renderRoster({pendingParticipantDropId: 'agent-2'}); + + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toHaveTextContent('Dropping…'); + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Drop customer +15551234567'})).toBeDisabled(); + }); + + it('disables all owner Drop controls during an active non-held consult', () => { + const {screen} = renderRoster({ + conferenceParticipantDropRoster: {...ownerDropRoster, isDropDisabled: true}, + }); + + expect(screen.getByRole('button', {name: 'Drop agent Agent Two'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Drop customer +15551234567'})).toBeDisabled(); + }); + + it('renders non-owner and Supervisor rows without Drop actions or read-only labels', () => { + const readOnlyRoster: ConferenceParticipantDropRoster = { + customer: ownerDropRoster.customer ? {...ownerDropRoster.customer, isReadOnly: true} : null, + participants: [ + {...ownerDropRoster.participants[0], isPrimary: true, isReadOnly: true}, + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor-1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }; + const {screen} = renderRoster({conferenceParticipantDropRoster: readOnlyRoster}); + + expect(screen.getByText('Agent Two (Primary)')).toBeInTheDocument(); + expect(screen.getByText('Supervisor One')).toBeInTheDocument(); + expect(screen.queryByText('Read only')).not.toBeInTheDocument(); + expect(document.querySelector('.participant-menu-type')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /Drop (agent|customer|supervisor)/})).not.toBeInTheDocument(); + }); + + it('counts visible non-customer rows and disables a pending EP-DN action', () => { + const pendingEpDn = { + participantType: 'EP-DN' as const, + displayName: '+15551230000', + dropTargetId: 'pending-epdn', + isPrimary: false, + isReadOnly: false, + isDropDisabled: true, + requiresConfirmation: false, + }; + const supervisor = { + participantType: 'Supervisor' as const, + displayName: 'Supervisor One', + dropTargetId: 'supervisor-1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }; + const {screen, dropConferenceParticipant} = renderRoster({ + conferenceParticipantDropRoster: { + ...ownerDropRoster, + participants: [...ownerDropRoster.participants, pendingEpDn, supervisor], + }, + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+3 Participants'); + const pendingDrop = screen.getByRole('button', {name: 'Drop ep-dn +15551230000'}); + expect(pendingDrop).toBeDisabled(); + fireEvent.click(pendingDrop); + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + }); + + it('renders an answered Entry Point agent as disabled until conference merge', () => { + const answeredEntryPointAgent = { + participantType: 'Agent' as const, + displayName: 'Support Agent', + dropTargetId: 'agent-3', + isPrimary: false, + isReadOnly: false, + isDropDisabled: true, + requiresConfirmation: false, + }; + const {screen, dropConferenceParticipant} = renderRoster({ + conferenceParticipantDropRoster: { + customer: null, + participants: [answeredEntryPointAgent], + isDropDisabled: false, + }, + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+1 Participant'); + expect(screen.getByText('Support Agent')).toBeInTheDocument(); + const answeredAgentDrop = screen.getByRole('button', {name: 'Drop agent Support Agent'}); + expect(answeredAgentDrop).toBeDisabled(); + fireEvent.click(answeredAgentDrop); + expect(dropConferenceParticipant).not.toHaveBeenCalled(); + }); + + it('keeps generic live feedback mounted when the roster disappears', () => { + const screen = render( + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Participant removed from the conference.'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + screen.rerender( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to drop participant from the call. Try again.'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + }); + describe('conference participants list visibility', () => { it('shows participants list when conference is active and other agents are present', () => { const screen = render( @@ -422,13 +700,14 @@ describe('CallControlCADComponent', () => { {...defaultProps} controls={createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}})} conferenceParticipants={[{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}]} + conferenceParticipantDropRoster={ownerDropRoster} /> ); expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); }); - it('hides participants list when exitConference is not visible and conference is not in progress', () => { + it('hides participants list when the validated Drop roster is null', () => { const screen = render( { {id: 'agent-2', name: 'Agent Two', pType: 'Agent'}, {id: 'agent-3', name: 'Agent Three', pType: 'Agent'}, ]} + conferenceParticipantDropRoster={null} /> ); @@ -462,6 +742,7 @@ describe('CallControlCADComponent', () => { currentTask={conferenceTask} controls={createEnabledMainTaskUIControls({exitConference: {isVisible: false, isEnabled: false}})} conferenceParticipants={[{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}]} + conferenceParticipantDropRoster={ownerDropRoster} /> ); diff --git a/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md b/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md index 983dbce96..b0c4c4dbd 100644 --- a/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md +++ b/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md @@ -83,7 +83,7 @@ boundary (see `src/wc.ts`). | `cc-widgets.UserState` | SDK | React export `UserState`; tag `widget-cc-user-state` (prop `onStateChange`) | Agent state management UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.IncomingTask` | SDK | React export `IncomingTask`; tag `widget-cc-incoming-task` (props `incomingTask:json`, `onAccepted`, `onRejected`) | Incoming task notification UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.CallControl` | SDK | React export `CallControl`; tag `widget-cc-call-control` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`) | Active-call control buttons | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK | React export `CallControlCAD`; tag `widget-cc-call-control-cad` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`) | CAD-enabled call control | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK | React export `CallControlCAD`; tag `widget-cc-call-control-cad` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`); participant Drop is inherited from the task widget | CAD-enabled call control with owner-aware conference participant removal | stable; no new property or attribute | `src/index.ts`, `src/wc.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.TaskList` | SDK | React export `TaskList`; tag `widget-cc-task-list` (props `onTaskAccepted`, `onTaskDeclined`, `onTaskSelected`, `hasCampaignPreviewEnabled:boolean`) | Active tasks list UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.OutdialCall` | SDK | React export `OutdialCall`; tag `widget-cc-outdial-call` (no mapped props; store-driven) | Outbound dialing UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.RealTimeTranscript` | SDK | React export `RealTimeTranscript`; tag `widget-cc-realtime-transcript` (props `liveTranscriptEntries:json`, `className:string`) | Live transcript UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -117,6 +117,7 @@ Compatibility notes: | `cc-widgets-R-004` | Map complex/callback props across the WC boundary with explicit r2wc prop types: `function` callbacks, `json` for object props (`incomingTask`, `liveTranscriptEntries`), `boolean` (`hasCampaignPreviewEnabled`), and `string` (`className`). | HTML attributes are strings only; functions and objects must be set as element properties with the right r2wc coercion or the widget won't receive them. | `packages/contact-center/cc-widgets/src/wc.ts` | None found | Prop maps are the WC public contract; no test asserts the type map. | WEAK | | `cc-widgets-R-005` | Import Momentum UI base CSS in the React entry so React consumers get widget styling without a separate import. | Avoids unstyled widgets in React hosts (a documented prior support issue). | `packages/contact-center/cc-widgets/src/index.ts` | None found | CSS side-effect import is untested. | WEAK | | `cc-widgets-R-006` | Treat React/ReactDOM as peer dependencies (`>=18.3.1`) rather than bundled runtime deps for the React export. | A single host React instance prevents "Invalid hook call" / duplicate-React failures. | `packages/contact-center/cc-widgets/package.json` | None found | Peer-dep enforcement is by package manager, not tested here. | WEAK | +| `cc-widgets-R-007` | The existing React `CallControlCAD` export and `widget-cc-call-control-cad` wrapper inherit participant Drop through `@webex/cc-task` without adding an r2wc prop mapping, attribute, property, event, or feature flag; `CallControl` remains unchanged. | Both consumption modes receive the same store-driven behavior without expanding or breaking the host contract. | `src/index.ts`, `src/wc.ts`, `packages/contact-center/task/src/CallControlCAD/index.tsx` | `@webex/cc-task` and `@webex/cc-components` tests; this package currently has no tests | Wrapper registration itself is unchanged. | PRESENT | ## Design Overview The module is a pure composition/distribution layer with two entry points and no internal state. The React @@ -354,6 +355,7 @@ tested by the upstream widget packages, not here. | `cc-widgets-R-004` (r2wc prop-type map) | None found | No test asserts function/json/boolean/string prop mapping. | | `cc-widgets-R-005` (Momentum CSS imported) | None found | No test for the CSS side-effect import. | | `cc-widgets-R-006` (React/ReactDOM peers) | None found | Enforced by package manager only; no automated check here. | +| `cc-widgets-R-007` (CallControlCAD inherits Drop without a new WC surface) | Covered by upstream task/component tests | No package-local test; the wrapper and prop map are unchanged. | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 75a882569..4967db0c0 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -76,7 +76,7 @@ This module is consumed as an imported SDK/code API (the `@webex/cc-store` packa | `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.types` | SDK | type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `IStore`, `IStoreWrapper`, `InitParams`, `RealTimeTranscriptionData`, ~20 more) | Typed domain surface for widget code | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts:334-366`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantDropRoster`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask`; the Drop roster is main-leg, owner-aware, and may add the current Entry Point/EP-DN consult destination by number while ringing or answering Agent name before merge | stable semver | `packages/contact-center/store/src/task-utils.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: - Adding a new observable getter or mutator is additive (minor). Removing/renaming an observable, mutator, or changing the `CC_EVENTS`/`TASK_EVENTS` enum values is breaking (major) — widgets and the SDK event stream depend on the exact string values. @@ -111,13 +111,15 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | +| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | +| `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`). With a host-supplied `webex`, the wrapper wires event listeners and registers synchronously. Without one, the store calls `Webex.init()`, arms a 6000ms timeout, and waits for the `ready` event before wiring listeners and registering; the timeout guards against an SDK that never becomes ready. Registration maps the agent `Profile` into observables once. -Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. +Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Terminal `task:end` and `task:consultEnd` signals coalesce that read into the next microtask because SDK final-state cleanup completes after event emission; consult flags still clear synchronously, and participant-left remains synchronous for surviving-call roster updates. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`. @@ -233,8 +235,10 @@ sequenceDiagram else W->>S: setCurrentTask(task); setState(ENGAGED) end - SDK-->>W: task:end - W->>S: setIsDeclineButtonEnabled(false); refreshTaskList() + SDK-->>W: task:end / task:consultEnd + W->>S: clear immediate button/consult state + W->>W: coalesce one microtask refresh + W->>S: refreshTaskList() after SDK final cleanup Note over W,S: handleTaskRemove detaches all task listeners,
clears transcription, drops accepted-campaign id,
resets state, refreshTaskList() ``` @@ -344,6 +348,7 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | | `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | | `STORE-R-021` | None found | `handleRealtimeTranscription` untested | +| `STORE-R-022` | `tests/task-utils.ts` (participant Drop roster, owner transfer, ANI/DNIS, filtering, consult gating, conference eligibility) | none | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index c0bf9fc45..30fce3470 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -411,6 +411,32 @@ export type Participant = { name?: string; }; +export type ConferenceParticipantDropType = 'Customer' | 'Agent' | 'EP-DN' | 'Supervisor'; + +/** + * Display and authorization data for one participant-drop roster row. + * `dropTargetId` is passed to the SDK only when `isReadOnly` is false. + */ +export type ConferenceParticipantDropTarget = { + participantType: ConferenceParticipantDropType; + displayName: string; + dropTargetId: string; + isPrimary: boolean; + isReadOnly: boolean; + /** Target is visible with an action, but the conference Drop request is not valid yet. */ + isDropDisabled: boolean; + requiresConfirmation: boolean; +}; + +/** + * Participant-drop roster derived from the authoritative main-call media leg. + */ +export type ConferenceParticipantDropRoster = { + customer: ConferenceParticipantDropTarget | null; + participants: ConferenceParticipantDropTarget[]; + isDropDisabled: boolean; +}; + /** * Desktop preference data structure containing E911 modal acknowledgment. * @public diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4f1f35f60..2cb5f04d1 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -50,6 +50,7 @@ class StoreWrapper implements IStoreWrapper { onTaskSelected?: (task: ITask, isClicked: boolean) => void; onErrorCallback?: (widgetName: string, error: Error) => void; private realtimeTranscriptionListeners: Record void> = {}; + private taskListRefreshScheduled = false; // Keyed by interactionId; the task is tracked alongside the listener so a // replacement task object (task:hydrate / task:merged) gets rebound. private realTimeAssistListeners: Record void}> = @@ -345,6 +346,18 @@ class StoreWrapper implements IStoreWrapper { }); }; + private scheduleTaskListRefresh = (): void => { + if (this.taskListRefreshScheduled) { + return; + } + + this.taskListRefreshScheduled = true; + queueMicrotask(() => { + this.taskListRefreshScheduled = false; + this.refreshTaskList(); + }); + }; + setWrapupCodes = (wrapupCodes: IWrapupCode[]): void => { this.store.wrapupCodes = wrapupCodes; }; @@ -799,8 +812,7 @@ class StoreWrapper implements IStoreWrapper { handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); - - this.refreshTaskList(); + this.scheduleTaskListRefresh(); }; handleTaskAssigned = (event) => { @@ -873,8 +885,8 @@ class StoreWrapper implements IStoreWrapper { this.setIsQueueConsultInProgress(false); this.setCurrentConsultQueueId(null); this.setLastConsultDestination(null); - this.refreshTaskList(); this.setConsultStartTimeStamp(null); + this.scheduleTaskListRefresh(); }; handleConsultOffer = () => { diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index f3dd96ca6..046bfc7b2 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -1,5 +1,12 @@ import {EXCLUDED_PARTICIPANT_TYPES, RELATIONSHIP_TYPE_CONSULT} from './constants'; -import {ITask, MEDIA_TYPE_TELEPHONY_LOWER, Participant} from './store.types'; +import { + ConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, + ConferenceParticipantDropType, + ITask, + MEDIA_TYPE_TELEPHONY_LOWER, + Participant, +} from './store.types'; /** * Determines if a task is an incoming task @@ -133,6 +140,445 @@ export const getConferenceParticipants = (task: ITask, agentId: string): Partici return participantsList; }; +const TERMINAL_CONFERENCE_STATES = new Set(['ended', 'disconnected', 'terminated']); + +type RosterTaskData = ITask['data']; +type RosterInteraction = RosterTaskData['interaction']; +type RosterParticipant = NonNullable[string]; +type RosterMedia = NonNullable[string]; + +type TaskWithStateSnapshot = ITask & { + state?: { + context?: { + taskData?: RosterTaskData; + }; + }; +}; + +const normalizeParticipantType = (participantType?: string): string => + String(participantType || '') + .trim() + .toUpperCase() + .replaceAll('_', '-'); + +const getParticipantTypeTokens = (participant: RosterParticipant): string[] => + [participant.pType, participant.type] + .map((participantType) => normalizeParticipantType(participantType)) + .filter(Boolean); + +const participantHasType = (participant: RosterParticipant, participantType: string): boolean => + getParticipantTypeTokens(participant).includes(participantType); + +const hasVisibleControls = (controls: ITask['uiControls']['consult'] | undefined): boolean => + Boolean(controls && Object.values(controls).some((control) => control?.isVisible)); + +const isViewingAgentActiveOnMainLeg = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { + const participants = task?.data?.interaction?.participants ?? {}; + return mainParticipantIds.some((participantId) => { + const participant = participants[participantId]; + + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return false; + } + + return participantId === agentId || participant.id === agentId; + }); +}; + +const isEligibleConference = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { + const interaction = task?.data?.interaction; + const interactionState = interaction?.state?.toLowerCase(); + const isTerminated = Boolean(interaction?.isTerminated || TERMINAL_CONFERENCE_STATES.has(interactionState)); + + return Boolean( + interaction?.mediaType?.toLowerCase() === MEDIA_TYPE_TELEPHONY_LOWER && + mainParticipantIds.length > 0 && + !isTerminated && + isViewingAgentActiveOnMainLeg(task, mainParticipantIds, agentId) + ); +}; + +const getMediaRecencyTimestamp = (media: Record): number | undefined => { + const candidateTimestamps = [ + media.lastUpdated, + media.joinTimestamp, + media.consultTimestamp, + media.holdTimestamp, + media.eventTime, + media.createdAt, + ]; + + for (const value of candidateTimestamps) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + + return undefined; +}; + +const getMediaRecencyScore = (media: Record, fallbackIndex: number): number => + getMediaRecencyTimestamp(media) ?? fallbackIndex; + +type ConsultMediaEntry = { + mediaId: string; + media: RosterMedia; + interaction: RosterInteraction; + taskData: RosterTaskData; + isConfiguredMedia: boolean; + source: 'observable' | 'snapshot'; +}; + +const getConsultMediaEntry = ( + taskData: RosterTaskData, + source: ConsultMediaEntry['source'] +): ConsultMediaEntry | undefined => { + const interaction = taskData?.interaction; + const configuredConsultMediaId = taskData?.consultMediaResourceId; + const matchingMedia = Object.entries(interaction?.media ?? {}).filter(([, media]) => media.mType === 'consult'); + + if (!interaction || matchingMedia.length === 0) { + return undefined; + } + + const configuredEntry = configuredConsultMediaId + ? matchingMedia.find( + ([mediaId, media]) => mediaId === configuredConsultMediaId || media.mediaResourceId === configuredConsultMediaId + ) + : undefined; + const [mediaId, media] = + configuredEntry ?? + matchingMedia.reduce((latest, current, index) => { + const latestScore = getMediaRecencyScore(latest[1] as Record, index - 1); + const currentScore = getMediaRecencyScore(current[1] as Record, index); + return currentScore >= latestScore ? current : latest; + }); + + return { + mediaId: media.mediaResourceId || mediaId, + media, + interaction, + taskData, + isConfiguredMedia: Boolean(configuredEntry), + source, + }; +}; + +const selectCurrentConsultMediaEntry = ( + current: ConsultMediaEntry, + candidate: ConsultMediaEntry +): ConsultMediaEntry => { + const currentTimestamp = getMediaRecencyTimestamp(current.media as Record); + const candidateTimestamp = getMediaRecencyTimestamp(candidate.media as Record); + + if (current.mediaId === candidate.mediaId) { + if (currentTimestamp !== undefined && candidateTimestamp !== undefined && candidateTimestamp > currentTimestamp) { + return candidate; + } + + // Preserve the existing observable-data behavior when both sources describe + // the same leg and there is no positive evidence that the snapshot is newer. + return current; + } + + if (currentTimestamp !== candidateTimestamp) { + if (currentTimestamp !== undefined && candidateTimestamp !== undefined) { + return candidateTimestamp > currentTimestamp ? candidate : current; + } + + if (current.isConfiguredMedia === candidate.isConfiguredMedia) { + return candidateTimestamp !== undefined ? candidate : current; + } + } + + if (current.isConfiguredMedia !== candidate.isConfiguredMedia) { + return candidate.isConfiguredMedia ? candidate : current; + } + + // The state-machine context can advance before observable task hydration. + // Prefer it when recency and current-media signals cannot distinguish the legs. + return candidate.source === 'snapshot' ? candidate : current; +}; + +const getCurrentConsultMediaEntry = (task: ITask) => { + const taskWithSnapshot = task as TaskWithStateSnapshot; + const snapshotTaskData = taskWithSnapshot.state?.context?.taskData; + const interactionState = task?.data?.interaction?.state?.toLowerCase(); + const consultIsActive = + hasVisibleControls(task?.uiControls?.consult) || ['consult', 'consulting'].includes(interactionState); + + if (!consultIsActive) { + return undefined; + } + + const candidates = [ + getConsultMediaEntry(task.data, 'observable'), + snapshotTaskData ? getConsultMediaEntry(snapshotTaskData, 'snapshot') : undefined, + ].filter((candidate): candidate is ConsultMediaEntry => Boolean(candidate)); + + return candidates.length > 0 ? candidates.reduce(selectCurrentConsultMediaEntry) : undefined; +}; + +const hasActiveNonHeldConsult = (task: ITask): boolean => { + const interaction = task?.data?.interaction; + const consultMediaResourceId = findMediaResourceId(task, 'consult'); + const consultMedia = consultMediaResourceId ? interaction?.media?.[consultMediaResourceId] : undefined; + const interactionState = interaction?.state?.toLowerCase(); + const consultIsActive = + hasVisibleControls(task?.uiControls?.consult) || ['consult', 'consulting'].includes(interactionState); + + // Treat an active consult without a hydrated media leg conservatively as non-held. + // Drop becomes available again once the SDK explicitly reports that consult leg held. + return Boolean(consultIsActive && consultMedia?.isHold !== true); +}; + +const getCustomerDropTargetId = (task: ITask): string => { + const interaction = task?.data?.interaction; + const direction = interaction?.contactDirection?.type?.toLowerCase(); + const callDetails = interaction?.callAssociatedDetails; + const processingDetails = interaction?.callProcessingDetails; + + if (direction === 'inbound') { + return callDetails?.ani || processingDetails?.ani || ''; + } + + if (direction === 'outbound') { + return callDetails?.dnis || processingDetails?.dnis || ''; + } + + return ''; +}; + +const getParticipantType = ( + normalizedType: string +): {participantType: ConferenceParticipantDropType; isSupervisor: boolean} | null => { + if (normalizedType === 'AGENT') { + return {participantType: 'Agent', isSupervisor: false}; + } + + if (['EP-DN', 'EPDN', 'DN'].includes(normalizedType)) { + return {participantType: 'EP-DN', isSupervisor: false}; + } + + if (normalizedType === 'SUPERVISOR') { + return {participantType: 'Supervisor', isSupervisor: true}; + } + + return null; +}; + +const getParticipantTypeDetails = ( + participant: RosterParticipant +): {participantType: ConferenceParticipantDropType; isSupervisor: boolean} | null => { + for (const participantType of getParticipantTypeTokens(participant)) { + const typeDetails = getParticipantType(participantType); + + if (typeDetails) { + return typeDetails; + } + } + + return null; +}; + +const isEpDnConsultDestination = (destinationType?: string): boolean => + ['ENTRYPOINT', 'ENTRY-POINT', 'DIALNUMBER', 'DIAL-NUMBER', 'EP-DN', 'EPDN', 'DN'].includes( + normalizeParticipantType(destinationType) + ); + +const isTrueLike = (value: boolean | string | undefined): boolean => + value === true || String(value).toLowerCase() === 'true'; + +/** + * Derives the owner-aware participant Drop roster from the active main-call media leg. + * The viewing agent is never returned as a target and backend authorization remains authoritative. + */ +export const getConferenceParticipantDropRoster = ( + task: ITask, + agentId: string +): ConferenceParticipantDropRoster | null => { + const interaction = task?.data?.interaction; + const mainCallMedia = getMainCallMediaEntry(task); + const mainParticipantIds = mainCallMedia?.participants ?? []; + + if (!interaction || !isEligibleConference(task, mainParticipantIds, agentId) || isSecondaryAgent(task)) { + return null; + } + + const participants = interaction.participants ?? {}; + const mainParticipantIdSet = new Set(mainParticipantIds); + + const isOwner = interaction.owner === agentId; + const participantRows: ConferenceParticipantDropTarget[] = []; + const includedParticipantIds = new Set(); + const currentConsult = getCurrentConsultMediaEntry(task); + let hasActiveCustomer = false; + + mainParticipantIdSet.forEach((participantId) => { + const participant = participants[participantId]; + + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return; + } + + const resolvedParticipantId = participant.id || participantId; + if (participantHasType(participant, 'CUSTOMER')) { + hasActiveCustomer = true; + return; + } + + if (participantId === agentId || resolvedParticipantId === agentId || participantHasType(participant, 'VVA')) { + return; + } + + const typeDetails = getParticipantTypeDetails(participant); + + if (!typeDetails) { + return; + } + + const {participantType, isSupervisor} = typeDetails; + const displayName = + participantType === 'EP-DN' + ? participant.dn || resolvedParticipantId || participantId + : participant.name || resolvedParticipantId || participantType; + + participantRows.push({ + participantType, + displayName: String(displayName), + dropTargetId: resolvedParticipantId, + isPrimary: interaction.owner === participantId || interaction.owner === resolvedParticipantId, + isReadOnly: !isOwner || isSupervisor, + isDropDisabled: false, + requiresConfirmation: false, + }); + includedParticipantIds.add(participantId); + includedParticipantIds.add(resolvedParticipantId); + }); + + const consultParticipantIds = currentConsult?.media.participants ?? []; + const consultParticipants = currentConsult?.interaction.participants ?? participants; + const pendingEpDnEntry = consultParticipantIds + .map((participantId) => ({participantId, participant: consultParticipants[participantId]})) + .find(({participantId, participant}) => { + if (!participant || participant.hasLeft) { + return false; + } + + const resolvedParticipantId = participant.id || participantId; + return ( + getParticipantTypeDetails(participant)?.participantType === 'EP-DN' && + participantId !== agentId && + resolvedParticipantId !== agentId + ); + }); + const consultProcessingDetails = currentConsult?.interaction.callProcessingDetails; + const rawDestinationAgentName = consultProcessingDetails?.consultDestinationAgentName; + const destinationAgentJoined = + isTrueLike(consultProcessingDetails?.consultDestinationAgentJoined) || Boolean(rawDestinationAgentName); + const destinationAgentName = destinationAgentJoined ? rawDestinationAgentName : ''; + const answeredAgentEntries = consultParticipantIds + .map((participantId) => ({participantId, participant: consultParticipants[participantId]})) + .filter(({participantId, participant}) => { + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return false; + } + + const resolvedParticipantId = participant.id || participantId; + return ( + getParticipantTypeDetails(participant)?.participantType === 'Agent' && + participantId !== agentId && + resolvedParticipantId !== agentId + ); + }); + // A nested Entry Point consult can contain both the consulting conference + // Agent and the newly answering Agent. Prefer the explicit destination, then + // a consulted non-main participant, so the row never regresses to the caller. + const answeredAgentEntry = + answeredAgentEntries.find(({participant}) => + Boolean(destinationAgentName && participant?.name === destinationAgentName) + ) || + answeredAgentEntries.find(({participantId, participant}) => { + const resolvedParticipantId = participant?.id || participantId; + return participant?.isConsulted === true && !mainParticipantIdSet.has(resolvedParticipantId); + }) || + answeredAgentEntries.find(({participantId, participant}) => { + const resolvedParticipantId = participant?.id || participantId; + return !mainParticipantIdSet.has(participantId) && !mainParticipantIdSet.has(resolvedParticipantId); + }); + const epDnConsultIsActive = Boolean( + currentConsult && + (pendingEpDnEntry || + isEpDnConsultDestination(currentConsult.taskData.destinationType) || + isEpDnConsultDestination(task.data.destinationType)) + ); + + if (epDnConsultIsActive) { + const answeredAgent = answeredAgentEntry?.participant; + const pendingEpDn = pendingEpDnEntry?.participant; + const visibleParticipant = answeredAgent || pendingEpDn; + const visibleParticipantId = answeredAgentEntry?.participantId || pendingEpDnEntry?.participantId; + + if (visibleParticipant && visibleParticipantId) { + const resolvedParticipantId = visibleParticipant.id || visibleParticipantId; + const answeredAgentAlreadyVisible = participantRows.some( + (target) => + target.participantType === 'Agent' && + Boolean(destinationAgentName) && + target.displayName === destinationAgentName + ); + + if ( + !mainParticipantIdSet.has(visibleParticipantId) && + !includedParticipantIds.has(resolvedParticipantId) && + !answeredAgentAlreadyVisible + ) { + const participantType = answeredAgent ? 'Agent' : 'EP-DN'; + const displayName = answeredAgent + ? answeredAgent.name || destinationAgentName || resolvedParticipantId + : destinationAgentName || pendingEpDn?.dn || resolvedParticipantId || currentConsult.media.mediaResourceId; + + participantRows.push({ + participantType, + displayName: String(displayName), + dropTargetId: resolvedParticipantId, + isPrimary: false, + isReadOnly: !isOwner, + isDropDisabled: true, + requiresConfirmation: false, + }); + includedParticipantIds.add(visibleParticipantId); + includedParticipantIds.add(resolvedParticipantId); + } + } + } + + const customerDropTargetId = hasActiveCustomer ? getCustomerDropTargetId(task) : ''; + const customer: ConferenceParticipantDropTarget | null = customerDropTargetId + ? { + participantType: 'Customer', + displayName: customerDropTargetId, + dropTargetId: customerDropTargetId, + isPrimary: false, + isReadOnly: !isOwner, + isDropDisabled: false, + requiresConfirmation: true, + } + : null; + + // Customer-only calls retain the original 1-to-1 UI. Any supported + // non-customer row keeps the participant roster visible. + if (participantRows.length === 0) { + return null; + } + + return { + customer, + participants: participantRows, + isDropDisabled: hasActiveNonHeldConsult(task), + }; +}; + export function isInteractionOnHold(task: ITask): boolean { if (!task || !task.data || !task.data.interaction) { return false; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index e41c51e94..a0b648a18 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -2392,7 +2392,7 @@ describe('storeEventsWrapper', () => { }); describe('handleTaskEnd — campaign preview (unaccepted)', () => { - it('should call refreshTaskList and let the backend drive task removal', () => { + it('should defer refreshTaskList so SDK cleanup completes first', async () => { const task = createCampaignPreviewTask('campaign-1'); storeWrapper['store'].taskList = {'campaign-1': task}; storeWrapper['store'].currentTask = task; @@ -2403,13 +2403,14 @@ describe('storeEventsWrapper', () => { storeWrapper.handleTaskEnd(); - // refreshTaskList should be called (normal path, no force cleanup) - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); describe('handleTaskEnd — accepted campaign preview', () => { - it('should call refreshTaskList for accepted campaign', () => { + it('should defer refreshTaskList for accepted campaign', async () => { const task = createCampaignPreviewTask('campaign-accepted'); storeWrapper['store'].acceptedCampaignIds = new Set(['campaign-accepted']); storeWrapper['store'].taskList = {'campaign-accepted': task}; @@ -2422,8 +2423,9 @@ describe('storeEventsWrapper', () => { // acceptedCampaignIds should NOT be cleaned up here (deferred to handleTaskRemove) expect(storeWrapper['store'].acceptedCampaignIds.has('campaign-accepted')).toBe(true); - // refreshTaskList SHOULD be called (normal path) - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); @@ -2455,7 +2457,7 @@ describe('storeEventsWrapper', () => { }); describe('handleTaskEnd — non-campaign tasks', () => { - it('should call refreshTaskList for a regular (non-campaign) task', () => { + it('should refresh a regular task after SDK terminal cleanup', async () => { const regularTask = makeMockTask({ data: { interactionId: 'regular-1', @@ -2474,11 +2476,43 @@ describe('storeEventsWrapper', () => { storeWrapper.handleTaskEnd(); - // Should call refreshTaskList normally - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); // taskList should still contain the task (SDK still returns it) expect(storeWrapper['store'].taskList['regular-1']).toBeDefined(); }); + + it('coalesces consult-end and task-end into one deferred refresh', async () => { + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + const setQueueProgressSpy = jest.spyOn(storeWrapper, 'setIsQueueConsultInProgress'); + + storeWrapper.handleConsultEnd(); + storeWrapper.handleTaskEnd(); + + expect(setQueueProgressSpy).toHaveBeenCalledWith(false); + expect(storeWrapper.consultStartTimeStamp).toBeNull(); + expect(refreshSpy).not.toHaveBeenCalled(); + + await Promise.resolve(); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('clears the ended current task after SDK cleanup removes it', async () => { + const regularTask = makeMockTask({ + data: {interactionId: 'ended-task', interaction: {state: 'connected'}}, + }); + storeWrapper['store'].taskList = {'ended-task': regularTask}; + storeWrapper['store'].currentTask = regularTask; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({}); + + storeWrapper.handleTaskEnd(); + + expect(storeWrapper.currentTask).toBe(regularTask); + await Promise.resolve(); + expect(storeWrapper.currentTask).toBeNull(); + }); }); describe('handleIncomingCampaignPreview — campaign type branching', () => { diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index 557448c68..3d1f8a7be 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -1,5 +1,11 @@ -import {isIncomingTask, getConferenceParticipants, findHoldTimestamp} from '../src/task-utils'; +import { + isIncomingTask, + getConferenceParticipants, + getConferenceParticipantDropRoster, + findHoldTimestamp, +} from '../src/task-utils'; import {mockTask} from '../../test-fixtures/src/fixtures'; +import {createEnabledMainTaskUIControls} from '../../test-fixtures/src/taskUIControlsFixtures'; import {ITask} from '../src/store.types'; const participant = (hasJoined: boolean) => @@ -601,6 +607,627 @@ describe('getConferenceParticipants', () => { }); }); +describe('getConferenceParticipantDropRoster', () => { + const currentAgentId = 'agent1'; + + const activeParticipant = (id: string, pType: string, name?: string) => ({ + id, + pType, + type: pType, + name, + hasJoined: true, + hasLeft: false, + isInPredial: false, + }); + + const createDropRosterTask = ({ + owner = currentAgentId, + direction = 'inbound', + state = 'conference', + wrapUpRequired = false, + consultHold, + }: { + owner?: string; + direction?: string; + state?: string; + wrapUpRequired?: boolean; + consultHold?: boolean; + } = {}): ITask => { + const controls = createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}); + const media = { + main: { + mediaResourceId: 'main', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'agent2', 'epdn1', 'supervisor1', 'customer1', 'vva1', 'unsupported1'], + }, + ...(consultHold === undefined + ? {} + : { + consult: { + mediaResourceId: 'consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: consultHold, + holdTimestamp: consultHold ? Date.now() : null, + participants: ['agent1', 'consult-agent'], + }, + }), + }; + + if (consultHold !== undefined) { + controls.consult.endConsult = {isVisible: true, isEnabled: true}; + } + + return { + ...mockTask, + uiControls: controls, + data: { + ...mockTask.data, + interactionId: 'main', + wrapUpRequired, + isConferenceInProgress: true, + interaction: createPartialInteraction({ + ...mockTask.data.interaction, + interactionId: 'main', + mediaType: 'telephony', + state, + owner, + contactDirection: {type: direction}, + callAssociatedDetails: {ani: '+15550000001', dnis: '+15550000002'}, + callProcessingDetails: { + ...mockTask.data.interaction.callProcessingDetails, + ani: '+15550000001', + dnis: '+15550000002', + }, + media, + participants: { + agent1: activeParticipant('agent1', 'Agent', 'Current Agent'), + agent2: activeParticipant('agent2', 'Agent', 'Agent Two'), + epdn1: {...activeParticipant('epdn1', 'EP_DN', 'EP-DN'), dn: '+15550000003'}, + supervisor1: activeParticipant('supervisor1', 'Supervisor', 'Supervisor One'), + customer1: activeParticipant('customer1', 'Customer', 'Customer'), + vva1: activeParticipant('vva1', 'VVA', 'Virtual Agent'), + unsupported1: activeParticipant('unsupported1', 'Queue', 'Queue'), + 'consult-agent': activeParticipant('consult-agent', 'Agent', 'Consult Agent'), + }, + }), + }, + } as ITask; + }; + + it('derives Customer, Agent, EP-DN, and read-only Supervisor rows from the main leg', () => { + const task = createDropRosterTask(); + const roster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(roster).toEqual({ + customer: { + participantType: 'Customer', + displayName: '+15550000001', + dropTargetId: '+15550000001', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: true, + }, + participants: [ + { + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent2', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + { + participantType: 'EP-DN', + displayName: '+15550000003', + dropTargetId: 'epdn1', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }); + expect(roster?.participants).not.toEqual(expect.arrayContaining([expect.objectContaining({dropTargetId: 'vva1'})])); + expect(roster?.participants).not.toEqual( + expect.arrayContaining([expect.objectContaining({dropTargetId: 'consult-agent'})]) + ); + expect(getConferenceParticipants(task, currentAgentId)).toHaveLength(3); + }); + + it('uses outbound DNIS for the synthetic Customer target', () => { + const roster = getConferenceParticipantDropRoster(createDropRosterTask({direction: 'outbound'}), currentAgentId); + + expect(roster?.customer?.dropTargetId).toBe('+15550000002'); + }); + + it('omits departed, not-yet-joined, and customer rows without a valid direction number', () => { + const task = createDropRosterTask(); + task.data.interaction.participants.agent2.hasLeft = true; + task.data.interaction.participants.epdn1.hasJoined = false; + task.data.interaction.callAssociatedDetails.ani = ''; + task.data.interaction.callProcessingDetails.ani = ''; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toEqual({ + customer: null, + participants: [ + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }); + }); + + it('makes every row read-only for a non-owner and marks the owner as Primary', () => { + const roster = getConferenceParticipantDropRoster(createDropRosterTask({owner: 'agent2'}), currentAgentId); + + expect(roster?.customer?.isReadOnly).toBe(true); + expect(roster?.participants.every((target) => target.isReadOnly)).toBe(true); + expect(roster?.participants.find((target) => target.dropTargetId === 'agent2')?.isPrimary).toBe(true); + }); + + it('disables Drop only while an active consult is not held', () => { + expect( + getConferenceParticipantDropRoster(createDropRosterTask({consultHold: false}), currentAgentId)?.isDropDisabled + ).toBe(true); + expect( + getConferenceParticipantDropRoster(createDropRosterTask({consultHold: true}), currentAgentId)?.isDropDisabled + ).toBe(false); + }); + + it('keeps Drop disabled while active consult controls precede consult media hydration', () => { + const task = createDropRosterTask({consultHold: false}); + delete task.data.interaction.media.consult; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.isDropDisabled).toBe(true); + }); + + it('returns null outside an eligible telephony main call', () => { + const task = createDropRosterTask(); + task.data.interaction.mediaType = 'chat'; + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('keeps a valid multiparty roster through post-call and wrap-up signal downgrades', () => { + const task = createDropRosterTask({state: 'post_call', wrapUpRequired: true}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'epdn1']; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({dropTargetId: 'agent2'}), + expect.objectContaining({dropTargetId: 'epdn1'}), + ]); + }); + + it('uses main-leg membership for a Customer-plus-Agent multiparty call', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).not.toBeNull(); + }); + + it('keeps Participants after Customer leaves and conference signals downgrade', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.data.interaction.participants.customer1.hasLeft = true; + + const roster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(roster?.customer).toBeNull(); + expect(roster?.participants).toEqual([ + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'}), + expect.objectContaining({participantType: 'EP-DN', dropTargetId: 'epdn1'}), + expect.objectContaining({participantType: 'Supervisor', dropTargetId: 'supervisor1'}), + ]); + }); + + it('does not treat an initial one-agent/one-customer call as a conference roster', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('keeps the roster when Customer leaves one other Agent', () => { + const task = createDropRosterTask({state: 'post_call', wrapUpRequired: true}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + task.data.interaction.media.main.participants = ['agent1', 'agent2']; + task.data.interaction.participants.customer1.hasLeft = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toEqual({ + customer: null, + participants: [expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'})], + isDropDisabled: false, + }); + }); + + it('returns to the 1-to-1 UI when the final Agent leaves while Customer remains', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'customer1']; + task.data.interaction.participants.agent2.hasLeft = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('shows an Entry Point number while ringing, replaces it with the answering agent, and enables it after merge', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'EP-DN', + displayName: '+15550000009', + dropTargetId: '+15550000009', + isReadOnly: false, + isDropDisabled: true, + }), + ]); + + task.data.interaction.participants['answering-agent'] = activeParticipant( + 'answering-agent', + 'Agent', + 'Support Agent' + ); + task.data.interaction.media.consult.participants.push('answering-agent'); + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: true, + }), + ]); + + task.data.interaction.media.main.participants.push('answering-agent'); + task.data.interaction.state = 'conference'; + task.data.interaction.media.consult.isHold = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: false, + }), + ]); + }); + + it('keeps the consulting conference Agent and identifies the Entry Point answering Agent', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'customer1']; + task.data.interaction.media.consult.participants = ['agent2', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({displayName: 'Agent Two', dropTargetId: 'agent2'}), + expect.objectContaining({displayName: '+15550000009', isDropDisabled: true}), + ]); + + task.data.interaction.participants['answering-agent'] = { + ...activeParticipant('answering-agent', 'Agent', 'Support Agent'), + isConsulted: true, + }; + task.data.interaction.media.consult.participants.push('answering-agent'); + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({displayName: 'Agent Two', dropTargetId: 'agent2'}), + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: true, + }), + ]); + }); + + it('uses the latest task snapshot when the Entry Point consult leg has not reached task.data yet', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + delete task.data.interaction.media.consult; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'snapshot-consult', + destinationType: 'entryPoint', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'snapshot-consult': { + mediaResourceId: 'snapshot-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'snapshot-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'snapshot-entry-point': { + ...activeParticipant('+15550000010', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'EP-DN', + displayName: '+15550000010', + dropTargetId: '+15550000010', + isDropDisabled: true, + }), + ]); + }); + + it('prefers a newer configured consult leg from the state snapshot over retained observable media', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'old-entry-point']; + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 1000; + task.data.interaction.participants['old-entry-point'] = { + ...activeParticipant('+15550000011', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'new-consult', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'new-consult': { + mediaResourceId: 'new-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + lastUpdated: 2000, + participants: ['agent1', 'new-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'new-entry-point': { + ...activeParticipant('+15550000012', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000012', + dropTargetId: '+15550000012', + }), + ]); + }); + + it('keeps a newer observable consult leg when the state snapshot is stale', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'current-entry-point']; + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 2000; + task.data.interaction.participants['current-entry-point'] = { + ...activeParticipant('+15550000013', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'old-consult', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'old-consult': { + mediaResourceId: 'old-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'old-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'old-entry-point': { + ...activeParticipant('+15550000014', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000013', + dropTargetId: '+15550000013', + }), + ]); + }); + + it('preserves observable data for the same leg unless the snapshot is demonstrably newer', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'observable-entry-point']; + task.data.interaction.participants['observable-entry-point'] = { + ...activeParticipant('+15550000015', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + consult: { + ...task.data.interaction.media.consult, + participants: ['agent1', 'snapshot-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'snapshot-entry-point': { + ...activeParticipant('+15550000016', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000015', + dropTargetId: '+15550000015', + }), + ]); + + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 1000; + (snapshotTaskData.interaction.media.consult as unknown as Record).lastUpdated = 2000; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000016', + dropTargetId: '+15550000016', + }), + ]); + }); + + it('does not revive a stale pending EP-DN consult leg', () => { + const task = createDropRosterTask({state: 'connected', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'pending-epdn']; + task.data.interaction.participants['pending-epdn'] = { + ...activeParticipant('pending-epdn', 'EP_DN', 'EP-DN'), + dn: '+15550000009', + hasJoined: false, + }; + task.uiControls.consult = createEnabledMainTaskUIControls().consult; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('returns null when the viewing agent has departed or the task is terminated', () => { + const departedTask = createDropRosterTask(); + departedTask.data.interaction.participants.agent1.hasLeft = true; + expect(getConferenceParticipantDropRoster(departedTask, currentAgentId)).toBeNull(); + + const terminatedTask = createDropRosterTask({state: 'terminated'}); + terminatedTask.data.interaction.isTerminated = true; + expect(getConferenceParticipantDropRoster(terminatedTask, currentAgentId)).toBeNull(); + }); +}); + describe('findHoldTimestamp', () => { it('should return null when task data is missing or undefined', () => { const task = {} as Partial as ITask; diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..4d5795f9c 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -79,7 +79,7 @@ packages/contact-center/task/src/ | `cc-widgets.IncomingTask` | SDK (React component / Web Component) | `IncomingTask` — props: `incomingTask`; callbacks: `onAccepted({task})`, `onRejected({task})` | Render an offered task with accept/decline; notify consumer on accept/reject/RONA | Stable; adding optional props/callbacks = minor | `src/task.types.ts` (`IncomingTaskProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.TaskList` | SDK (React component / Web Component) | `TaskList` — props: `hasCampaignPreviewEnabled?`; callbacks: `onTaskAccepted(task)`, `onTaskDeclined(task, reason)`, `onTaskSelected({task, isClicked})` | List concurrent tasks; accept/decline/select | Stable; `hasCampaignPreviewEnabled` defaults true | `src/task.types.ts` (`TaskListProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.CallControl` | SDK (React component / Web Component) | `CallControl` — callbacks: `onHoldResume({isHeld,task})`, `onEnd({task})`, `onWrapUp({task,wrapUpReason})`, `onRecordingToggle({isRecording,task})`, `onToggleMute({isMuted,task})`; props: `conferenceEnabled?`, `consultTransferOptions?`, `callControlClassName?`, `callControlConsultClassName?` | Active-call controls for `store.currentTask` | Stable; `conferenceEnabled` defaults `true` | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName` | CallControl variant styled for a customer-data layout | Stable; same surface as CallControl | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName`; participant Drop is store-driven | CallControl variant styled for a customer-data layout with owner-aware conference participant removal | Stable; no new React prop or Web Component property/attribute | `src/task.types.ts` (`CallControlProps`); [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.OutdialCall` | SDK (React component / Web Component) | `OutdialCall` — props: `isAddressBookEnabled?` (default `true`); no consumer callbacks | Outbound dialpad + ANI selection; disabled when a telephony task is active | Stable | `src/task.types.ts` (`OutdialProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.RealTimeTranscript` | SDK (React component / Web Component) | `RealTimeTranscript` — props: `liveTranscriptEntries?`, `className?` | Render live transcript for `store.currentTask` | Stable | `src/task.types.ts` (`RealTimeTranscriptProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -120,11 +120,12 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | +| `TASK-R-024` | `useCallControl.dropConferenceParticipant(target)` revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous request token; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; ignores stale completions; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. -`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, additionally maintains a dozen `useState` values (recording, buddy agents, consult agent name, target type, timers, conference participants) and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. +`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, maintains transient UI state (recording, buddy agents, consult agent name, target type, timers, participant Drop request/announcement), derives both conference rosters directly from the current observable task, and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. `Utils/` is pure logic split out for testability: `task-util.ts` decides control visibility/enablement from device type, feature flags, and a large set of derived task-state booleans (consult status, hold status, conference progress, customer-in-call, participant counts); `timer-utils.ts` and `useHoldTimer.ts` compute timer labels/elapsed times. Keeping these pure means the visibility matrix and timer math are unit-tested without rendering. @@ -436,7 +437,7 @@ stateDiagram-v2 ## UI Flow - **IncomingTask:** task card with caller/queue/media info, RONA countdown badge, Accept/Decline buttons. Empty state = no card when `incomingTask` is null. Error state = empty fragment via ErrorBoundary. - **TaskList:** list of task cards; selected task highlighted (mirrors `currentTask`); per-task Accept/Decline; empty list renders nothing. Campaign-preview tasks render a `CampaignTask` when `hasCampaignPreviewEnabled` (default true). -- **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks. Disabled/enabled state of every button comes from `getControlsVisibility`. +- **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. The CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks plus owner-aware conference participant Drop; standard `CallControl` has no participant Drop UI. Disabled/enabled state of the established call-control buttons comes from `getControlsVisibility`. - **OutdialCall:** numeric dialpad with E.164/special-char validation, ANI selector dropdown, optional address book (`isAddressBookEnabled`), dial button disabled on invalid/empty input or while a telephony task is active. - **RealTimeTranscript:** scrolling transcript with normalized speaker ("You"/"Customer") and `HH:MM` display time; renders supplied `liveTranscriptEntries` when no live data for the current task. @@ -474,7 +475,7 @@ stateDiagram-v2 - DON'T add new task-event subscriptions without matching the exact event name in both `setTaskCallback` and the cleanup `removeTaskCallback`. ## Host Integration & Theming -These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides. The host supplies `store.onErrorCallback` to receive widget-crash notifications. +These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides and inherits participant Drop in both React and Web Component modes without new public inputs. The host supplies `store.onErrorCallback` to receive widget-crash and sanitized participant-Drop failure notifications. ## Test-Case Strategy (module) Tests are split between widget-shell render tests (each `tests//index.tsx` asserts the hook is called with the right props, the presentational component receives merged output, and the ErrorBoundary renders empty + invokes/handles-missing `onErrorCallback`) and exhaustive hook/util logic tests. `tests/helper.ts` is the large behavioral suite covering accept/decline, hold/resume, end, recording pause/resume (positive + SDK-failure negative cases), mute (including rapid toggles and failure revert), wrap-up + auto-wrap-up cancel, consult/transfer/conference, queue-consult flags, buddy-agent loading, and consulting-agent extraction. `tests/utils/task-util.ts` matrices `getControlsVisibility` across device types (BROWSER/AGENT_DN/EXTENSION) and media types (telephony/chat/email) plus EP-DN end-button rules and the error→safe-defaults path. `tests/utils/timer-utils.test.ts` and `tests/utils/useHoldTimer.test.ts` cover label priority, consult-on-hold, null-task defaults, and consult-vs-main hold prioritization. Edge cases asserted: missing interaction/participants, missing currentTask, error logging in every callback. Gaps: no unit test for the OutdialCall empty-destination alert, the `getOutdialANIEntries` missing-ANI-id throw, or the CallControl unaccepted-campaign-preview early return. @@ -504,6 +505,7 @@ Tests are split between widget-shell render tests (each `tests//index.ts | `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | | `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | | `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | +| `TASK-R-024` participant Drop orchestration | `tests/helper.ts` (exact payload, duplicate prevention, success/failure cleanup, sanitized callback/logging, stale completion, roster re-derivation) | Live routing-event behavior is covered by SDK/manual integration tests | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..0b15e8f2b 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -15,6 +15,7 @@ import { useOutdialCallProps, TargetType, TARGET_TYPE, + ParticipantDropAnnouncement, } from './task.types'; import store, { TASK_EVENTS, @@ -22,7 +23,8 @@ import store, { DestinationType, PaginatedListParams, getConferenceParticipants, - Participant, + getConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, findMediaResourceId, MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, @@ -42,6 +44,15 @@ import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/servic const ENGAGED_LABEL = 'ENGAGED'; const ENGAGED_USERNAME = 'Engaged'; +const PARTICIPANT_DROP_SUCCESS_MESSAGE = 'Participant removed from the conference.'; +const PARTICIPANT_DROP_FAILURE_MESSAGE = 'Unable to drop participant from the call. Try again.'; + +type PendingParticipantDropRequest = { + token: symbol; + taskId: string; + task: ITask; + dropTargetId: string; +}; const getTranscriptSpeaker = (role?: string): string => { const normalizedRole = role?.toUpperCase(); @@ -376,8 +387,22 @@ export const useCallControl = (props: useCallControlProps) => { !!(initialControls?.consult?.endConsult?.isVisible || initialControls?.main?.endConsult?.isVisible) ); const [lastTargetType, setLastTargetType] = useState(TARGET_TYPE.AGENT); - const [conferenceParticipants, setConferenceParticipants] = useState([]); const lastWrapupAuxCodeIdRef = useRef(null); + const [pendingParticipantDropId, setPendingParticipantDropId] = useState(null); + const [participantDropAnnouncement, setParticipantDropAnnouncement] = useState( + null + ); + const pendingParticipantDropRef = useRef(null); + const participantDropTaskRef = useRef(currentTask); + const participantDropAgentIdRef = useRef(agentId); + participantDropTaskRef.current = currentTask; + participantDropAgentIdRef.current = agentId; + + // Derive conference state during render so MobX tracks nested participant and owner changes. + const conferenceParticipants = currentTask && agentId ? getConferenceParticipants(currentTask, agentId) : []; + const conferenceParticipantDropRoster = + currentTask && agentId ? getConferenceParticipantDropRoster(currentTask, agentId) : null; + const participantDropTaskId = currentTask?.data?.interactionId; // Subscribe to SDK-computed UI control updates useEffect(() => { @@ -447,11 +472,15 @@ export const useCallControl = (props: useCallControlProps) => { }, [controls?.consult?.endConsult?.isVisible, controls?.main?.endConsult?.isVisible]); useEffect(() => { - if (currentTask && store?.cc?.agentConfig?.agentId) { - const participants = getConferenceParticipants(currentTask, store.cc.agentConfig.agentId); - setConferenceParticipants(participants); + const pendingRequest = pendingParticipantDropRef.current; + + if (pendingRequest && (pendingRequest.taskId !== participantDropTaskId || pendingRequest.task !== currentTask)) { + pendingParticipantDropRef.current = null; } - }, [currentTask, controls]); + + setPendingParticipantDropId(null); + setParticipantDropAnnouncement(null); + }, [currentTask, participantDropTaskId]); // Function to extract consulting agent information const extractConsultingAgent = useCallback(() => { try { @@ -813,6 +842,84 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const dropConferenceParticipant = useCallback( + async (target: ConferenceParticipantDropTarget): Promise => { + const task = participantDropTaskRef.current; + const currentAgentId = participantDropAgentIdRef.current; + + if (!task || !currentAgentId || pendingParticipantDropRef.current) { + return; + } + + const latestRoster = getConferenceParticipantDropRoster(task, currentAgentId); + const latestTarget = [latestRoster?.customer, ...(latestRoster?.participants ?? [])].find( + (candidate) => + candidate?.dropTargetId === target.dropTargetId && candidate.participantType === target.participantType + ); + + if ( + !latestRoster || + !latestTarget || + latestTarget.isReadOnly || + latestTarget.isDropDisabled || + latestRoster.isDropDisabled + ) { + return; + } + + const taskId = task.data.interactionId; + const request: PendingParticipantDropRequest = { + token: Symbol('participant-drop-request'), + taskId, + task, + dropTargetId: latestTarget.dropTargetId, + }; + + pendingParticipantDropRef.current = request; + setPendingParticipantDropId(latestTarget.dropTargetId); + setParticipantDropAnnouncement(null); + + try { + await task.dropConferenceParticipant({participantId: latestTarget.dropTargetId}); + + if ( + pendingParticipantDropRef.current === request && + participantDropTaskRef.current === request.task && + participantDropTaskRef.current?.data?.interactionId === request.taskId + ) { + setParticipantDropAnnouncement({type: 'success', message: PARTICIPANT_DROP_SUCCESS_MESSAGE}); + } + } catch { + if ( + pendingParticipantDropRef.current === request && + participantDropTaskRef.current === request.task && + participantDropTaskRef.current?.data?.interactionId === request.taskId + ) { + const sanitizedError = new Error(PARTICIPANT_DROP_FAILURE_MESSAGE); + + setParticipantDropAnnouncement({type: 'error', message: PARTICIPANT_DROP_FAILURE_MESSAGE}); + logger.error('CC-Widgets: Conference participant Drop failed', { + module: 'useCallControl', + method: 'dropConferenceParticipant', + }); + store.onErrorCallback?.('CallControlCAD', sanitizedError); + } + } finally { + if (pendingParticipantDropRef.current === request) { + pendingParticipantDropRef.current = null; + + if ( + participantDropTaskRef.current === request.task && + participantDropTaskRef.current?.data?.interactionId === request.taskId + ) { + setPendingParticipantDropId(null); + } + } + } + }, + [logger] + ); + const toggleMute = async () => { try { if (!controls?.main?.mute?.isVisible) { @@ -1261,6 +1368,10 @@ export const useCallControl = (props: useCallControlProps) => { secondsUntilAutoWrapup, cancelAutoWrapup, conferenceParticipants, + conferenceParticipantDropRoster, + pendingParticipantDropId, + participantDropAnnouncement, + dropConferenceParticipant, getAddressBookEntries, getEntryPoints, getQueuesFetcher, @@ -1304,12 +1415,17 @@ export const useOutdialCall = (props: useOutdialCallProps) => { return; } - // Only pass origin if it's defined and not empty - const outdialArgs = origin ? [destination, origin] : [destination]; + const outdialClient = cc as typeof cc & { + startOutdial(destination: string, origin?: string): Promise; + }; + + const outdialRequest = origin + ? outdialClient.startOutdial(destination, origin) + : outdialClient.startOutdial(destination); - cc.startOutdial(...outdialArgs) - .then((response) => { - logger.info('Outdial call started', response); + outdialRequest + .then(() => { + logger.info('Outdial call started'); }) .catch((error: Error) => { logger.error(`${error}`, { diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 94c1affc6..ac6f961c6 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -4,6 +4,7 @@ import { OutdialCallProps, RealTimeTranscriptComponentProps, RealTimeTranscriptEntry, + ParticipantDropAnnouncement, } from '@webex/cc-components'; import {RealTimeTranscriptionData} from '@webex/cc-store'; @@ -57,7 +58,7 @@ export type useOutdialCallProps = Pick; // Re-exported from store — single source of truth. export {CAMPAIGN_PREVIEW_OUTBOUND_TYPES, CAMPAIGN_PREVIEW_CAMPAIGN_TYPES} from '@webex/cc-store'; -export type {RealTimeTranscriptEntry}; +export type {RealTimeTranscriptEntry, ParticipantDropAnnouncement}; export interface OutdialProps { /** * Flag to determine if the address book is enabled. diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 3e7bae023..6965e79b3 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -57,6 +57,10 @@ describe('CallControl Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getEntryPoints: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getQueuesFetcher: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index b30905ba9..2dff4d1a6 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -60,6 +60,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -131,6 +135,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -184,6 +192,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -240,6 +252,10 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + dropConferenceParticipant: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx index 9b5ea11ee..659325f2e 100644 --- a/packages/contact-center/task/tests/call-control-recording.tsx +++ b/packages/contact-center/task/tests/call-control-recording.tsx @@ -76,8 +76,8 @@ const promoteTask = (task: FakeTask) => { store.store.agentId = AGENT_ID; // Registers the store's own task listeners (refreshTaskList on recording // pause/resume, etc.) exactly as production does. - store.handleIncomingTask(task); - store.setCurrentTask(task); + store.handleIncomingTask(task as unknown as Parameters[0]); + store.setCurrentTask(task as unknown as Parameters[0]); }; /** What the SDK does when the ContactRecordingPaused websocket event arrives. */ @@ -104,7 +104,6 @@ describe('CallControl recording pause/resume state', () => { info: jest.fn(), warn: jest.fn(), error: jest.fn(), - debug: jest.fn(), trace: jest.fn(), }; }); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..cdabf044f 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -206,6 +206,28 @@ describe('useIncomingTask Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + it('should reject an unaccepted consult exactly once on consult-end', async () => { + renderHook(() => + useIncomingTask({ + incomingTask: taskMock, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + const consultEndCallback = taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_CONSULT_END)?.[1]; + + act(() => { + consultEndCallback?.(); + }); + + await waitFor(() => { + expect(onTaskDeclined).toHaveBeenCalledTimes(1); + expect(onTaskDeclined).toHaveBeenCalledWith({task: taskMock}); + }); + }); + it('should return if there is no taskId for incoming task', async () => { // Reset the mock first onTaskDeclined.mockClear(); @@ -746,6 +768,7 @@ describe('useCallControl', () => { toggleMute: jest.fn(() => Promise.resolve()), switchCall: jest.fn(() => Promise.resolve()), transferConference: jest.fn(() => Promise.resolve()), + dropConferenceParticipant: jest.fn(() => Promise.resolve()), }; const mockLogger = mockCC.LoggerProxy; @@ -754,6 +777,69 @@ describe('useCallControl', () => { const mockOnEnd = jest.fn(); const mockOnWrapUp = jest.fn(); + const createParticipantDropTask = (dropRequest = jest.fn().mockResolvedValue(undefined)): ITask => + ({ + ...mockCurrentTask, + dropConferenceParticipant: dropRequest, + uiControls: createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}), + data: { + ...mockCurrentTask.data, + interactionId: 'participant-drop-task', + isConferenceInProgress: true, + interaction: { + ...mockCurrentTask.data.interaction, + interactionId: 'participant-drop-task', + mediaType: 'telephony', + mediaChannel: 'telephony', + state: 'conference', + owner: 'agent1', + contactDirection: {type: 'inbound'}, + callAssociatedDetails: {ani: '+15550000001', dnis: '+15550000002'}, + callProcessingDetails: {...mockCurrentTask.data.interaction.callProcessingDetails}, + media: { + main: { + mediaResourceId: 'main', + mediaType: 'telephony', + mediaMgr: 'aqm', + participants: ['agent1', 'agent2', 'customer1'], + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + }, + }, + participants: { + agent1: { + id: 'agent1', + pType: 'Agent', + type: 'Agent', + name: 'Current Agent', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + agent2: { + id: 'agent2', + pType: 'Agent', + type: 'Agent', + name: 'Agent Two', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + customer1: { + id: 'customer1', + pType: 'Customer', + type: 'Customer', + name: 'Customer', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + }, + }, + }, + }) as ITask; + beforeEach(() => { store.refreshTaskList(); // Mock the MediaStreamTrack and MediaStream classes for the test environment @@ -799,6 +885,7 @@ describe('useCallControl', () => { // Restore the original Worker class and URL.createObjectURL global.Worker = originalWorker; delete global.URL.createObjectURL; + store.onErrorCallback = undefined; jest.clearAllMocks(); }); @@ -837,6 +924,321 @@ describe('useCallControl', () => { setTaskCallbackSpy.mockRestore(); }); + describe('conference participant Drop', () => { + it('retains surviving Participants when Customer leaves and conference signals downgrade', () => { + const task = createParticipantDropTask(); + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'agent3', 'customer1']; + task.data.interaction.participants.agent3 = { + id: 'agent3', + pType: 'Agent', + type: 'Agent', + name: 'Agent Three', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + expect(result.current.conferenceParticipantDropRoster?.customer).not.toBeNull(); + + task.data.interaction.participants.customer1.hasLeft = true; + task.data.interaction.state = 'connected'; + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.customer).toBeNull(); + expect(result.current.conferenceParticipantDropRoster?.participants).toEqual([ + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'}), + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent3'}), + ]); + }); + + it('keeps the roster when Customer leaves only one other Agent', () => { + const task = createParticipantDropTask(); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + expect(result.current.conferenceParticipantDropRoster).not.toBeNull(); + + task.data.interaction.participants.customer1.hasLeft = true; + task.data.interaction.state = 'post_call'; + task.data.wrapUpRequired = true; + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + rerender(); + + expect(result.current.conferenceParticipantDropRoster).toEqual({ + customer: null, + participants: [expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'})], + isDropDisabled: false, + }); + }); + + it('shows the Entry Point number while ringing, then replaces it with the answering agent', async () => { + const dropRequest = jest.fn().mockResolvedValue(undefined); + const task = createParticipantDropTask(dropRequest); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.state = 'consulting'; + task.data.interaction.media.consult = { + mediaResourceId: 'consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + participants: ['agent1', 'entry-point-route'], + mType: 'consult', + isHold: true, + holdTimestamp: Date.now(), + }; + task.data.interaction.participants['entry-point-route'] = { + id: '+15550000009', + pType: 'entry-point-id', + type: 'EpDn', + name: 'EP-DN', + dn: '+15550000009', + hasJoined: false, + hasLeft: false, + isInPredial: false, + }; + task.uiControls.consult.endConsult = {isVisible: true, isEnabled: true}; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const pendingTarget = result.current.conferenceParticipantDropRoster?.participants.find( + (target) => target.dropTargetId === '+15550000009' + ); + + expect(pendingTarget).toEqual(expect.objectContaining({displayName: '+15550000009', isDropDisabled: true})); + if (!pendingTarget) throw new Error('Expected pending EP-DN target'); + + await act(async () => { + await result.current.dropConferenceParticipant(pendingTarget); + }); + + expect(dropRequest).not.toHaveBeenCalled(); + + task.data.interaction.media.consult.participants.push('agent2', 'agent3'); + task.data.interaction.participants.agent3 = { + id: 'agent3', + pType: 'Agent', + type: 'Agent', + name: 'Support Agent', + hasJoined: true, + hasLeft: false, + isInPredial: false, + isConsulted: true, + }; + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent2', + isDropDisabled: false, + }), + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'agent3', + isDropDisabled: true, + }), + ]); + }); + + it('delegates the exact target and publishes success only after the SDK promise resolves', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + act(() => { + requestPromise = result.current.dropConferenceParticipant(target); + }); + + expect(dropRequest).toHaveBeenCalledWith({participantId: 'agent2'}); + expect(result.current.pendingParticipantDropId).toBe('agent2'); + expect(result.current.participantDropAnnouncement).toBeNull(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'success', + message: 'Participant removed from the conference.', + }); + }); + + it('globally serializes same-target and cross-target requests', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const agentTarget = result.current.conferenceParticipantDropRoster?.participants[0]; + const customerTarget = result.current.conferenceParticipantDropRoster?.customer; + if (!agentTarget || !customerTarget) throw new Error('Expected Agent and Customer Drop targets'); + let firstRequest!: Promise; + + act(() => { + firstRequest = result.current.dropConferenceParticipant(agentTarget); + void result.current.dropConferenceParticipant(agentTarget); + void result.current.dropConferenceParticipant(customerTarget); + }); + + expect(dropRequest).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDrop(); + await firstRequest; + }); + }); + + it('reports a sanitized failure without logging participant data', async () => { + const sensitiveParticipantId = 'sensitive-participant-id'; + const dropRequest = jest.fn().mockRejectedValue(new Error(`Failed URL contained ${sensitiveParticipantId}`)); + const task = createParticipantDropTask(dropRequest); + task.data.interaction.media.main.participants[1] = sensitiveParticipantId; + task.data.interaction.participants[sensitiveParticipantId] = { + ...task.data.interaction.participants.agent2, + id: sensitiveParticipantId, + }; + delete task.data.interaction.participants.agent2; + const hostErrorCallback = jest.fn(); + store.onErrorCallback = hostErrorCallback; + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + + await act(async () => { + await result.current.dropConferenceParticipant(target); + }); + + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'error', + message: 'Unable to drop participant from the call. Try again.', + }); + expect(hostErrorCallback).toHaveBeenCalledWith( + 'CallControlCAD', + Error('Unable to drop participant from the call. Try again.') + ); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(sensitiveParticipantId); + expect(JSON.stringify(hostErrorCallback.mock.calls)).not.toContain(sensitiveParticipantId); + }); + + it('ignores stale completion and re-derives roster changes from the current task', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const firstTask = createParticipantDropTask(dropRequest); + const nextTask = createParticipantDropTask(); + nextTask.data.interaction.owner = 'agent2'; + let activeTask = firstTask; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.dropConferenceParticipant(target); + }); + activeTask = nextTask; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.participants[0].isReadOnly).toBe(true); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.participantDropAnnouncement).toBeNull(); + + nextTask.data.interaction.participants.agent2.hasLeft = true; + rerender(); + expect(result.current.conferenceParticipantDropRoster).toBeNull(); + }); + }); + it('should not call any call backs if callbacks are not provided', async () => { mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); @@ -5696,7 +6098,7 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); + expect(logger.info).toHaveBeenCalledWith('Outdial call started'); }); it('should successfully start an outdial call with origin', async () => { @@ -5713,7 +6115,7 @@ describe('useOutdialCall', () => { }); expect(mockOutdialCallProps.startOutdial).toHaveBeenCalledWith(destination, origin); - expect(logger.info).toHaveBeenCalledWith('Outdial call started', 'Success'); + expect(logger.info).toHaveBeenCalledWith('Outdial call started'); }); it('should show alert when destination is empty or only contains spaces', async () => {