diff --git a/.gitignore b/.gitignore index 0afe52a0e..563a57023 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ yarn-debug.log* yarn-error.log* .node_repl_history reports/ +.generated/ #IDEs .project diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index ed36076d2..e0d1d6c1a 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -96,6 +96,8 @@ Host ──call──> Widget(observer) ──call──> Hook(helper.ts) ── SDK ──event(CC_EVENTS/TASK_EVENTS)──> Store(runInAction) ──observable change──> Widget(observer) re-render Hook ──call──> cc-components (props) ──call──> ui-logging(withMetrics) ``` +For Consult/Transfer, `Task.uiControls.consultTransferDestinations` is the visibility and ordering source of truth. Task widgets pass the matching action array to `cc-components`; the store does not mirror Desktop Profile collaboration flags, and components do not derive policy from raw task/profile data. + | From | To | Kind | Purpose | |---|---|---|---| | Widget (observer) | Custom hook (`helper.ts`) | call | Read derived state, obtain action callbacks | diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 11862cc93..07c59c20f 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -23,7 +23,8 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.DigitalChannels | `@webex/cc-digital-channels` | `DigitalChannels` | React component; custom element `widget-cc-digital-channels`; no declared props | stable semver | `packages/contact-center/cc-digital-channels/ai-docs/cc-digital-channels-spec.md` | `packages/contact-center/cc-digital-channels/src/index.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | -| store.types | `@webex/cc-store` | Type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `AgentLogin`, `IStore`, `ILogger`, `InitParams`, `IWebex`, `RealTimeTranscriptionData`, plus ~20 more) | TypeScript `type`/`interface` exports describing the SDK-backed domain surface | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:334-366` | +| store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | +| cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action`, `availableDestinations`, and optional list-item `presence` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list/category order, passes buddy availability to Momentum Avatar as semantic presence, shows `AddressBookEntry.number` and `EntryPointRecord.number` subtitles, and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts; optional presentation prop is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md`; `ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | | store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | @@ -32,7 +33,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store (`packages/contact-center/store/src/storeEventsWrapper.ts`) | Host establishes the authenticated Webex session; SDK assumed reachable | `Store.init()` rejects after a 6s init timeout; widgets stay inert and surface error UI (`packages/contact-center/store/src/store.ts:140-142`) | Pinned by the SDK dependency in each package's `package.json` | +| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established queue records and entry-point response wrapper with mapped `number`, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | Pinned or locally linked by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md new file mode 100644 index 000000000..07a3174b4 --- /dev/null +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -0,0 +1,314 @@ +--- +type: Feature Spec +title: Consult and transfer list policy +description: Keep consult and transfer destination eligibility and ordering consistent while leaving widgets as a thin SDK consumer. +tags: [feature, specification, contact-center, consult-transfer] +--- + +# Consult and transfer list policy + +This document owns the feature's what and why. The paired SDK delta owns reusable destination-list policy and the ordered destination availability attached to each Task; this repository owns list loading, selection UI, host hide overrides, and error presentation. + +Related context: [repository architecture](../../../ARCHITECTURE.md) · [specification index](../../../SPEC_INDEX.md) · [repository instructions](../../../../AGENTS.md) + +## Metadata + +| Field | Value | +| --- | --- | +| Feature key | `CAI-8354` | +| Owner | Webex Contact Center widgets maintainers | +| Status | Approved and implemented; diff-scoped drift validation PASS; independent validation pending | +| Work type | Defect | +| Change class | Contract / UI | +| Source/intake | Developer-approved consult/transfer behavior review and current code/tests | +| Last verified | 2026-08-21 in the approved SDK/widgets worktrees | + +## Applicability + +| Condition ID | Status | Evidence or reason | Owned section | +| --- | --- | --- | --- | +| `feature.feature_nontrivial` | Applicable | `packages/contact-center/store/src/storeEventsWrapper.ts` | Feasibility and risks | +| `feature.feature_interactions` | Applicable | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | Interaction and scenario matrix | +| `feature.touches_data_shapes` | Applicable | `packages/contact-center/store/src/store.types.ts` | Requested data and fields | +| `feature.backward_compat` | Applicable | `packages/contact-center/store/package.json` | Migration expectations | +| `feature.perf_critical` | N/A | This change does not add client-side processing or a new request fan-out. | Scale and performance | +| `feature.security_compliance` | N/A | The store forwards authenticated SDK calls and adds no credential or authorization ownership. | Security and compliance | +| `feature.needs_rollout` | N/A | No widget feature flag or staged runtime path is introduced. | Rollout and feature controls | +| `feature.serviceability` | Applicable | `packages/contact-center/task/src/helper.ts` | Serviceability | +| `feature.doc_obligations` | Applicable | `packages/contact-center/store/ai-docs/store-spec.md` | Documentation obligations | +| `feature.changes_ui` | Applicable | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | UI flow and design | +| `feature.changes_api` | Applicable | `packages/contact-center/store/src/store.types.ts` | API contract delta | +| `feature.changes_events` | N/A | No event name, payload, producer, consumer, or delivery order changes. | Event contract delta | +| `feature.changes_public_api` | Applicable | `packages/contact-center/cc-components/src/components/task/task.types.ts` | Public API and semver impact | +| `feature.cross_package` | Applicable | `packages/contact-center/store/package.json` | Cross-package impact | + +## Problem and goal + +The widgets previously embedded destination policy in the store: buddy agents were always restricted to Available, queues were fetched through the generic SDK API and filtered in memory by channel, and list response metadata was reconstructed. That made behavior inconsistent across consumers and made list order dependent on widget-side transformation. + +The goal is for widgets to use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods. Widgets supply the user's action and pagination/search input; the store uses current-task media only to override the SDK's queue default for an active non-telephony task. Entry points delegate directly so the SDK can fetch and map the profile-scoped dial-number records. The SDK returns eligible, backend-ordered lists and their metadata. Widgets must not transform returned rows, choose ordering, or reconstruct pagination semantics. + +## Stakeholders and open questions + +| Stakeholder | Need or decision | Status | +| --- | --- | --- | +| Contact Center agents | Consult and transfer destination lists have consistent eligibility and order. | Decided | +| Widget maintainers | Destination business policy remains outside React and MobX UI code. | Decided | +| SDK maintainers | Existing SDK services own default list eligibility, profile views, and ordering; no consult/transfer-specific list method or response abstraction is added. | Decided in the paired SDK delta | +| Host applications | Existing widget UI behavior remains compatible apart from the corrected destination results. | Decided | + +There are no open product decisions for this delta. + +## Scope + +### In scope + +- Pass `Consult` or `Transfer` from the call-control menu and reload action to the SDK through the task hook and store. +- Forward pagination, page size, and search text through the existing list APIs. For an active non-telephony task, the store supplies a complete channel eligibility filter only to the queue request because the generic queue method does not receive Task context. Entry-point requests delegate without widget-owned filters. +- Load dial numbers through the generic SDK AddressBook service and rely on its default backend ordering. +- Preserve the SDK's `data` order and pagination metadata without local sorting, channel filtering, or metadata reconstruction. +- Keep loading, empty, and error behavior in the existing widget layers. +- Use the locally linked SDK worktree while verifying the coordinated change. + +### Out of scope + +- Reimplementing queue, entry-point, or buddy-agent eligibility in widgets. +- Sorting any destination array in React, the task hook, or the store. +- Supplying `sortBy` or `sortOrder` from the consult/transfer widget path. +- Changing backend ordering or adding a widget feature flag. +- Committing, publishing, or pushing either repository. + +## Prior work and evidence + +| Source | What it establishes | Decision or disposition | +| --- | --- | --- | +| `packages/contact-center/store/src/storeEventsWrapper.ts` | The old store filtered queue data and rebuilt pagination metadata; the new store delegates to the existing SDK methods and only supplies active non-telephony task context to queue requests through the existing filter. | Used | +| `packages/contact-center/task/src/helper.ts` | The task hook owns UI loading/error state and forwards list input. | Used | +| `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` | The selected menu identifies Consult versus Transfer. | Used | +| `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Reload has the same action context as initial list loading. | Used | +| `packages/contact-center/store/tests/storeEventsWrapper.ts` | Store delegation, action forwarding, metadata preservation, and error propagation are asserted. | Used | +| `packages/contact-center/task/tests/helper.ts` | Hook forwarding and safe empty-page fallback are asserted. | Used | +| `packages/contact-center/cc-components/tests/components/task/CallControl` | Initial load and reload preserve action context. | Used | + +## Requirements + +| ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence | +| --- | --- | --- | --- | --- | --- | --- | +| `WIDGET-LIST-R-001` | The store must use the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and the existing AddressBook service for dial numbers. It must not filter or sort returned rows or reconstruct pagination metadata. | Reusing established methods and response types keeps the public surface small while SDK-owned defaults prevent policy drift. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Requires the paired SDK default-policy delta at runtime. | Present | +| `WIDGET-LIST-R-002` | Opening or reloading the Agents list must forward the active `Consult` or `Transfer` action through the component, hook, and store. The store must also continue accepting the established media-type call form for direct consumers. | Agent eligibility differs by action, so losing the action would silently return the wrong population; retaining the old call form avoids an unnecessary breaking store change. | `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/task/src/helper.ts`, `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts` | None. | Present | +| `WIDGET-LIST-R-003` | Queue and entry-point requests must forward page, page size, and search. When the active task is non-telephony, the store must pass a complete inbound/active/channel filter only to the queue request and combine it with any caller filter; params-only telephony or missing-media calls omit that override. The queue loader must accept both the thin params-only form and the established media-plus-params form; the legacy form retains explicit channel scoping, and params-only calls preserve an explicit empty filter. Entry-point and dial-number requests delegate without widget-owned filter or sort policy. | The SDK's generic queue method has no Task argument and cannot infer which concurrent task is being rendered, while entry-point profile/dial-number mapping is reusable SDK policy. Combining filters prevents caller input from erasing required Task scope, and exact compatibility handling avoids breaking existing store consumers or swallowing an SDK override. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | No new SDK method or response type is required. | Present | +| `WIDGET-LIST-R-004` | Widgets must render destination arrays in the order supplied by the SDK and preserve the SDK pagination metadata object. | Backend-selected ordering must not be changed or made inconsistent by a second client sort. | `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Presentational virtualized-list behavior remains unchanged. | Present | +| `WIDGET-LIST-R-005` | Store failures must be logged and rethrown; the task hook must convert destination-page failures to an empty page and buddy-agent failures to an empty agent list while ending loading state. | Existing UI boundaries need predictable empty/error behavior without hiding failures at the SDK/store boundary. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts` | Existing UI error presentation remains unchanged. | Present | +| `WIDGET-LIST-R-006` | Store and component types must reuse `BuddyAgents`, `TaskUIControls`, `ContactServiceQueueSearchParams`, `ContactServiceQueuesResponse`, `EntryPointSearchParams`, `EntryPointListResponse`, `ContactServiceQueue`, and `EntryPointRecord` without `any`; no one-off action, media, destination-control, destination-list, list-options, or list-response public type may be introduced. | Consumers only need the existing methods, lists, and Task control field. Deriving destination typing from `TaskUIControls` avoids parallel public abstractions and misleading projected-record types. | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/cc-components/src/components/task/task.types.ts` | `packages/contact-center/store/tests/storeEventsWrapper.ts`, `packages/contact-center/task/tests/helper.ts`, `packages/contact-center/cc-components/tests/components/task/CallControl` | The local SDK link is required until a released SDK contains the default behavior. | Present | +| `WIDGET-LIST-R-007` | CallControl must read the matching ordered category array from `currentTask.uiControls.consultTransferDestinations`, pass it directly to the popover, and render categories in that order. Widgets must not use collaboration profile flags or media/direction/task payload fields to derive visibility, and must not fetch buddy agents when Agents is omitted; the legacy `allowConsultToQueue` store/property pass-through remains exported only for compatibility and is not consumed by this UI. Host options may only hide Dial Number or Entry Point after the SDK decision. | One SDK Task control surface prevents policy drift while retaining the existing public widgets surface, fixes incorrect payload-path reads, makes the SDK-provided first category the default selection, and avoids loading data for a disallowed category. | `packages/contact-center/task/src/CallControl/index.tsx`, `packages/contact-center/task/src/CallControlCAD/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/store/src/storeEventsWrapper.ts` | `packages/contact-center/cc-components/tests/components/task/CallControl`, `packages/contact-center/store/tests/storeEventsWrapper.ts` | Consumers cannot enable a category omitted by the SDK; compatibility fields do not participate in the decision. | Present | +| `WIDGET-LIST-R-008` | Agent rows must pass Momentum Avatar presence `active` for SDK state `Available` and `away` for every other state. Every destination avatar must derive initials from the first character of the first and last non-empty name tokens, using one character for a single-token name. Dial-number and entry-point rows must show their typed SDK `number` as secondary text below the name. | The design-system Avatar owns presence presentation, first/last-token initials keep multi-token destination labels distinguishable, and secondary identifiers distinguish similarly named routable destinations without widget-side response transformation. | `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Entry-point `number` requires the paired SDK dial-number mapping. | Present | +| `WIDGET-LIST-R-009` | Buddy-agent state and loading must be updated only by the newest request, including when the action switches between Consult and Transfer while a request is in flight. | A slower Consult response must not overwrite the Transfer population currently selected by the user. | `packages/contact-center/task/src/helper.ts` | `packages/contact-center/task/tests/helper.ts` | The underlying request is not cancelled; its stale result is ignored. | Present | + +## Defect context (when applicable) + +- Observed versus expected behavior: widgets could show a different destination population/order because they always requested Available agents, used generic queue/entry-point APIs, locally filtered queues, and rebuilt metadata; expected behavior is the SDK-owned request policy with no widget-side sorting or filtering. +- Reproduction and environment: open Consult and Transfer destination popovers for the same active task and compare Agents, Queues, and Entry Points across SDK and widget consumers. +- Regression range or last known good state: unknown; the previous widget implementation predates this coordinated SDK policy. +- Severity, frequency, and workaround: user-visible whenever backend order or action eligibility differs; no reliable host-side workaround. +- Diagnostic evidence: `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. + +## MODIFIED Requirements + +### MOD-001 — Store list delegation (`STORE-R-015`) + +- **WHAT**: Replace widget-owned returned-row filtering and metadata reconstruction with delegation to the SDK's existing queue and entry-point methods. Keep dial numbers on the existing AddressBook service. Pass pagination/search plus a complete non-telephony channel filter only to queue requests when the active Task requires an override, combine that scope with a caller filter, and preserve the established media-plus-params store call as a compatibility overload; delegate entry points without widget policy and preserve each SDK response as returned. +- **WHY**: Eligibility, backend query flags, and ordering defaults are reusable domain policy and must not diverge across UI clients. +- **Evidence:** `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/store/tests/storeEventsWrapper.ts`. +- **Acceptance:** No queue, entry-point, or dial-number returned-data `.sort()`/`.filter()` exists in the store list path, and tests prove response order and metadata are unchanged. Telephony queue requests rely on SDK defaults; non-telephony queue requests use only the existing `filter` option without losing a caller filter; both queue call forms work; entry points always delegate directly. + +### MOD-002 — Task consult/transfer orchestration (`TASK-R-011` through `TASK-R-014`) + +- **WHAT**: Extend the existing consult/transfer flow so list loading carries `Consult` or `Transfer`; preserve the store's legacy media-type buddy call for compatibility; make buddy state latest-request-wins; queue loading no longer derives and passes an independent media argument from the hook. +- **WHY**: The action affects buddy-agent eligibility, while media and request policy must be resolved once at the store/SDK boundary. +- **Evidence:** `packages/contact-center/task/src/helper.ts`, `packages/contact-center/task/tests/helper.ts`. +- **Acceptance:** Transfer loading reaches the store as `Transfer`, Consult remains the default, legacy media callers keep working, a late request from the prior action cannot overwrite the current agent list, and paginated queue inputs contain only page, page size, and search. + +### MOD-003 — Call-control action context (`CC-COMPONENTS-R-006`) + +- **WHAT**: Initial menu opening and agent-list reload must call the loader with the active menu action. Category visibility, order, and initial selection come from the matching SDK Task destination-control array; widget wrappers no longer build interaction context or forward raw profile access flags. +- **WHY**: A reload must not silently revert Transfer eligibility to Consult eligibility, and UI consumers must not duplicate the SDK's destination policy. +- **Evidence:** `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `packages/contact-center/cc-components/tests/components/task/CallControl`. +- **Acceptance:** Component tests cover action-preserving reload, SDK category omission, SDK category order, and host-only hide overrides. + +## Acceptance criteria + +- [x] Consult agent loading forwards `Consult`; Transfer agent loading forwards `Transfer` on initial open and reload (`MOD-002`, `MOD-003`, `WIDGET-LIST-R-002`). +- [x] Consult/Transfer category visibility, order, and default selection come from `Task.uiControls`; widgets contain no collaboration-profile/direction policy (`MOD-003`, `WIDGET-LIST-R-007`). +- [x] Queue and entry-point fetchers delegate to existing SDK methods, while dial numbers use AddressBook, without local returned-data sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). +- [x] Params-only telephony queue requests rely on SDK defaults; active non-telephony requests supply a complete channel filter, and legacy media-plus-params calls preserve their channel scope and caller filter. Entry-point requests always delegate directly, and no list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). +- [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). +- [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with the linked SDK (`WIDGET-LIST-R-006`). +- [x] Agent rows show active/away presence from buddy state, and dial-number/entry-point rows show their typed secondary identifiers (`WIDGET-LIST-R-008`). +- [x] A late buddy-agent response from a previous Consult/Transfer action cannot overwrite the current action's list or loading state (`WIDGET-LIST-R-009`). +- [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with the coordinated SDK worktree. +- [x] The complete cc-components unit suite and the focused consult/transfer suites pass with the locally linked SDK. + +## Scenarios and applicable change views + +| Scenario | Actor | Preconditions | Expected behavior | Failure or boundary behavior | Requirements | +| --- | --- | --- | --- | --- | --- | +| Open Consult Agents | Agent | Active task and Consult selected | UI forwards `Consult`; SDK result order is rendered unchanged and each row shows active/away presence from buddy state. | SDK failure produces an empty agent list and clears loading; a stale request cannot replace newer state. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008`, `WIDGET-LIST-R-009` | +| Open Transfer Agents | Agent | Active task and Transfer selected | UI forwards `Transfer`; SDK applies transfer eligibility. | Reload retains `Transfer`; a late Consult response cannot replace Transfer results. | `WIDGET-LIST-R-002`, `WIDGET-LIST-R-009` | +| Search Queues | Agent | Active task with media context | Page/search reach the existing SDK method; a non-telephony Task adds a complete channel filter, and the response and metadata are preserved. | Telephony or missing media lets SDK defaults apply. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Search Entry Points | Agent | Entry-point tab visible | Page/search reach the existing SDK method without widget policy; backend order is rendered and typed `number` appears below the name when present. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | +| Search Dial Numbers | Agent | Address book enabled | Page/search input reaches AddressBook, its SDK-default backend order is rendered, and `number` appears below the name. | Failure becomes the existing empty paginated result. | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`, `WIDGET-LIST-R-005`, `WIDGET-LIST-R-008` | + +### Interaction and scenario matrix + +| Context or interacting state | Trigger | Expected result | Invalid or conflicting result | Requirements | +| --- | --- | --- | --- | --- | +| Consult + Agents | Open or reload | `loadBuddyAgents('Consult')` | Applying Transfer-only availability filtering | `WIDGET-LIST-R-002` | +| Transfer + Agents | Open or reload | `loadBuddyAgents('Transfer')` | Reloading with the default Consult action | `WIDGET-LIST-R-002` | +| Queue + non-telephony current task media | Fetch/search/page | Store calls existing `getQueues` with a complete channel filter and preserves SDK order | Hook/store filters returned rows or sorts them again | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Telephony or no current task media + params-only call | Queue fetch | Store supplies no policy override and SDK defaults apply | Widget supplies redundant sort, projection, or profile-view flags | `WIDGET-LIST-R-003` | +| Entry point | Fetch/search/page | Store calls existing `getEntryPoints` directly and preserves SDK rows/order | Widget filters returned rows or supplies query policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Dial number | Fetch/search/page | Store forwards pagination/search only and preserves SDK order | Widget supplies media or sort policy | `WIDGET-LIST-R-001`, `WIDGET-LIST-R-003`, `WIDGET-LIST-R-004` | +| Consult buddy request in flight | User opens or reloads Transfer Agents | Only the Transfer request may update agents and loading state | Late Consult response replaces the Transfer list | `WIDGET-LIST-R-009` | + +### UI flow and design + +The popover, tabs, pagination, loading indicators, empty states, and accessibility labels remain unchanged. Agent avatars pass semantic active/away presence derived directly from `BuddyDetails.state` to the Momentum Avatar; dial-number and entry-point rows show their typed `number` below the name. The rendered list order remains exactly the SDK response order. + +### API contract delta + +| API or operation | Change | Consumer impact | Compatibility expectation | Canonical definition | +| --- | --- | --- | --- | --- | +| Store buddy-agent loader | Adds a `Consult`/`Transfer` action overload while retaining the established optional media-type form. | Task and component layers pass user intent; existing direct store callers remain valid. | Additive overload; coordinated SDK behavior update required. | `packages/contact-center/store/src/store.types.ts` | +| Store queue loader | Adds a thin params-only overload while retaining the established media-plus-params form, then delegates to SDK `getQueues`; the store adds a non-telephony filter from Task context when needed. | Widget callers omit redundant media plumbing; existing direct store callers remain valid. | Additive overload; no new SDK method or response type. | `packages/contact-center/store/src/store.types.ts` | +| Store entry-point loader | Retains the existing SDK-compatible search-parameter and full-response signature and delegates directly to `getEntryPoints`. | Callers keep the established entry-point list contract and receive SDK-mapped numbers. | No new SDK method or response type; coordinated SDK default update required. | `packages/contact-center/store/src/store.types.ts` | +| Store dial-number loader | Delegates pagination/search to the generic SDK AddressBook service. | The SDK default supplies backend name ordering. | Existing SDK surface with a corrected default. | `packages/contact-center/store/src/store.types.ts` | +| Consult/transfer list-item props | Adds optional semantic `presence` passed directly to Momentum Avatar. | Agent rows expose SDK availability while leaving visual presentation to the design system. | Additive optional prop. | `packages/contact-center/cc-components/src/components/task/task.types.ts` | + +### Public API and semver impact + +| Export or entry point | Change | Affected consumers | Required version change | Deprecation or migration | +| --- | --- | --- | --- | --- | +| `@webex/cc-store` loader types | Buddy loading adds Consult/Transfer action context and queue loading adds a params-only form; both established call forms remain accepted. Entry-point loaders retain existing SDK request/response types. | Internal widget packages and any direct store consumer | Additive overloads; queue and entry-point list shapes remain established. | No required direct-consumer migration; new widget code should use action and params-only forms. | +| `@webex/cc-components` call-control loader prop | Optional action parameter | Call-control consumers | Additive callback argument for compatible functions; coordinate typings | Consumers may ignore the argument, but action-aware loaders should use it. | +| `@webex/cc-components` list-item props | Optional `active`/`away` `presence` value | Internal list rows and direct type consumers | Additive optional prop | Consumers that omit it retain the plain avatar. | + +### Cross-package impact + +| Package | Change | Dependency direction | Release sequencing | Owner | +| --- | --- | --- | --- | --- | +| `@webex/contact-center` | Applies consult/transfer defaults through existing `getBuddyAgents`, `getQueues`, and `getEntryPoints`; Queue returns full records and EntryPoint maps EP-DN rows through its existing response wrapper. | SDK → store | Build/link first. | SDK maintainers | +| `@webex/cc-store` | Thin delegation and typed boundary. | store → SDK | Release with a compatible SDK version. | Widgets maintainers | +| `@webex/cc-task` | Carries action and pagination/search. | task → store | Release after store types. | Widgets maintainers | +| `@webex/cc-components` | Carries menu action on open/reload. | components → task callback | Release with task package. | Widgets maintainers | + +## Contracts delta + +**Provides — MODIFIED:** The widget packages provide action-aware list loading and preserve SDK result order/metadata without owning eligibility policy. + +**Requires — MODIFIED:** The store requires the SDK's existing `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and their existing queue/entry-point request and response types, plus the action-aware buddy-agent option and Task destination controls. + +No event contract changes. + +## Success and guardrail metrics + +| Metric | Baseline | Target | Measurement source | +| --- | --- | --- | --- | +| Widget-side consult/transfer queue filters or sorts | Present | 0 | `packages/contact-center/store/src/storeEventsWrapper.ts` | +| Action loss on initial/reload agent fetch | Possible | 0 covered paths | `packages/contact-center/cc-components/tests/components/task/CallControl` | +| Touched package unit failures | Unknown before change | 0 | Store, task, and cc-components unit suites | +| Touched package build/style failures | Unknown before change | 0 | Store, task, test-fixtures, and cc-components build/style commands | + +## Requested data and fields + +| Entity or payload | Requested field or shape | Purpose | Ownership | Privacy, retention, or compatibility constraint | +| --- | --- | --- | --- | --- | +| Buddy-agent request | `action`, optional current task `mediaType` | Distinguish Consult and Transfer eligibility. | SDK contract; widgets supply runtime context. | No new retained data or credentials. | +| Queue list request | Existing queue search parameters; params-only calls add a complete filter only for an active non-telephony Task, while the legacy media form retains explicit channel scope. | Paginated destination discovery. | SDK owns reusable telephony eligibility, profile-view, and ordering defaults; store owns active Task context and legacy-call compatibility. | No new method or response; no widget-side returned-data filtering. | +| Entry-point list request | Existing entry-point search parameters, delegated without widget-owned filter policy. | Paginated destination discovery with mapped dialled numbers. | SDK owns the profile-scoped dial-number query, row mapping, and ordering defaults. | No new method or signature; no widget-selected filter, projection, view, or sort flags. | +| Dial-number list request | `page`, `pageSize`, `search` | Paginated address-book destination discovery. | AddressBook owns backend name ordering. | No widget-owned sort flags. | +| Buddy-agent row | `state` | Pass Avatar presence `active` only for `Available`; pass `away` otherwise. | SDK response owns agent state; Momentum owns presence presentation. | No identity or state value is logged or retained. | +| Dial-number / entry-point row | `AddressBookEntry.number` / optional `EntryPointRecord.number` | Show a secondary routable identifier below each destination name. | SDK response owns the fields; cc-components renders them unchanged. | Entry-point numbers come from the SDK's dial-number mapping. | +| Queue/entry-point paginated response | Existing `ContactServiceQueuesResponse` with full `ContactServiceQueue` rows and `EntryPointListResponse` with SDK-mapped `{id, name, number?}` `EntryPointRecord` rows; `meta` remains unchanged. | Preserve backend order, established typing, and pagination truth. | SDK/backend | Widgets must not project rows or reconstruct metadata. | + +## Impacted domains + +| Repository or module | Impact | Owner | +| --- | --- | --- | +| `packages/contact-center/store` | SDK boundary and response preservation | Widgets maintainers | +| `packages/contact-center/task` | Action and list-input forwarding | Widgets maintainers | +| `packages/contact-center/cc-components` | Initial/reload action propagation | Widgets maintainers | +| `packages/contact-center/test-fixtures` | Type-compatible fixtures | Widgets maintainers | + +## Feasibility and risks + +| Risk or assumption | Evidence | Mitigation or decision owner | +| --- | --- | --- | +| Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Coordinate the SDK dependency/release; use the approved local worktree link for testing. | +| A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | +| Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | +| A Consult buddy request completes after the user switches to Transfer. | `packages/contact-center/task/src/helper.ts` | Track the newest buddy request and ignore older data, error fallback, and loading-state updates. | + +## Error Matrix + +| Failure | Store behavior | Task/UI behavior | Evidence | +| --- | --- | --- | --- | +| Buddy-agent SDK rejection | Log and rethrow. | Log, clear agents, end loading. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | +| Queue SDK rejection | Log and rethrow. | Log and return an empty paginated result. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | +| Entry-point SDK rejection | Log and rethrow. | Log and return an empty paginated result. | `packages/contact-center/store/src/storeEventsWrapper.ts`, `packages/contact-center/task/src/helper.ts` | +| Missing current task media | Omit media from the SDK request. | Existing UI flow continues. | `packages/contact-center/store/src/storeEventsWrapper.ts` | + +## Resilience + +- The change adds no retry or duplicate request loop; existing component reload remains the explicit retry mechanism. +- Buddy-agent loading retains only the newest action request, preventing a prior Consult/Transfer response from replacing the selected action's data or loading state. +- Empty fallbacks remain confined to the task/UI boundary, while the store preserves rejection semantics for other consumers. +- Response order and pagination are not cached or reconstructed by widgets. + +## Observability + +- Existing store and task error logs remain the diagnostic surface. +- Successful buddy-agent loading retains the count-only informational log; no agent identities or list contents are logged. +- No new metric, trace, alert, or PII-bearing log is introduced. + +## Operations + +- Build/link the paired SDK before widgets so the corrected defaults and existing types resolve. +- Run the store, task, and cc-components unit suites plus touched package builds/styles before release. +- Roll back widgets and SDK together if the coordinated behavior is incompatible; no data migration or cleanup is required. + +## Migration expectations + +- Compatibility: queue, entry-point, and legacy buddy-agent callers retain their existing methods and call forms; action-aware buddy loading and params-only queue loading are additive overloads. +- Data or consumer transition: release a compatible SDK before or with the store, then task and component packages. +- Coexistence period: local development uses the approved SDK worktree link; published packages must use a released compatible SDK. +- Completion and rollback outcome: all packages resolve the existing types and list methods; rollback is a coordinated dependency/code rollback with no persisted state. + +## Serviceability + +| Signal or support surface | Required change | Consumer or operator | Acceptance evidence | +| --- | --- | --- | --- | +| Store error log | Preserve SDK rejection context without list contents. | Widget maintainers | `packages/contact-center/store/tests/storeEventsWrapper.ts` | +| Task error log | Preserve list kind and operation context. | Widget maintainers | `packages/contact-center/task/tests/helper.ts` | +| Buddy load info log | Record count only. | Widget maintainers | `packages/contact-center/task/src/helper.ts` | + +## Documentation obligations + +- This approved delta modifies `STORE-R-015`, the task consult/transfer requirement family, and `CC-COMPONENTS-R-006` without overwriting the draft canonical module specs. +- The paired SDK feature spec remains the canonical owner for reusable queue eligibility/order/profile defaults and entry-point mapping/order/cache behavior. This widget delta owns only the active non-telephony queue filter override needed because the generic queue call carries no Task context. +- A future canonical-spec promotion must fold this delta into the routed module specs and reconcile the delta path rather than duplicate the requirements. + +## Decision and change log + +| Date | Decision or change | Rationale | Owner | +| --- | --- | --- | --- | +| 2026-08-21 | Preserved legacy buddy media and queue media-plus-params store calls, combined Task and caller queue filters, and made buddy-agent state latest-request-wins across action changes. | The simplified widget calls must not break direct store consumers, erase required digital Task scope, or allow a stale Consult response to replace Transfer results. | Developer + Codex | +| 2026-08-21 | Changed destination initials from the first two tokens to the first and last non-empty tokens. | Multi-token destination labels must yield compact, distinguishable initials such as `Queue e2e 1 → Q1` and `Entry point e2e set 1 → E1`. | Developer + Codex | +| 2026-08-21 | Mapped SDK availability to the Momentum Avatar's built-in `active`/`away` presence and removed widget-owned icon/color/position CSS. The provisional entry-point `dbId` subtitle was replaced with SDK-mapped `EntryPointRecord.number`, and entry-point requests now delegate without a task-media filter. | Figma and the installed design-system API assign presence presentation to Avatar, while the backend's dial-number mapping supplies the visible entry-point number and owns its query policy. | Developer + Codex | +| 2026-08-19 | Removed dependencies on new action/media/destination aliases and typed widget destinations from `TaskUIControls`; removed the queue `dbId` passthrough fixture. | Widgets need the existing methods, entity records, and Task control field only; deriving types prevents an unnecessary public SDK surface. | Developer + Codex | +| 2026-08-19 | Approved this exact MODIFIED delta path. | Avoid overwriting draft canonical module specs while keeping spec-currency with the implementation. | Developer | +| 2026-08-19 | Assigned reusable list policy to the SDK and retained only UI/runtime context in widgets. | Prevent policy duplication and ordering drift. | Developer + Codex | +| 2026-08-19 | Explicitly prohibited widget-side sorting/filtering of SDK destination results. | Preserve the backend order selected by the SDK request. | Developer + Codex | +| 2026-08-19 | Reused the existing queue and entry-point methods and full response types; removed the one-off consult/transfer destination/list abstractions. | Widget consumers need lists, not a parallel public model or method family. | Developer + Codex | +| 2026-08-19 | Removed widget selection of entry-point media and the consult-specific dial-number helper. | EntryPoint and AddressBook defaults must work for widgets out of the box; other SDK consumers can pass explicit overrides. | Developer + Codex | +| 2026-08-19 | Removed the widget/store destination-policy utility and raw profile/context plumbing; CallControl now renders the matching ordered `Task.uiControls.consultTransferDestinations` array. | Task already contains the live interaction and SDK-computed UI decisions, so no extra policy call or duplicated consumer logic is needed. | Developer + Codex | +| 2026-08-19 | Limited widget-owned list policy to a complete non-telephony request filter selected from the active Task media. | The existing generic SDK methods have no Task parameter and cannot infer which concurrent Task the UI is rendering; all reusable defaults and response decisions remain in the SDK. | Developer + Codex | diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 7f8cc7078..9edaa7e52 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,6 +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.consult-transfer-lists` | SDK callback props | `getQueuesFetcher`/`getQueues` use `FetchPaginatedList` and `getEntryPoints` uses `FetchPaginatedList` | Render the SDK's established queue and entry-point records directly in backend order | types track the existing SDK/store entity contracts | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -121,7 +122,7 @@ Compatibility notes: ## Requires (dependencies) -- `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. +- `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `ContactServiceQueue`, `EntryPointRecord`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. - `@webex/cc-ui-logging` (workspace:\*) — `withMetrics` HOC wrapping each top-level component for mount/metrics tracking. - `@momentum-ui/react-collaboration` (peer `>=26.197.0`) and `@momentum-design/components/dist/react` — UI primitives. - `@r2wc/react-to-web-component` `2.0.3` — custom-element wrapping in `wc.ts`. @@ -138,7 +139,7 @@ Compatibility notes: | `CC-COMPONENTS-R-003` | `StationLoginComponent` invokes the supplied callbacks (`login`, `setDeviceType`, `setDialNumber`, `handleContinue`, `saveLoginOptions`) on the matching user action and surfaces `loginFailure`/`saveError` as error UI. | Login intent and errors must propagate to the widget layer without the component owning login logic. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`calls login function...`, `renders login failure when passed`, `renders save error when passed`) | None | PRESENT | | `CC-COMPONENTS-R-004` | `StationLoginComponent` hides the Desktop login option when `hideDesktopLogin` is true (in both login and profile mode) and shows it when false/undefined. | Deployments can disable Desktop login; must be honored consistently across modes. | `src/components/StationLogin/station-login.tsx`, `src/components/StationLogin/station-login.utils.tsx` | `tests/components/StationLogin/station-login.tsx` (`hides Desktop login option when hideDesktopLogin is true`, `... when false`, `... when undefined`, `... in profile mode`) | None | PRESENT | | `CC-COMPONENTS-R-005` | `UserStateComponent` renders idle codes sorted/built into the dropdown, reflects `currentState`/`elapsedTime`, and calls `setAgentStatus(auxCodeId)` on selection; error-triggering idle codes are styled distinctly. | Agent must change state and see correct current state + timing; error idle codes need visual emphasis. | `src/components/UserState/user-state.tsx`, `src/components/UserState/user-state.utils.ts` (`buildDropdownItems`, `sortDropdownItems`, `handleSelectionChange`, `getDropdownClass`) | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-006` | `CallControlComponent` builds its button set from `controlVisibility` and current task, and routes button presses to the matching callback (`toggleHold`, `toggleMute`, `toggleRecording`, `endCall`, `wrapupCall`, consult/transfer/conference handlers); wrapup requires selecting a reason. | Call control must reflect the allowed actions for the current interaction state and emit the right intent. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/call-control.utils.ts` (`buildCallControlButtons`, `filterButtonsForConsultation`, `handleWrapupCall`) | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-006` | `CallControlComponent` builds its button set from Task controls, passes the matching SDK-ordered `consultTransferDestinations` action array into the popover, and routes button presses to the matching callback; wrapup requires selecting a reason. The popover renders destination categories in supplied order and may only remove Dial Number/Entry Point through explicit host hide options. | Call control must reflect SDK decisions without reconstructing profile/media/direction policy, while retaining supported host presentation overrides. | `src/components/task/CallControl/call-control.tsx`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `src/components/task/CallControl/call-control.utils.ts` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | Consumers cannot enable a destination omitted by the SDK. | PRESENT | | `CC-COMPONENTS-R-007` | `CallControlCADComponent` renders the customer/queue/caller header and an agent-viewable CAD global variables panel, and renders the campaign call icon + "Campaign call" label when `isCampaignCall` is true. | CAD/header info and campaign branding must be visible to the agent during a call. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/Task/task.utils.ts` (`getAgentViewableGlobalVariables`) | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | PRESENT | | `CC-COMPONENTS-R-008` | `IncomingTaskComponent` renders the standard `Task` with Answer/Decline when an `incomingTask` is present and renders nothing (hidden) when it is absent; Accept/Decline invoke `accept(task)`/`reject(task)`. | Avoids a stray empty notification when no task; routes accept/decline intent up. | `src/components/task/IncomingTask/incoming-task.tsx`, `src/components/task/IncomingTask/incoming-task.utils.tsx` (`extractIncomingTaskData`) | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | PRESENT | | `CC-COMPONENTS-R-009` | `TaskListComponent` renders nothing when the task list is empty, otherwise renders one row per task; campaign preview tasks render `CampaignTask` (instead of `Task`) only when `hasCampaignPreviewEnabled` (default true) and the task is a campaign preview. | List must collapse when empty and switch row UI for campaign previews per the feature flag. | `src/components/task/TaskList/task-list.tsx`, `src/components/task/TaskList/task-list.utils.ts` (`isTaskListEmpty`, `getTasksArray`, `isCampaignPreviewTask`, `getActiveCampaignPreviewId`) | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | PRESENT | @@ -149,6 +150,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` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render `ContactServiceQueue`, `EntryPointRecord`, and dial-number arrays in received order. | The component must preserve backend-selected order and established SDK entity records instead of introducing a second list policy or destination abstraction. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | ## Design Overview @@ -342,6 +344,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/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | ## Traceability diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts index 1bc2f21f9..e8c87945f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts @@ -116,12 +116,14 @@ export const getVisibleButtons = (buttons: ButtonConfig[], logger?): ButtonConfi */ export const createInitials = (name: string, logger?): string => { try { - return name - .split(' ') - .map((word) => word[0]) - .join('') - .slice(0, 2) - .toUpperCase(); + const words = name.trim().split(/\s+/).filter(Boolean); + + if (words.length === 0) { + return ''; + } + + const lastInitial = words.length > 1 ? words[words.length - 1][0] : ''; + return `${words[0][0]}${lastInitial}`.toUpperCase(); } catch (error) { logger?.error('CC-Widgets: CallControlCustom: Error in createInitials', { module: 'cc-components#call-control-custom.utils.ts', diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index 3440f3cc4..e21ee42d3 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import {ListItemBase, ListItemBaseSection, AvatarNext, Text, ButtonCircle} from '@momentum-ui/react-collaboration'; -import {Icon} from '@momentum-design/components/dist/react'; +import {ListItemBase, ListItemBaseSection, Text, ButtonCircle} from '@momentum-ui/react-collaboration'; +import {Avatar, Icon} from '@momentum-design/components/dist/react'; import classnames from 'classnames'; import {ConsultTransferListComponentProps} from '../../task.types'; import {createInitials, handleListItemPress} from './call-control-custom.utils'; const ConsultTransferListComponent: React.FC = (props) => { - const {title, subtitle, buttonIcon, onButtonPress, className, logger} = props; + const {title, subtitle, presence, buttonIcon, onButtonPress, className, logger} = props; const initials = createInitials(title); @@ -17,7 +17,7 @@ const ConsultTransferListComponent: React.FC return ( - + diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts index c827bc1de..7c5aab327 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -6,7 +6,6 @@ import { ILogger, FetchPaginatedList, PaginatedListParams, - TransformPaginatedData, } from '@webex/cc-store'; import { CategoryType, @@ -20,24 +19,21 @@ import {debounce} from './call-control-custom.utils'; import {DEFAULT_PAGE_SIZE} from '../../constants'; /** - * React hook to load, transform and manage paginated data with optional search. + * React hook to load and manage paginated SDK data with optional search. * - * @template T - The item type returned by the provided `fetchFunction` (raw API/entity). - * @template U - The transformed item type stored internally and returned to consumers. + * @template T - The item type returned by the provided `fetchFunction`. * @param fetchFunction - Fetcher that returns a paginated list of items of type T. - * @param transformFunction - Mapper that converts each T into U for UI consumption. * @param categoryName - Human-readable name used for logging/telemetry. * @param logger - Optional logger instance for diagnostics. - * @returns An object containing the transformed data (U[]), pagination state and helpers. + * @returns An object containing SDK response data, pagination state and helpers. */ -export const usePaginatedData = ( +export const usePaginatedData = ( fetchFunction: FetchPaginatedList | undefined, - transformFunction: TransformPaginatedData, categoryName: string, logger?: ILogger ) => { const MODULE = 'cc-components#consult-transfer-popover-hooks.ts'; - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); @@ -84,12 +80,10 @@ export const usePaginatedData = ( method: 'usePaginatedData#loadData', }); - const transformedEntries = response.data.map((entry, index) => transformFunction(entry, currentPage, index)); - if (reset || currentPage === 0) { - setData(transformedEntries); + setData(response.data); } else { - setData((prev) => [...prev, ...transformedEntries]); + setData((prev) => [...prev, ...response.data]); } const newPage = response.meta?.page ?? currentPage; @@ -117,7 +111,7 @@ export const usePaginatedData = ( setLoading(false); } }, - [fetchFunction, transformFunction, logger, categoryName] + [fetchFunction, logger, categoryName] ); const reset = useCallback(() => { @@ -130,14 +124,13 @@ export const usePaginatedData = ( }; export function useConsultTransferPopover({ - showDialNumberTab, - showEntryPointTab, + availableCategories, getAddressBookEntries, getEntryPoints, getQueues, logger, }: UseConsultTransferParams) { - const [selectedCategory, setSelectedCategory] = useState(CATEGORY_AGENTS); + const [selectedCategory, setSelectedCategory] = useState(availableCategories[0] ?? CATEGORY_AGENTS); const [searchQuery, setSearchQuery] = useState(''); const loadMoreRef = useRef(null); @@ -148,20 +141,7 @@ export function useConsultTransferPopover({ loading: loadingDialNumbers, loadData: loadDialNumbers, reset: resetDialNumbers, - } = usePaginatedData( - getAddressBookEntries, - (entry) => ({ - id: entry.id, - name: entry.name, - number: entry.number, - organizationId: entry.organizationId, - version: entry.version, - createdTime: entry.createdTime, - lastUpdatedTime: entry.lastUpdatedTime, - }), - CATEGORY_DIAL_NUMBER, - logger - ); + } = usePaginatedData(getAddressBookEntries, CATEGORY_DIAL_NUMBER, logger); const { data: entryPoints, @@ -170,12 +150,7 @@ export function useConsultTransferPopover({ loading: loadingEntryPoints, loadData: loadEntryPoints, reset: resetEntryPoints, - } = usePaginatedData( - getEntryPoints, - (entry) => ({id: entry.id, name: entry.name}), - CATEGORY_ENTRY_POINT, - logger - ); + } = usePaginatedData(getEntryPoints, CATEGORY_ENTRY_POINT, logger); const { data: queuesData, @@ -184,12 +159,7 @@ export function useConsultTransferPopover({ loading: loadingQueues, loadData: loadQueues, reset: resetQueues, - } = usePaginatedData( - getQueues, - (entry) => ({id: entry.id, name: entry.name, description: entry.description}), - CATEGORY_QUEUES, - logger - ); + } = usePaginatedData(getQueues, CATEGORY_QUEUES, logger); const loadNextPage = useCallback(() => { if (!canLoadCategory(selectedCategory)) return; @@ -249,11 +219,12 @@ export function useConsultTransferPopover({ [resetDialNumbers, resetEntryPoints, resetQueues] ); - const createCategoryClickHandler = (category: CategoryType) => () => handleCategoryChange(category); - const handleAgentsClick = createCategoryClickHandler(CATEGORY_AGENTS); - const handleQueuesClick = createCategoryClickHandler(CATEGORY_QUEUES); - const handleDialNumberClick = createCategoryClickHandler(CATEGORY_DIAL_NUMBER); - const handleEntryPointClick = createCategoryClickHandler(CATEGORY_ENTRY_POINT); + useEffect(() => { + const firstAvailableCategory = availableCategories[0]; + if (firstAvailableCategory && !availableCategories.includes(selectedCategory)) { + handleCategoryChange(firstAvailableCategory); + } + }, [availableCategories, handleCategoryChange, selectedCategory]); // Helper: determines if the given category can load next page now const canLoadCategory = (category: CategoryType): boolean => { @@ -306,14 +277,26 @@ export function useConsultTransferPopover({ }, [loadNextPage]); useEffect(() => { - if (selectedCategory === CATEGORY_DIAL_NUMBER && showDialNumberTab && dialNumbers.length === 0) { + if ( + selectedCategory === CATEGORY_DIAL_NUMBER && + availableCategories.includes(CATEGORY_DIAL_NUMBER) && + dialNumbers.length === 0 + ) { loadCategory(CATEGORY_DIAL_NUMBER, 0, '', true); - } else if (selectedCategory === CATEGORY_ENTRY_POINT && showEntryPointTab && entryPoints.length === 0) { + } else if ( + selectedCategory === CATEGORY_ENTRY_POINT && + availableCategories.includes(CATEGORY_ENTRY_POINT) && + entryPoints.length === 0 + ) { loadCategory(CATEGORY_ENTRY_POINT, 0, '', true); - } else if (selectedCategory === CATEGORY_QUEUES && queuesData.length === 0) { + } else if ( + selectedCategory === CATEGORY_QUEUES && + availableCategories.includes(CATEGORY_QUEUES) && + queuesData.length === 0 + ) { loadCategory(CATEGORY_QUEUES, 0, '', true); } - }, [selectedCategory]); + }, [availableCategories, selectedCategory]); const handleReload = useCallback(() => { logger?.info(`CC-Components: Reloading ${selectedCategory} data`, { @@ -337,10 +320,7 @@ export function useConsultTransferPopover({ hasMoreQueues, loadingQueues, handleSearchChange, - handleAgentsClick, - handleQueuesClick, - handleDialNumberClick, - handleEntryPointClick, + handleCategoryChange, handleReload, }; } diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index 32e1ab744..b1a0edb55 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -1,8 +1,15 @@ -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {Text, ListNext, TextInput, Button, ButtonCircle, TooltipNext} from '@momentum-ui/react-collaboration'; import {Icon, Checkbox, Spinner} from '@momentum-design/components/dist/react'; import ConsultTransferListComponent from './consult-transfer-list-item'; -import {ConsultTransferPopoverComponentProps} from '../../task.types'; +import { + CategoryType, + ConsultTransferPopoverComponentProps, + CATEGORY_AGENTS, + CATEGORY_DIAL_NUMBER, + CATEGORY_ENTRY_POINT, + CATEGORY_QUEUES, +} from '../../task.types'; import ConsultTransferEmptyState from './consult-transfer-empty-state'; import { handleAgentSelection, @@ -11,14 +18,19 @@ import { getAgentsForDisplay, } from './call-control-custom.utils'; import {useConsultTransferPopover} from './consult-transfer-popover-hooks'; - import { SEARCH_PLACEHOLDER, CLEAR_SEARCH, SCROLL_TO_LOAD_MORE, NO_DATA_AVAILABLE_CONSULT_TRANSFER, } from '../../constants'; -import {CATEGORY_AGENTS, CATEGORY_DIAL_NUMBER, CATEGORY_ENTRY_POINT, CATEGORY_QUEUES} from '../../task.types'; + +const DESTINATION_CATEGORY = { + agent: CATEGORY_AGENTS, + queue: CATEGORY_QUEUES, + dialNumber: CATEGORY_DIAL_NUMBER, + entryPoint: CATEGORY_ENTRY_POINT, +} as const; const ConsultTransferPopoverComponent: React.FC = ({ heading, @@ -33,13 +45,25 @@ const ConsultTransferPopoverComponent: React.FC { const {showDialNumberTab = true, showEntryPointTab = true} = consultTransferOptions || {}; - const isEntryPointTabVisible = showEntryPointTab && heading === 'Consult'; + const availableCategories = useMemo( + () => + availableDestinations + .filter((destination) => showDialNumberTab || destination !== 'dialNumber') + .filter((destination) => showEntryPointTab || destination !== 'entryPoint') + .map((destination) => DESTINATION_CATEGORY[destination]), + [availableDestinations, showDialNumberTab, showEntryPointTab] + ); + const isAgentsTabVisible = availableCategories.includes(CATEGORY_AGENTS); + const isQueueTabVisible = availableCategories.includes(CATEGORY_QUEUES); + const isDialNumberTabVisible = availableCategories.includes(CATEGORY_DIAL_NUMBER); + const isEntryPointTabVisible = availableCategories.includes(CATEGORY_ENTRY_POINT); const { selectedCategory, searchQuery, @@ -54,30 +78,31 @@ const ConsultTransferPopoverComponent: React.FC(false); - const renderList = ( + const renderList = ( items: T[], onButtonPress: (item: T) => void ) => ( {items.map((item) => ( -

e.stopPropagation()} className="consult-list-item-wrapper"> +
e.stopPropagation()} + className="consult-list-item-wrapper" + > onButtonPress(item)} logger={logger} @@ -92,9 +117,9 @@ const ConsultTransferPopoverComponent: React.FC ); - const noQueues = !allowConsultToQueue || queuesData.length === 0; - const noDialNumbers = !showDialNumberTab || dialNumbers.length === 0; - const noEntryPoints = !isEntryPointTabVisible || entryPoints.length === 0; + const noQueues = queuesData.length === 0; + const noDialNumbers = dialNumbers.length === 0; + const noEntryPoints = entryPoints.length === 0; const consultTransferManualAction = shouldAddConsultTransferAction( selectedCategory, @@ -133,7 +158,7 @@ const ConsultTransferPopoverComponent: React.FC { if (selectedCategory === CATEGORY_AGENTS && loadBuddyAgents) { - loadBuddyAgents(); + loadBuddyAgents(action); } else { handleReload(); } @@ -180,56 +205,28 @@ const ConsultTransferPopoverComponent: React.FC
- - {allowConsultToQueue && ( - - )} - {showDialNumberTab && ( - - )} - {isEntryPointTabVisible && ( - - )} + {availableCategories.map((category: CategoryType) => { + const isWide = category === CATEGORY_DIAL_NUMBER || category === CATEGORY_ENTRY_POINT; + + return ( + + ); + })}
- {selectedCategory === 'Agents' && + {isAgentsTabVisible && + selectedCategory === CATEGORY_AGENTS && (loadingBuddyAgents ? (
@@ -241,12 +238,14 @@ const ConsultTransferPopoverComponent: React.FC ({ id: agent.agentId, name: agent.agentName, + presence: agent.state?.toLowerCase() === 'available' ? ('active' as const) : ('away' as const), })), (item) => handleAgentSelection(item.id, item.name, allowParticipantsToInteract, onAgentSelect, logger) ) ))} - {selectedCategory === 'Queues' && + {isQueueTabVisible && + selectedCategory === CATEGORY_QUEUES && (loadingQueues && queuesData.length === 0 ? (
@@ -255,9 +254,10 @@ const ConsultTransferPopoverComponent: React.FC ) : (
- {renderList( - queuesData.map((q) => ({id: q.id, name: q.name})), - (item) => handleQueueSelection(item.id, item.name, allowParticipantsToInteract, onQueueSelect, logger) + {renderList(queuesData, (item) => + item.id + ? handleQueueSelection(item.id, item.name, allowParticipantsToInteract, onQueueSelect, logger) + : undefined )} {hasMoreQueues && (
@@ -275,7 +275,7 @@ const ConsultTransferPopoverComponent: React.FC ))} - {showDialNumberTab && + {isDialNumberTabVisible && selectedCategory === CATEGORY_DIAL_NUMBER && (loadingDialNumbers && dialNumbers.length === 0 ? (
@@ -285,14 +285,11 @@ const ConsultTransferPopoverComponent: React.FC ) : (
- {renderList( - dialNumbers.map((d) => ({id: d.id, name: d.name, number: d.number})), - (item) => { - if (item.number) { - onDialNumberSelect(item.number, allowParticipantsToInteract); - } + {renderList(dialNumbers, (item) => { + if (item.number) { + onDialNumberSelect(item.number, allowParticipantsToInteract); } - )} + })} {hasMoreDialNumbers && (
{loadingDialNumbers ? ( @@ -319,12 +316,9 @@ const ConsultTransferPopoverComponent: React.FC ) : (
- {renderList( - entryPoints.map((e) => ({id: e.id, name: e.name})), - (item) => { - onEntryPointSelect(item.id, item.name, allowParticipantsToInteract); - } - )} + {renderList(entryPoints, (item) => { + onEntryPointSelect(item.id, item.name, allowParticipantsToInteract); + })} {hasMoreEntryPoints && (
{loadingEntryPoints ? ( @@ -340,6 +334,7 @@ const ConsultTransferPopoverComponent: React.FC ))} + {availableCategories.length === 0 && }
{isConferenceInProgress && (
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index eb902e4b9..9454f9791 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -57,7 +57,6 @@ function CallControlComponent(props: CallControlComponentProps) { consultTransfer, callControlAudio, setConsultAgentName, - allowConsultToQueue, setLastTargetType, controls, logger, @@ -164,6 +163,12 @@ function CallControlComponent(props: CallControlComponentProps) { if (!button.isVisible) return null; if (button.menuType) { + const action = button.menuType === 'Transfer' ? 'Transfer' : 'Consult'; + const availableDestinations = + action === 'Transfer' + ? controls.consultTransferDestinations.transfer + : controls.consultTransferDestinations.consult; + return ( { setShowAgentMenu(false); @@ -241,16 +248,9 @@ function CallControlComponent(props: CallControlComponentProps) { onDialNumberSelect={(dialNumber, allowParticipantsToInteract) => handleTargetSelect(dialNumber, dialNumber, 'dialNumber', allowParticipantsToInteract) } - allowConsultToQueue={allowConsultToQueue} - consultTransferOptions={ - isTelephony - ? consultTransferOptions - : { - ...consultTransferOptions, - showDialNumberTab: false, - showEntryPointTab: false, - } - } + action={action} + availableDestinations={availableDestinations} + consultTransferOptions={consultTransferOptions} isConferenceInProgress={controls?.main?.exitConference?.isVisible ?? false} logger={logger} /> diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 6192837be..d3f6bf576 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -340,7 +340,7 @@ export interface ControlProps { /** * Function to load buddy agents */ - loadBuddyAgents: () => Promise; + loadBuddyAgents: (action?: 'Consult' | 'Transfer') => Promise; /** * Function to transfer the call to a destination. @@ -503,7 +503,7 @@ export interface ControlProps { /** Fetch paginated entry points */ getEntryPoints?: FetchPaginatedList; - /** Fetch paginated queues (filtered by media type in store) */ + /** Fetch paginated consult/transfer queues from the SDK-owned policy */ getQueuesFetcher?: FetchPaginatedList; /** @@ -647,6 +647,7 @@ export type OutdialCallComponentProps = Pick< export interface ConsultTransferListComponentProps { title: string; subtitle?: string; + presence?: 'active' | 'away'; buttonIcon: string; onButtonPress: () => void; className?: string; @@ -673,7 +674,7 @@ export interface ConsultTransferPopoverComponentProps { buttonIcon: string; buddyAgents: BuddyDetails[]; loadingBuddyAgents: boolean; - loadBuddyAgents?: () => Promise; + loadBuddyAgents?: (action?: 'Consult' | 'Transfer') => Promise; getAddressBookEntries?: FetchPaginatedList; getEntryPoints?: FetchPaginatedList; getQueues?: FetchPaginatedList; @@ -681,7 +682,8 @@ export interface ConsultTransferPopoverComponentProps { onQueueSelect: (queueId: string, queueName: string, allowParticipantsToInteract: boolean) => void; onEntryPointSelect: (entryPointId: string, entryPointName: string, allowParticipantsToInteract: boolean) => void; onDialNumberSelect: (dialNumber: string, allowParticipantsToInteract: boolean) => void; - allowConsultToQueue: boolean; + action: 'Consult' | 'Transfer'; + availableDestinations: TaskUIControls['consultTransferDestinations']['consult']; /** Options governing popover visibility/behavior */ consultTransferOptions?: ConsultTransferOptions; isConferenceInProgress?: boolean; @@ -885,8 +887,7 @@ export const CATEGORY_AGENTS: CategoryType = 'Agents'; * Parameters for `useConsultTransferPopover` hook. */ export type UseConsultTransferParams = { - showDialNumberTab: boolean; - showEntryPointTab: boolean; + availableCategories: CategoryType[]; getAddressBookEntries?: FetchPaginatedList; getEntryPoints?: FetchPaginatedList; getQueues?: FetchPaginatedList; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap index 8ed318e08..02ddcc9a9 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap @@ -17,21 +17,10 @@ exports[`ConsultTransferListComponent Snapshots Interactions should render compo class="call-control-list-item-start" data-position="start" > - +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
+
- +
- +
+
- +
+
- +
- +
- +
`; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with agents having different states 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render when SDK controls omit queues 1`] = `
@@ -1045,7 +1033,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem tagname="h3" type="body-large-bold" > - Select an Agent + Consult
@@ -1176,82 +1164,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem class="consult-list-item-wrapper" >
  • -
    - -
    -
    - - Available Agent - -
    -
    -
    - -
    -
    -
  • -
    -
    -
  • - +
  • - Busy Agent + Agent One
  • - +
  • - Idle Agent + Agent Two
    `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with allowConsultToQueue false 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with agents having different states 1`] = `
    @@ -1442,7 +1333,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem class="consult-action-buttons" > + +
  • - +
  • - Agent One + Available Agent
  • +
  • +
    + + Busy Agent + +
    +
    + +
    +
    +
  • +
    +
    - Agent Two + Idle Agent
    +
    +
    - +
    - +
    +
    +
    +
    - +
    +
    - +
    +
    - +
    - +
    +
    - +
    +
    - +
    - +
    +
    - +
    { expect(createInitials('John')).toBe('J'); }); - it('should create initials from multiple names, taking first two', () => { - expect(createInitials('John Michael Doe')).toBe('JM'); + it('should create initials from the first and last words', () => { + expect(createInitials('John Michael Doe')).toBe('JD'); + }); + + it('should use the first and last tokens for multi-token destination initials', () => { + expect(createInitials('Queue e2e 1')).toBe('Q1'); + expect(createInitials('Entry point e2e set 1')).toBe('E1'); }); it('should handle empty string', () => { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index 11652b518..3b8d22345 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -3,6 +3,11 @@ import {render, fireEvent} from '@testing-library/react'; import '@testing-library/jest-dom'; import ConsultTransferListComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-list-item'; +type AvatarElement = HTMLElement & { + initials?: string; + presence?: 'active' | 'away'; +}; + const loggerMock = { log: jest.fn(), info: jest.fn(), @@ -54,18 +59,11 @@ describe('CallControlListItemPresentational', () => { expect(listItem).toHaveAttribute('tabindex', '0'); // Verify avatar section - const avatarWrapper = screen.container.querySelector('.md-avatar-wrapper'); - expect(avatarWrapper).toBeInTheDocument(); - expect(avatarWrapper).toHaveAttribute('role', 'img'); - expect(avatarWrapper).toHaveAttribute('data-size', '32'); - expect(avatarWrapper).toHaveAttribute('data-color', 'default'); - expect(avatarWrapper).toHaveAttribute('aria-hidden', 'false'); - - // Verify initials display - const initialsSpan = screen.container.querySelector('.md-avatar-wrapper-children'); - expect(initialsSpan).toBeInTheDocument(); - expect(initialsSpan).toHaveTextContent('JD'); - expect(initialsSpan).toHaveAttribute('aria-hidden', 'true'); + const avatar = screen.container.querySelector('mdc-avatar') as AvatarElement; + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('aria-hidden', 'true'); + expect(avatar.initials).toBe('JD'); + expect(avatar).toHaveAttribute('size', '32'); // Verify middle section with title and subtitle (class-based layout) const middleSection = screen.container.querySelector('[data-position="middle"]'); @@ -132,4 +130,13 @@ describe('CallControlListItemPresentational', () => { expect(titleOnlyElements).toHaveLength(1); expect(titleOnlyElements[0]).toHaveTextContent('John Doe'); }); + + it.each([['active' as const], ['away' as const]])('passes %s presence to the avatar', (presence) => { + const screen = render(); + const listItem = screen.container.querySelector('.call-control-list-item'); + const avatar = screen.container.querySelector('mdc-avatar') as AvatarElement; + + expect(listItem).toHaveAttribute('aria-label', 'John Doe'); + expect(avatar.presence).toBe(presence); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx index 303822113..981f7a200 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx @@ -2,7 +2,9 @@ import React from 'react'; import '@testing-library/jest-dom'; import {render, fireEvent, act, waitFor} from '@testing-library/react'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; -import {ContactServiceQueue} from '@webex/cc-store'; +import {ContactServiceQueue, TaskUIControls} from '@webex/cc-store'; + +type AvailableDestinations = TaskUIControls['consultTransferDestinations']['consult']; const mockUIDProps = (container) => { container @@ -29,31 +31,7 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const mockOnAgentSelect = jest.fn(); const mockOnQueueSelect = jest.fn(); - const buildQueue = (id: string, name: string, description: string = 'Queue'): ContactServiceQueue => ({ - organizationId: 'org-test', - id, - version: 1, - name, - description, - queueType: 'INBOUND', - checkAgentAvailability: true, - channelType: 'TELEPHONY', - serviceLevelThreshold: 20, - maxActiveContacts: 25, - maxTimeInQueue: 600, - defaultMusicInQueueMediaFileId: 'media-1', - active: true, - monitoringPermitted: true, - parkingPermitted: true, - recordingPermitted: true, - recordingAllCallsPermitted: true, - pauseRecordingPermitted: true, - controlFlowScriptUrl: 'https://example.com/flow', - ivrRequeueUrl: 'https://example.com/requeue', - routingType: 'LONGEST_AVAILABLE_AGENT', - queueRoutingType: 'TEAM_BASED', - callDistributionGroups: [], - }); + const buildQueue = (id: string, name: string): ContactServiceQueue => ({id, name}) as ContactServiceQueue; const defaultProps = { heading: 'Select an Agent', @@ -81,7 +59,8 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { onQueueSelect: mockOnQueueSelect, onDialNumberSelect: jest.fn(), onEntryPointSelect: jest.fn(), - allowConsultToQueue: true, + action: 'Consult' as const, + availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as AvailableDestinations, loadingBuddyAgents: false, logger: mockLogger, }; @@ -164,8 +143,12 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { expect(container).toMatchSnapshot(); }); - it('should render with allowConsultToQueue false', async () => { - const noQueueConsultProps = {...defaultProps, allowConsultToQueue: false}; + it('should render when SDK controls omit queues', async () => { + const noQueueConsultProps = { + ...defaultProps, + heading: 'Consult', + availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as AvailableDestinations, + }; let screen; await act(async () => { screen = render(); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index 375b76089..72acb140b 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -2,9 +2,11 @@ import React from 'react'; import {render, fireEvent, waitFor, act} from '@testing-library/react'; import '@testing-library/jest-dom'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; -import {ContactServiceQueue, EntryPointRecord, AddressBookEntry} from '@webex/cc-store'; +import {AddressBookEntry, ContactServiceQueue, EntryPointRecord, TaskUIControls} from '@webex/cc-store'; import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; +type AvailableDestinations = TaskUIControls['consultTransferDestinations']['consult']; + const loggerMock = { log: jest.fn(), info: jest.fn(), @@ -43,7 +45,7 @@ describe('ConsultTransferPopoverComponent', () => { agentId: 'agent2', agentName: 'Agent Two', dn: '1002', - state: 'Available', + state: 'Idle', teamId: 'team1', siteId: 'site1', }, @@ -59,7 +61,8 @@ describe('ConsultTransferPopoverComponent', () => { onQueueSelect: mockOnQueueSelect, onDialNumberSelect: jest.fn(), onEntryPointSelect: jest.fn(), - allowConsultToQueue: true, + action: 'Consult' as const, + availableDestinations: ['agent', 'queue', 'dialNumber', 'entryPoint'] as AvailableDestinations, loadingBuddyAgents: false, logger: loggerMock, }; @@ -108,6 +111,11 @@ describe('ConsultTransferPopoverComponent', () => { expect(listItems[0]).toHaveTextContent('Agent One'); expect(listItems[1]).toHaveTextContent('Agent Two'); + const availableAvatar = listItems[0].querySelector('mdc-avatar') as HTMLElement & {presence?: string}; + const awayAvatar = listItems[1].querySelector('mdc-avatar') as HTMLElement & {presence?: string}; + expect(availableAvatar.presence).toBe('active'); + expect(awayAvatar.presence).toBe('away'); + // Verify list item wrappers render const listItemContainers = screen.container.querySelectorAll('.consult-list-item-wrapper'); expect(listItemContainers).toHaveLength(2); @@ -137,6 +145,41 @@ describe('ConsultTransferPopoverComponent', () => { expect(mockOnQueueSelect).toHaveBeenCalledWith('queue1', 'Queue One', false); }); + it('shows the number below dial-number and entry-point names', async () => { + const screen = render( + ({ + data: [ + { + id: 'dn1', + name: 'Dial Number One', + number: '12345', + } as AddressBookEntry, + ], + meta: {page: 0, totalPages: 1}, + })} + getEntryPoints={async () => ({ + data: [ + { + id: 'ep1', + name: 'Entry Point One', + number: '67890', + } as EntryPointRecord, + ], + meta: {page: 0, totalPages: 1}, + })} + /> + ); + + fireEvent.click(screen.getByRole('button', {name: 'Dial Number'})); + await waitFor(() => expect(screen.getByText('12345')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', {name: 'Entry Point'})); + await waitFor(() => expect(screen.getByText('67890')).toBeInTheDocument()); + }); + it('hides Dial Number tab when consultTransferOptions.showDialNumberTab is false', async () => { const screen = await render( @@ -223,10 +266,10 @@ describe('ConsultTransferPopoverComponent', () => { expect(screen.container.querySelectorAll('.call-control-list-item').length).toBe(0); }); - it('hides queue tab when allowConsultToQueue is false', async () => { + it('hides a category omitted by the SDK controls', async () => { const propsWithoutQueue = { ...baseProps, - allowConsultToQueue: false, + availableDestinations: ['agent', 'dialNumber', 'entryPoint'] as AvailableDestinations, }; const screen = await render(); @@ -234,6 +277,40 @@ describe('ConsultTransferPopoverComponent', () => { expect(maybeQueuesButton).toBeNull(); }); + it('renders category tabs in the order supplied by the SDK controls', async () => { + const orderedProps = { + ...baseProps, + action: 'Transfer' as const, + availableDestinations: ['queue', 'agent', 'entryPoint', 'dialNumber'] as AvailableDestinations, + }; + + const screen = await render(); + const categoryLabels = Array.from(screen.container.querySelectorAll('.consult-category-buttons button')).map( + (button) => button.textContent + ); + + expect(categoryLabels).toEqual(['Queues', 'Agents', 'Entry Point', 'Dial Number']); + expect(screen.getByRole('button', {name: 'Queues'})).toHaveClass('consult-category-button-active'); + }); + + it('renders an empty state without looping when the SDK exposes no destinations', async () => { + const screen = await render(); + + expect(screen.container.querySelectorAll('.consult-category-buttons button')).toHaveLength(0); + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); + }); + + it('shows entry point when it is included in the SDK controls', async () => { + const transferProps = { + ...baseProps, + action: 'Transfer' as const, + consultTransferOptions: {showEntryPointTab: true}, + }; + + const screen = await render(); + expect(screen.getByRole('button', {name: 'Entry Point'})).toBeInTheDocument(); + }); + it('covers edge case for empty items in renderList (line 50)', async () => { const propsWithEmptyAgents = { ...baseProps, @@ -333,6 +410,7 @@ describe('ConsultTransferPopoverComponent', () => { fireEvent.click(reloadButton); expect(mockLoadBuddyAgents).toHaveBeenCalledTimes(1); + expect(mockLoadBuddyAgents).toHaveBeenCalledWith('Consult'); }); it('reloads queues when reload button clicked on Queues tab', async () => { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8363fb2ec..e7ef90f03 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -306,6 +306,13 @@ describe('CallControlComponent', () => { const modifiedProps = { ...defaultProps, buddyAgents: mockBuddyAgents, + controls: { + ...defaultProps.controls, + consultTransferDestinations: { + consult: [], + transfer: ['agent' as const], + }, + }, }; const screen = await render(); @@ -338,6 +345,7 @@ describe('CallControlComponent', () => { // After clicking, the popover should be expanded expect(transferButton).toHaveAttribute('aria-expanded', 'true'); + expect(modifiedProps.loadBuddyAgents).toHaveBeenCalledWith('Transfer'); // Verify buttons maintain their CSS classes after interactions expect(transferButton).toHaveClass('call-control-button'); @@ -467,7 +475,13 @@ describe('CallControlComponent', () => { const screen = await render( ); @@ -476,11 +490,46 @@ describe('CallControlComponent', () => { fireEvent.click(consultButton); await screen.findByRole('button', {name: 'Agents'}); + expect(defaultProps.loadBuddyAgents).toHaveBeenCalledWith('Consult'); expect(screen.getByRole('button', {name: 'Queues'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Entry Point'})).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Dial Number'})).not.toBeInTheDocument(); }); + it('does not load buddy agents when the SDK omits the Agents destination', async () => { + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id: 'consult', + icon: 'consult', + tooltip: 'Consult', + className: 'call-control-button', + disabled: false, + menuType: 'Consult', + isVisible: true, + dataTestId: 'consult-button', + }, + ]); + + const screen = await render( + + ); + + fireEvent.click(screen.getByLabelText('Consult')); + + await screen.findByRole('button', {name: 'Queues'}); + expect(defaultProps.loadBuddyAgents).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', {name: 'Agents'})).not.toBeInTheDocument(); + }); + it('hides Dial Number and Entry Point tabs for non-telephony media', async () => { jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ { @@ -500,7 +549,13 @@ describe('CallControlComponent', () => { const screen = await render( ); diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 75a882569..2a445d89e 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -74,7 +74,7 @@ This module is consumed as an imported SDK/code API (the `@webex/cc-store` packa | Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | |---|---|---|---|---|---|---| | `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.types` | SDK | type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `IStore`, `IStoreWrapper`, `InitParams`, `RealTimeTranscriptionData`, ~20 more) | Typed domain surface for widget code | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts:334-366`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.types` | SDK | type re-exports including the existing `ContactServiceQueue`, `ContactServiceQueuesResponse`, `ContactServiceQueueSearchParams`, `EntryPointRecord`, `EntryPointListResponse`, and `EntryPointSearchParams` contracts plus Task destination controls | Typed SDK-backed domain surface using the SDK's established entity and list types directly | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -104,13 +104,14 @@ Compatibility notes: | `STORE-R-012` | `handleTaskRemove` detaches every task listener, clears `realtimeTranscriptionData` for the removed current task, drops accepted-campaign tracking, resets custom state, and refreshes the list | Prevent listener/audio/state leaks across task lifecycles | `src/storeEventsWrapper.ts:458-521` | `tests/storeEventsWrapper.ts` ("handleTaskRemove — campaign ID cleanup") | Per-listener detach is asserted only partially; full leak audit is a gap | PRESENT | | `STORE-R-013` | `agent:logoutSuccess` triggers `cleanUpStore()` which resets session observables and removes CC SDK listeners; `agent:multiLogin` sets `showMultipleLoginAlert` | Clean session teardown and multi-login warning | `src/storeEventsWrapper.ts:811-819,1003-1024,1029-1066` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | | `STORE-R-014` | `agent:stateChange` (type `AgentStateChangeSuccess`) updates `currentState` (defaulting `auxCodeId` `''`→`'0'`) and both state-change timestamps | Drives the agent-state widget and timers | `src/storeEventsWrapper.ts:797-809` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | -| `STORE-R-015` | List fetchers proxy the SDK and propagate errors after logging; `getQueues` filters by upper-cased channel type; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false | Centralize SDK fetch + transform so widgets stay SDK-agnostic | `src/storeEventsWrapper.ts:924-1001` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper", "getAccessToken") | `getBuddyAgents`/`getQueues` happy-path filtering covered; address-book disabled branch coverage is a gap | PRESENT | +| `STORE-R-015` | Consult/transfer fetchers use the existing SDK `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and return their established entity/list responses without local result filtering, sorting, projection, or metadata reconstruction. Telephony calls rely on SDK defaults; for a non-telephony active task the store supplies only a complete channel eligibility expression through the existing `filter` parameter. Errors are logged and rethrown; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false. | Keep ordinary list policy in the SDK and preserve backend order while carrying the one piece of per-task context an SDK-global list method cannot infer without a new signature. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/storeEventsWrapper.ts` | Address-book-disabled branch coverage is a gap. | PRESENT | | `STORE-R-016` | `setOnError` wraps the caller callback to also submit a behavioral metrics event before invoking it | Consistent telemetry on widget errors | `src/storeEventsWrapper.ts:285-301` | None found | Negative/telemetry-path test missing | WEAK | | `STORE-R-017` | `isIncomingTask` returns true only when the task is not wrap-up-required, the agent has not joined, and the interaction state is `new`/`consult`/`connected`/`conference` | Gates whether a task is treated as an unanswered incoming offer | `src/task-utils.ts:26-37` | `tests/task-utils.ts` ("isIncomingTask" — incoming / not incoming / edge cases) | none | PRESENT | | `STORE-R-018` | `getConsultStatus`/`getTaskStatus` map participant `consultState` + interaction state to a `ConsultStatus`, with special handling for secondary EP-DN agents | Consult/conference UI relies on a single derived status | `src/task-utils.ts:39-146` | None found (direct `getConsultStatus` test) | Only `isIncomingTask`, conference, and hold helpers are directly tested; consult-status helper is a gap | WEAK | | `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` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. @@ -244,12 +245,11 @@ sequenceDiagram participant W as StoreWrapper participant SDK as "@webex/contact-center" - Widget->>W: getQueues(mediaType, params) - W->>SDK: cc.getQueues(params) + Widget->>W: getQueues(params) / getEntryPoints(params) + W->>SDK: getQueues/getEntryPoints(existing params) alt resolves - SDK-->>W: queues - W->>W: filter by channelType == mediaType.toUpperCase() - W-->>Widget: {data, meta} + SDK-->>W: existing full-record paginated response + W-->>Widget: unchanged {data, meta} else rejects SDK-->>W: error W->>W: logger.error(...) @@ -281,14 +281,16 @@ classDiagram - **UC-3 Observe agent/session state in React:** Widget wraps in `observer()` and reads `store.agentId`, `store.isAgentLoggedIn`, `store.deviceType`, `store.currentState` → re-renders on mutation. Evidence: `src/storeEventsWrapper.ts:56-187`, `_archive/.../AGENTS.md` usage. - **UC-4 Handle an incoming task through to wrap-up:** SDK `task:incoming` → listeners registered + `onIncomingTask` fired → `task:assigned` sets ENGAGED/current → `task:end` + `handleTaskRemove` cleans up. Evidence: `src/storeEventsWrapper.ts:585-762`, `tests/storeEventsWrapper.ts` ("events reactions"). - **UC-5 Campaign-preview accept flow:** `task:campaignPreviewReservation` puts a preview in RESERVED; preview stays out of `currentTask` until accepted (`acceptedCampaignIds`), then transitions to ENGAGED. Evidence: `src/storeEventsWrapper.ts:243-283,537-583,772-795`, `tests/storeEventsWrapper.ts` ("campaign preview task lifecycle"). -- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult widget calls `getBuddyAgents()`/`getQueues()`; Outdial calls `getEntryPoints()`/`getAddressBookEntries()` → store proxies the SDK, transforms/filters, returns. Evidence: `src/storeEventsWrapper.ts:924-1001`, `tests/storeEventsWrapper.ts`. +- **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult calls the existing `getBuddyAgents()`/`getQueues()`/`getEntryPoints()` methods and Outdial calls `getAddressBookEntries()`. The store relies on SDK telephony defaults, supplies an existing filter only for a non-telephony active task, and returns full-record data/metadata unchanged. Evidence: `src/storeEventsWrapper.ts`, `tests/storeEventsWrapper.ts`. ## State Model The store is a single MobX `makeAutoObservable` instance. Observable slices (all in `src/store.ts:23-56`): - **Session / profile:** `agentId`, `agentProfile`, `isAgentLoggedIn`, `deviceType`, `dialNumber`, `teamId`, `teams`, `loginOptions`, `idleCodes`, `wrapupCodes`, `featureFlags`, `dataCenter`. - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. -- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `isDigitalChannelsInitialized`. +- **Call/consult control:** `isMuted`, `callControlAudio`, `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`, `isDeclineButtonEnabled`, `isEndConsultEnabled`, `allowConsultToQueue`, `isDigitalChannelsInitialized`. `allowConsultToQueue` remains a compatibility pass-through only; destination availability/order comes from the SDK Task's `uiControls`. +- **`getQueues` / `getEntryPoints`:** call the same-named SDK methods and return `ContactServiceQueuesResponse` / `EntryPointListResponse`. Telephony uses SDK defaults; non-telephony tasks use the existing `filter` parameter. They do not sort, filter returned rows, project fields, or reinterpret metadata. +- **`getBuddyAgents`:** returns the SDK `agentList` unchanged (full list, no pagination). - **Misc:** `currentTheme`, `cc` (`observable.ref` — not deeply observed), `isAddressBookEnabled`. Transition triggers: SDK CC/task events drive the session/agent/task slices via the wrapper's handlers (`handleStateChange`, `handleTaskAssigned`, `refreshTaskList`, `cleanUpStore`, campaign-preview handlers). Widget-initiated mutators (`setDeviceType`, `setDialNumber`, `setTeamId`, `setState`, `setCurrentTheme`, etc.) drive UI-local slices. All writes pass through `runInAction`. @@ -305,7 +307,7 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via - **Event enums are local copies (`store.types.ts:204-259`):** `CC_EVENTS`/`TASK_EVENTS` string values must match the SDK exactly; an SDK rename will silently stop a handler from firing. - **Pending campaign previews must not become `currentTask`:** `setCurrentTask` clears `currentTask` for a preview in state `new` that is not in `acceptedCampaignIds` (`storeEventsWrapper.ts:255-267`). Bypassing this (e.g. calling SDK methods directly) re-introduces the bug where CallControl renders for an unaccepted preview. - **Listener leaks:** every `task.on(...)` in `registerTaskEventListeners` has a matching `task.off(...)` in `handleTaskRemove`. Adding a listener in one without the other leaks handlers and can double-fire `refreshTaskList`. -- **`getBuddyAgents`/`getQueues` default args dereference `this.currentTask.data.interaction.mediaType` (`storeEventsWrapper.ts:925,941`):** calling them with no `currentTask` set throws. Callers should pass an explicit `mediaType` when no task is active. +- **Task media is SDK-originated but broadly typed on `ITask`:** the store validates it against the supported media keys before passing the existing `BuddyAgents` option; absent or unknown media uses the SDK telephony default. - **`@ts-expect-error` markers tie to SDK gaps:** several casts (e.g. `response.teams`, credentials API) are pinned to `CAI-6762`; removing the workaround before the SDK fix breaks the build. ## Module Do's / Don'ts @@ -337,13 +339,14 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-012` | `tests/storeEventsWrapper.ts` (handleTaskRemove cleanup) | full per-listener detach not exhaustively asserted | | `STORE-R-013` | `tests/storeEventsWrapper.ts` (events reactions) | none | | `STORE-R-014` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-015` | `tests/storeEventsWrapper.ts` (list fetchers, getAccessToken) | address-book-disabled branch not directly asserted | +| `STORE-R-015` | `tests/storeEventsWrapper.ts` (existing-method delegation, non-telephony existing-filter use, unchanged order/metadata, errors) | address-book-disabled branch not directly asserted | | `STORE-R-016` | None found | missing telemetry-path test | | `STORE-R-017` | `tests/task-utils.ts` (isIncomingTask) | none | | `STORE-R-018` | None found | `getConsultStatus`/`getTaskStatus` untested | | `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | | `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | | `STORE-R-021` | None found | `handleRealtimeTranscription` untested | +| `STORE-R-022` | `tests/storeEventsWrapper.ts` no longer exposes raw collaboration-policy getters | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index c0bf9fc45..1ace2ee1b 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -216,8 +216,13 @@ interface IStoreWrapper extends IStore { onErrorCallback?: (widgetName: string, error: Error) => void; setCurrentTask(task: ITask): void; refreshTaskList(): void; + getBuddyAgents(action: 'Consult' | 'Transfer'): Promise; getBuddyAgents(mediaType?: string): Promise; - getQueues(mediaType?: string, params?: ContactServiceQueueSearchParams): Promise; + getQueues(params?: ContactServiceQueueSearchParams): Promise; + getQueues( + mediaType: string | undefined, + params?: ContactServiceQueueSearchParams + ): Promise; getEntryPoints(params?: EntryPointSearchParams): Promise; getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; setDeviceType(option: string): void; @@ -308,7 +313,7 @@ type PaginatedListParams = { search?: string; }; -// Generic fetch/transform helpers for paginated APIs +// Generic fetch helper for paginated APIs type FetchPaginatedList = ( params: PaginatedListParams ) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4f1f35f60..7a003bdad 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -15,7 +15,7 @@ import { ENGAGED_USERNAME, RESERVED_LABEL, RESERVED_USERNAME, - ContactServiceQueue, + ContactServiceQueuesResponse, ContactServiceQueueSearchParams, EntryPointListResponse, EntryPointSearchParams, @@ -32,7 +32,6 @@ import Store from './store'; import { DEVICE_TYPE_BROWSER, MEDIA_TYPE_TELEPHONY_LOWER, - MEDIA_TYPE_TELEPHONY_UPPER, AGENT_STATE_AVAILABLE, CAMPAIGN_PREVIEW_OUTBOUND_TYPES, CAMPAIGN_PREVIEW_CAMPAIGN_TYPES, @@ -41,6 +40,29 @@ import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; import {SUGGESTED_RESPONSE_EVENT, TASK_MULTI_LOGIN_HYDRATE} from './constants'; +const CONSULT_TRANSFER_CHANNELS = { + telephony: 'TELEPHONY', + chat: 'CHAT', + social: 'SOCIAL_CHANNEL', + email: 'EMAIL', +} as const; + +const getSupportedMediaType = (mediaType?: string): keyof typeof CONSULT_TRANSFER_CHANNELS | undefined => { + const normalizedMediaType = typeof mediaType === 'string' ? mediaType.toLowerCase() : ''; + const channel = CONSULT_TRANSFER_CHANNELS[normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS]; + + return typeof channel === 'string' ? (normalizedMediaType as keyof typeof CONSULT_TRANSFER_CHANNELS) : undefined; +}; + +const getQueueChannelFilter = (mediaType?: string): string | undefined => { + const supportedMediaType = getSupportedMediaType(mediaType); + const channelType = supportedMediaType ? CONSULT_TRANSFER_CHANNELS[supportedMediaType] : undefined; + + if (!channelType) return undefined; + + return `queueType==INBOUND;channelType==${channelType};active==true`; +}; + class StoreWrapper implements IStoreWrapper { store: IStore; onIncomingTask: ({task}: {task: ITask}) => void; @@ -1246,15 +1268,22 @@ class StoreWrapper implements IStoreWrapper { }); }; - getBuddyAgents = async ( - mediaType: string = this.currentTask.data.interaction.mediaType - ): Promise> => { + getBuddyAgents = async (actionOrMediaType?: string): Promise> => { try { - const response = await this.store.cc.getBuddyAgents({ - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - mediaType: mediaType ?? MEDIA_TYPE_TELEPHONY_LOWER, - state: AGENT_STATE_AVAILABLE, - }); + const isAction = actionOrMediaType === 'Consult' || actionOrMediaType === 'Transfer'; + const taskMediaType = getSupportedMediaType(this.currentTask?.data?.interaction?.mediaType); + const mediaType = isAction ? taskMediaType : (getSupportedMediaType(actionOrMediaType) ?? taskMediaType); + const response = await this.store.cc.getBuddyAgents( + isAction + ? { + action: actionOrMediaType, + ...(mediaType ? {mediaType} : {}), + } + : { + mediaType: mediaType ?? MEDIA_TYPE_TELEPHONY_LOWER, + state: AGENT_STATE_AVAILABLE, + } + ); return 'data' in response ? response.data.agentList : []; } catch (error) { this.store.logger.error('Error fetching buddy agents:', error); @@ -1263,24 +1292,25 @@ class StoreWrapper implements IStoreWrapper { }; getQueues = async ( - mediaType: string = this.currentTask.data.interaction.mediaType ?? MEDIA_TYPE_TELEPHONY_UPPER, - params?: ContactServiceQueueSearchParams - ): Promise<{ - data: ContactServiceQueue[]; - meta: {page: number; pageSize: number; total: number; totalPages: number}; - }> => { + mediaTypeOrParams?: string | ContactServiceQueueSearchParams, + legacyParams?: ContactServiceQueueSearchParams + ): Promise => { try { - const upperMediaType = mediaType.toUpperCase(); - const response = await this.store.cc.getQueues(params); - const data = Array.isArray(response) ? response : response.data; - const filtered = data.filter((queue) => queue.channelType === upperMediaType); - const page = Array.isArray(response) ? 0 : (response.meta?.page ?? 0); - const totalPages = Array.isArray(response) ? 1 : (response.meta?.totalPages ?? 1); - const pageSize = Array.isArray(response) ? filtered.length : (response.meta?.pageSize ?? filtered.length); - const total = Array.isArray(response) - ? filtered.length - : ((response as {meta?: {total?: number}}).meta?.total ?? filtered.length); - return {data: filtered, meta: {page, pageSize, total, totalPages}}; + const usesLegacyMediaSignature = typeof mediaTypeOrParams === 'string' || legacyParams !== undefined; + const params = typeof mediaTypeOrParams === 'string' ? legacyParams : (mediaTypeOrParams ?? legacyParams); + const mediaType = + typeof mediaTypeOrParams === 'string' ? mediaTypeOrParams : this.currentTask?.data?.interaction?.mediaType; + const supportedMediaType = getSupportedMediaType(mediaType); + const taskFilter = + usesLegacyMediaSignature || (supportedMediaType && supportedMediaType !== 'telephony') + ? getQueueChannelFilter(mediaType) + : undefined; + const filter = taskFilter && params?.filter ? `${taskFilter};${params.filter}` : (taskFilter ?? params?.filter); + + return await this.store.cc.getQueues({ + ...(params ?? {}), + ...(filter !== undefined ? {filter} : {}), + }); } catch (error) { this.store.logger.error('Error fetching queues:', error); throw error; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index e41c51e94..dfd2d43b3 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -1005,53 +1005,167 @@ describe('storeEventsWrapper', () => { }); it('should return buddy agents list', async () => { - const buddyAgents = [{name: 'agent1'}, {name: 'agent2'}]; + const buddyAgents = [ + {agentName: 'Zeta Agent', agentId: '3'}, + {agentName: 'Alpha Agent', agentId: '1'}, + {agentName: 'Beta Agent', agentId: '2'}, + ]; + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: buddyAgents}}); - const result = await storeWrapper.getBuddyAgents('telephony'); + const result = await storeWrapper.getBuddyAgents('Consult'); expect(result).toEqual(buddyAgents); + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({ + action: 'Consult', + mediaType: 'telephony', + }); + }); + + it('should pass the transfer intent to the SDK', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); + + await storeWrapper.getBuddyAgents('Transfer'); + + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({ + action: 'Transfer', + mediaType: 'telephony', + }); + }); + + it('should preserve the legacy media-type buddy-agent call', async () => { + storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); + + await storeWrapper.getBuddyAgents('chat'); + + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({ + mediaType: 'chat', + state: 'Available', + }); + }); + + it('should omit an unsupported task media type and use the SDK default', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'video'}}} as ITask; + storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockResolvedValue({data: {agentList: []}}); + + await storeWrapper.getBuddyAgents('Consult'); + + expect(storeWrapper['store'].cc.getBuddyAgents).toHaveBeenCalledWith({action: 'Consult'}); }); it('should handle error in getBuddyAgents and throw error', async () => { + storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getBuddyAgents = jest.fn().mockRejectedValue(new Error('error')); - await expect(storeWrapper.getBuddyAgents('telephony')).rejects.toThrow('error'); + await expect(storeWrapper.getBuddyAgents('Consult')).rejects.toThrow('error'); }); it('should return contact service queues list', async () => { const queueList = [ {id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}, {id: 'queue2', name: 'Queue 2', channelType: 'TELEPHONY'}, - {id: 'queue3', name: 'Queue 3', channelType: 'CHAT'}, // This one should be filtered out + {id: 'queue3', name: 'Queue 3', channelType: 'CHAT'}, ]; - storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(queueList); + const response = {data: queueList, meta: {page: 0, totalPages: 1}}; + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(response); - const result = await storeWrapper.getQueues('telephony'); + const result = await storeWrapper.getQueues(); - expect(result.data).toEqual([ - {id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}, - {id: 'queue2', name: 'Queue 2', channelType: 'TELEPHONY'}, - ]); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalled(); + expect(result.data).toEqual(queueList); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({}); + }); + + it('should pass only runtime context and list inputs when getQueues is called with params', async () => { + const queueList = [{id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}]; + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest + .fn() + .mockResolvedValue({data: queueList, meta: {page: 1, totalPages: 1}}); + + await storeWrapper.getQueues({page: 1, pageSize: 25}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + page: 1, + pageSize: 25, + }); + }); + + it('should use the existing queue filter parameter for a non-telephony task', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'social'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues({page: 0, pageSize: 25}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==SOCIAL_CHANNEL;active==true', + page: 0, + pageSize: 25, + }); + }); + + it('should combine a caller filter with the active task channel filter', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'chat'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues({filter: 'name==Support', page: 0}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==CHAT;active==true;name==Support', + page: 0, + }); + }); + + it('should preserve the legacy media-type and params queue call', async () => { + storeWrapper['store'].currentTask = null; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues('social', {page: 2, search: 'support'}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==SOCIAL_CHANNEL;active==true', + page: 2, + search: 'support', + }); + }); + + it('should preserve telephony scoping for the legacy queue call when a caller filter is supplied', async () => { + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues('telephony', {filter: 'name==Support'}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({ + filter: 'queueType==INBOUND;channelType==TELEPHONY;active==true;name==Support', + }); + }); + + it('should preserve an explicit empty filter in the params-only queue call', async () => { + storeWrapper['store'].currentTask = {data: {interaction: {mediaType: 'telephony'}}} as ITask; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + + await storeWrapper.getQueues({filter: ''}); + + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({filter: ''}); }); it('should handle error in getQueues and throw error', async () => { + storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getQueues = jest.fn().mockRejectedValue(new Error('queue error')); - await expect(storeWrapper.getQueues('telephony')).rejects.toThrow('queue error'); + await expect(storeWrapper.getQueues()).rejects.toThrow('queue error'); }); it('should return contact service queues list when SDK returns paginated response', async () => { const queueList = [ - {...mockQueueDetails[0], channelType: 'TELEPHONY'}, - {...mockQueueDetails[1], channelType: 'CHAT'}, + {id: mockQueueDetails[0].id, name: mockQueueDetails[0].name}, + {id: mockQueueDetails[1].id, name: mockQueueDetails[1].name}, ]; - storeWrapper['store'].cc.getQueues = jest - .fn() - .mockResolvedValue({data: queueList, meta: {page: 1, pageSize: 50, total: 2, totalPages: 1}}); + const response = {data: queueList, meta: {page: 1, pageSize: 50, totalRecords: 2, totalPages: 1}}; + storeWrapper['store'].currentTask = null; + storeWrapper['store'].cc.getQueues = jest.fn().mockResolvedValue(response); - const result = await storeWrapper.getQueues('telephony'); + const result = await storeWrapper.getQueues(); - expect(result.data).toEqual([{...mockQueueDetails[0], channelType: 'TELEPHONY'}]); - expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalled(); + expect(result).toEqual(response); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalledWith({}); }); it('should handle consultQueueCancelled event', () => { @@ -1074,6 +1188,7 @@ describe('storeEventsWrapper', () => { }); it('should handle error while fetching entry points', async () => { + storeWrapper['store'].currentTask = null; storeWrapper['store'].cc.getEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); await expect(storeWrapper.getEntryPoints({page: 0, pageSize: 25})).rejects.toThrow('ep error'); }); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..98e6b3489 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -88,7 +88,7 @@ Compatibility notes: - `conferenceEnabled` is normalized to `true` when undefined inside the `CallControl`/`CallControlCAD` wrappers; consumers relying on `undefined` getting `false` would break. ## Requires (dependencies) -- `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask`, `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names: `packages/contact-center/store/src/store.types.ts`. +- `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask` (including SDK `TaskUIControls`), `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names and destination-control types: `packages/contact-center/store/src/store.types.ts`. - `@webex/cc-components` (internal): presentational components (`IncomingTaskComponent`, `TaskListComponent`, `CallControlComponent`, `CallControlCADComponent`, `OutdialCallComponent`, `RealTimeTranscriptComponent`) and types (`ControlProps`, `TaskProps`, `OutdialCallProps`, `Visibility`, `ControlVisibility`, `RealTimeTranscriptComponentProps`, `CampaignCallProcessingDetails`). - `@webex/contact-center` (SDK, transitive via store): the `ITask` interface and methods invoked here (`accept`, `decline`, `hold`, `resume`, `end`, `wrapup`, `cancelAutoWrapupTimer`, `pauseRecording`, `resumeRecording`, `toggleMute`, `transfer`, `consult`, `endConsult`, `consultTransfer`, `consultConference`, `transferConference`, `exitConference`), `cc.startOutdial`, `cc.getOutdialAniEntries`, `cc.addressBook.getEntries`, `cc.agentConfig`. - `react` ^18, `mobx-react-lite`, `react-error-boundary`. @@ -120,6 +120,7 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | +| `TASK-R-024` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated destination policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. @@ -392,7 +393,7 @@ The six widget shells are siblings that each bind to exactly one hook and one pr - **UC-3 Hold / resume active call (CallControl):** Agent clicks Hold → `toggleHold(true)` → `currentTask.hold()` → `TASK_HOLD` → hold timer starts via `useHoldTimer`, `onHoldResume({isHeld:true})`. Evidence: `src/helper.ts`, `src/Utils/useHoldTimer.ts`, `tests/helper.ts` (hold/resume cases). UI flow: Hold button toggles to Resume; "Hold" elapsed timer shown. - **UC-4 Toggle recording (CallControl):** Agent clicks record toggle → `toggleRecording()` → `pauseRecording()`/`resumeRecording()` → `TASK_RECORDING_PAUSED/RESUMED` → `isRecording` flips, `onRecordingToggle`. Evidence: `src/helper.ts`, `tests/helper.ts` (recording cases). - **UC-5 Mute / unmute (CallControl):** Agent clicks mute → `toggleMute()` (gated by `controlVisibility.muteUnmute`) → `currentTask.toggleMute()` → `store.setIsMuted`, `onToggleMute`. Evidence: `src/helper.ts`, `tests/helper.ts` ("should successfully toggle mute…", "rapid toggleMute"). UI flow: shows error-safe revert on failure. -- **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Evidence: `src/helper.ts`, `tests/helper.ts` (consult/transfer/conference cases), `tests/utils/task-util.ts` (EP-DN end-button cases). UI flow: consult controls (switch/merge/end) appear; consult timer label `Consult Requested`/`Consulting`/`Consult on Hold`. +- **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → component reads the matching ordered category array from `currentTask.uiControls.consultTransferDestinations` → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Evidence: `src/helper.ts`, `src/CallControl/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`. - **UC-7 Blind transfer (CallControl):** Agent picks destination → `transferCall(to,type)` → `currentTask.transfer(...)`; failure re-thrown to the modal. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call transferCall successfully"). - **UC-8 Wrap up a call (CallControl):** Call ends → wrap-up codes shown → Agent selects code → `wrapupCall(reason, auxCodeId)` → `currentTask.wrapup` → next task promoted to `currentTask`, agent → ENGAGED. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call wrapupCall"). Auto-wrap-up: countdown shown with Cancel. UI flow: wrap-up dropdown + optional auto-wrap-up countdown. - **UC-9 Place an outbound call (OutdialCall):** Agent enters number, selects ANI → dial → `startOutdial(destination, origin)`; empty number alerts and aborts; disabled while a telephony task is active. Evidence: `src/helper.ts` (`useOutdialCall`), `tests/OutdialCall/index.tsx`. UI flow: dialpad with validation, ANI dropdown, address book toggled by `isAddressBookEnabled`. @@ -504,6 +505,7 @@ Tests are split between widget-shell render tests (each `tests//index.ts | `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | | `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | | `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | +| `TASK-R-024` SDK destination-control pass-through | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`; cc-components focused destination tests | None | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..890f88b77 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -575,22 +575,35 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, agentId, extractConsultingAgent]); - const loadBuddyAgents = useCallback(async () => { - try { - setLoadingBuddyAgents(true); - const agents = await store.getBuddyAgents(); - logger.info(`Loaded ${agents.length} buddy agents`, {module: 'helper.ts', method: 'loadBuddyAgents'}); - setBuddyAgents(agents); - } catch (error) { - logger?.error(`CC-Widgets: Task: Error loading buddy agents - ${error.message || error}`, { - module: 'useCallControl', - method: 'loadBuddyAgents', - }); - setBuddyAgents([]); - } finally { - setLoadingBuddyAgents(false); - } - }, [logger]); + const buddyAgentsRequestIdRef = useRef(0); + + const loadBuddyAgents = useCallback( + async (action: 'Consult' | 'Transfer' = 'Consult') => { + const requestId = ++buddyAgentsRequestIdRef.current; + + try { + setLoadingBuddyAgents(true); + const agents = await store.getBuddyAgents(action); + if (requestId !== buddyAgentsRequestIdRef.current) return; + + logger.info(`Loaded ${agents.length} buddy agents`, {module: 'helper.ts', method: 'loadBuddyAgents'}); + setBuddyAgents(agents); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error loading buddy agents - ${error.message || error}`, { + module: 'useCallControl', + method: 'loadBuddyAgents', + }); + if (requestId !== buddyAgentsRequestIdRef.current) return; + + setBuddyAgents([]); + } finally { + if (requestId === buddyAgentsRequestIdRef.current) { + setLoadingBuddyAgents(false); + } + } + }, + [logger] + ); const getAddressBookEntries = useCallback( async ({page, pageSize, search}: PaginatedListParams) => { @@ -625,8 +638,7 @@ export const useCallControl = (props: useCallControlProps) => { const getQueuesFetcher = useCallback( async ({page, pageSize, search}: PaginatedListParams) => { try { - const mediaType = currentTask?.data?.interaction?.mediaType; - return await store.getQueues(mediaType, {page, pageSize, search}); + return await store.getQueues({page, pageSize, search}); } catch (error) { logger?.error(`CC-Widgets: Task: Error fetching queues (paginated) - ${error.message || error}`, { module: 'useCallControl', @@ -635,7 +647,7 @@ export const useCallControl = (props: useCallControlProps) => { return {data: [], meta: {page: 0, totalPages: 0}}; } }, - [logger, currentTask] + [logger] ); const holdCallback = () => { diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..240d638b4 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -2400,12 +2400,59 @@ describe('useCallControl', () => { }) ); await act(async () => { - await result.current.loadBuddyAgents(); + await result.current.loadBuddyAgents('Transfer'); }); expect(result.current.buddyAgents).toEqual(mockAgents); + expect(getBuddyAgentsSpy).toHaveBeenCalledWith('Transfer'); getBuddyAgentsSpy.mockRestore(); }); + it('should ignore a stale buddy-agent response after the action changes', async () => { + let resolveConsult!: (agents: typeof mockAgents) => void; + let resolveTransfer!: (agents: typeof mockAgents) => void; + const consultResponse = new Promise((resolve) => { + resolveConsult = resolve; + }); + const transferResponse = new Promise((resolve) => { + resolveTransfer = resolve; + }); + jest + .spyOn(store, 'getBuddyAgents') + .mockImplementation((action) => (action === 'Transfer' ? transferResponse : consultResponse)); + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + let consultRequest!: Promise; + let transferRequest!: Promise; + act(() => { + consultRequest = result.current.loadBuddyAgents('Consult'); + transferRequest = result.current.loadBuddyAgents('Transfer'); + }); + + await act(async () => { + resolveTransfer([mockAgents[1]]); + await transferRequest; + }); + expect(result.current.buddyAgents).toEqual([mockAgents[1]]); + + await act(async () => { + resolveConsult([mockAgents[0]]); + await consultRequest; + }); + expect(result.current.buddyAgents).toEqual([mockAgents[1]]); + expect(result.current.loadingBuddyAgents).toBe(false); + }); + it('should call transferCall successfully', async () => { const transferSpy = jest.fn().mockResolvedValue('Transferred'); const currentTaskSuccess = {...mockCurrentTask, transfer: transferSpy}; @@ -3538,7 +3585,7 @@ describe('useCallControl', () => { it('should get queues via getQueuesFetcher', async () => { const getQueuesResponse: Awaited> = { data: mockQueueDetails, - meta: {page: 0, pageSize: mockQueueDetails.length, total: mockQueueDetails.length, totalPages: 1}, + meta: {page: 0, pageSize: mockQueueDetails.length, totalRecords: mockQueueDetails.length, totalPages: 1}, }; const getQueuesSpy = jest.spyOn(store, 'getQueues').mockResolvedValue(getQueuesResponse); @@ -3598,7 +3645,7 @@ describe('useCallControl', () => { it('should get queues via getQueuesFetcher (paginated)', async () => { const mockResponse: Awaited> = { data: [mockQueueDetails[0]], - meta: {page: 0, pageSize: 25, total: 1, totalPages: 1}, + meta: {page: 0, pageSize: 25, totalRecords: 1, totalPages: 1}, }; jest.spyOn(store, 'getQueues').mockResolvedValue(mockResponse); diff --git a/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md b/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md index 4a4efdbb5..47c12808a 100644 --- a/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md +++ b/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md @@ -47,6 +47,7 @@ test-fixtures/src/ ├── fixtures.ts # Core SDK-shaped mocks: mockCC, mockProfile, mockTask, queues, agents, address book, campaign tasks ├── incomingTaskFixtures.ts # mockIncomingTaskData — incoming-task UI data by channel scenario ├── taskListFixtures.ts # mockTaskData — task-list UI data by scenario (active/incoming/action/selection) +├── taskUIControlsFixtures.ts # TaskUIControls factories with per-leg and destination overrides └── components/task/ └── outdialCallFixtures.ts # Outdial mocks composed from mockCC: mockOutdialCallProps, mockAniEntries, mockCCWithAni ``` @@ -59,6 +60,7 @@ test-fixtures/src/ | `packages/contact-center/test-fixtures/src/fixtures.ts` | Core fixture values and their type annotations (`IContactCenter`, `Profile`, `ITask`, etc.). Never re-infer these shapes elsewhere. | | `packages/contact-center/test-fixtures/src/incomingTaskFixtures.ts` | `mockIncomingTaskData` and its `MEDIA_CHANNEL` source import. | | `packages/contact-center/test-fixtures/src/taskListFixtures.ts` | `mockTaskData` and its `MEDIA_CHANNEL` source import. | +| `packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts` | Typed `TaskUIControls` factories, including consult/transfer destination-control overrides. | | `packages/contact-center/test-fixtures/src/components/task/outdialCallFixtures.ts` | Outdial fixtures derived from `mockCC`. | | `packages/contact-center/test-fixtures/package.json` | Dependency list and the `deploy:npm` no-op. | @@ -82,6 +84,7 @@ Internal Surface — consumed only by other packages' Jest tests in this monorep | `test-fixtures.mockCallAssociatedData` | data export | `mockCallAssociatedData` | Call-associated-data variants (global, viewable/hidden, secure) | Additive keys safe | `src/fixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockIncomingTaskData` | data export | `mockIncomingTaskData` | Incoming-task UI data keyed `webRTC`/`extension`/`social`/`chat` | Additive scenario keys safe | `src/incomingTaskFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockTaskData` | data export | `mockTaskData` | Task-list UI data keyed `active`/`incoming`/`action`/`selection` | Additive scenario keys safe | `src/taskListFixtures.ts` | internal (`src/index.ts`) | +| `test-fixtures.createMockTaskUIControls` | factory export | `createMockTaskUIControls(overrides?): TaskUIControls` | Produces SDK-shaped task controls with independent main/consult/active-leg and ordered consult/transfer destination overrides | Track `TaskUIControls`; additive override fields are safe | `src/taskUIControlsFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockOutdialCallProps` | data export | `mockOutdialCallProps` | `mockCC` spread + `startOutdial`/`getOutdialANIEntries` jest mocks | Spread of `mockCC` | `src/components/task/outdialCallFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockAniEntries` | data export | `mockAniEntries` | Outdial ANI entry list | Additive fields safe | `src/components/task/outdialCallFixtures.ts` | internal (`src/index.ts`) | | `test-fixtures.mockCCWithAni` | data export | `mockCCWithAni` | `mockCC` + `agentConfig.outdialANIId` + ANI-resolving `getOutdialAniEntries` | Spread of `mockCC` | `src/components/task/outdialCallFixtures.ts` | internal (`src/index.ts`) | @@ -108,6 +111,7 @@ Compatibility notes: | `test-fixtures-R-005` | `mockIncomingTaskData` and `mockTaskData` expose UI-data variants keyed by scenario (incoming: `webRTC`/`extension`/`social`/`chat`; list: `active`/`incoming`/`action`/`selection`) using the shared `MEDIA_CHANNEL` enum. | Task widget tests render against named, channel-correct scenarios rather than ad-hoc literals. | `src/incomingTaskFixtures.ts`, `src/taskListFixtures.ts` | none found | none | PRESENT | | `test-fixtures-R-006` | Outdial fixtures (`mockOutdialCallProps`, `mockCCWithAni`) are composed by spreading `mockCC` and adding outdial-specific jest mocks/config, keeping a single source of SDK shape. | Avoids a divergent second SDK mock; outdial tests inherit the canonical `mockCC`. | `src/components/task/outdialCallFixtures.ts` | none found | none | PRESENT | | `test-fixtures-R-007` | Every fixture and factory is re-exported through the barrel `src/index.ts`; non-barrelled internals (`mockAddressBook`, `mockQueuesResponse`) are not part of the public surface. | Consumers import from the package root; the barrel is the stability boundary. | `src/index.ts`, `src/fixtures.ts` (export list) | none found | none | PRESENT | +| `test-fixtures-R-008` | `createMockTaskUIControls` starts from the SDK defaults, applies independent per-leg and active-leg overrides, and preserves or overrides the ordered `consultTransferDestinations.consult` and `.transfer` arrays. | Component tests must represent the same task-owned destination visibility and ordering contract used at runtime without rebuilding that policy in test code. | `src/taskUIControlsFixtures.ts` | Consumed by call-control tests in `packages/contact-center/cc-components/tests` | No package-local unit test; type and shape are checked by package builds and consumers. | PRESENT | Do not record raw data/schema inventory as requirements. The per-field contents of each mock are descriptive data in `src/`, not behavioral requirements. @@ -221,6 +225,7 @@ The package ships no tests of its own (no `tests/` directory; confirmed by tree) | `test-fixtures-R-005` (scenario UI data) | None found | Consumed indirectly by task widget tests only | | `test-fixtures-R-006` (outdial composition) | None found | Consumed by outdial widget tests only | | `test-fixtures-R-007` (barrel surface) | None found | No test guarding the public export list | +| `test-fixtures-R-008` (task UI controls) | Call-control consumer tests; `@webex/test-fixtures` and `@webex/cc-components` builds | No package-local assertion for destination override merging | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) diff --git a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts index b3c6001da..cf6741beb 100644 --- a/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts +++ b/packages/contact-center/test-fixtures/src/taskUIControlsFixtures.ts @@ -10,12 +10,17 @@ export function createMockTaskUIControls(overrides?: { main?: Partial; consult?: Partial; activeLeg?: TaskUILeg; + consultTransferDestinations?: Partial; }): TaskUIControls { const base = getDefaultUIControls(); return { activeLeg: overrides?.activeLeg ?? base.activeLeg, main: {...base.main, ...overrides?.main}, consult: {...base.consult, ...overrides?.consult}, + consultTransferDestinations: { + consult: overrides?.consultTransferDestinations?.consult ?? base.consultTransferDestinations.consult, + transfer: overrides?.consultTransferDestinations?.transfer ?? base.consultTransferDestinations.transfer, + }, }; }