diff --git a/ai-docs/RULES.md b/ai-docs/RULES.md index d82984c4c..a1f52dc6f 100644 --- a/ai-docs/RULES.md +++ b/ai-docs/RULES.md @@ -10,53 +10,72 @@ Coverage state is mirrored from `.sdd/manifest.json`. Every module is currently `DRAFT`: specs were generated fresh during the SDLC migration (2026-06-29) and have not been validated. **Cross-check code before relying on any spec claim** (drift tolerance ≤ 25%, see below). -| Module | Manifest coverage state | What it means here | -|---|---|---| -| `store` (`@webex/cc-store`, `packages/contact-center/store`) | DRAFT | Tier-1, sole SDK access point (`store.ts` `getInstance`). Spec `store/ai-docs/store-spec.md` is unvalidated — verify observables/SDK proxying against `store/src/store.ts` and `store/src/storeEventsWrapper.ts`. | -| `cc-components` (`@webex/cc-components`, `packages/contact-center/cc-components`) | DRAFT | Tier-1 presentational primitives. Verify prop contracts against `cc-components/src/` before trusting the spec. | -| `cc-widgets` (`@webex/cc-widgets`, `packages/contact-center/cc-widgets`) | DRAFT | Tier-1 r2wc aggregator. The custom-element registry lives in `cc-widgets/src/wc.ts` — cross-check element names/attrs there. | -| `cc-digital-channels` (`@webex/cc-digital-channels`, `packages/contact-center/cc-digital-channels`) | DRAFT | Tier-1 digital-channels widget (`widget-cc-digital-channels`). Verify against `cc-digital-channels/src/`. | -| `station-login` (`@webex/cc-station-login`, `packages/contact-center/station-login`) | DRAFT | Tier-1 widget. Verify against `station-login/src/`. | -| `user-state` (`@webex/cc-user-state`, `packages/contact-center/user-state`) | DRAFT | Tier-1 widget. Verify state/idle-code logic against `user-state/src/helper.ts`. | -| `task` (`@webex/cc-task`, `packages/contact-center/task`) | DRAFT | Tier-1 bundle of sub-widgets CallControl, CallControlCAD, IncomingTask, OutdialCall, TaskList. Verify per-widget behavior against `task/src/{Widget}/index.tsx` and `task/src/helper.ts`. | -| `ui-logging` (`@webex/cc-ui-logging`, `packages/contact-center/ui-logging`) | DRAFT | Tier-2 telemetry. `withMetrics` HOC + `metricsLogger` in `ui-logging/src/`. | -| `test-fixtures` (`@webex/test-fixtures`, `packages/contact-center/test-fixtures`) | DRAFT | Tier-2 shared mocks/helpers in `test-fixtures/src/`. | -| `meetings-widgets` (`@webex/widgets`, `packages/@webex/widgets`) | DRAFT | Tier-2 legacy meetings family, separate from the CC widget family. Out of scope for CC rules below unless explicitly named. | +| Module | Manifest coverage state | What it means here | +| --------------------------------------------------------------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` (`@webex/cc-store`, `packages/contact-center/store`) | DRAFT | Tier-1, sole SDK access point (`store.ts` `getInstance`). Spec `store/ai-docs/store-spec.md` is unvalidated — verify observables/SDK proxying against `store/src/store.ts` and `store/src/storeEventsWrapper.ts`. | +| `cc-components` (`@webex/cc-components`, `packages/contact-center/cc-components`) | DRAFT | Tier-1 presentational primitives. Verify prop contracts against `cc-components/src/` before trusting the spec. | +| `cc-widgets` (`@webex/cc-widgets`, `packages/contact-center/cc-widgets`) | DRAFT | Tier-1 r2wc aggregator. The custom-element registry lives in `cc-widgets/src/wc.ts` — cross-check element names/attrs there. | +| `cc-digital-channels` (`@webex/cc-digital-channels`, `packages/contact-center/cc-digital-channels`) | DRAFT | Tier-1 digital-channels widget (`widget-cc-digital-channels`). Verify against `cc-digital-channels/src/`. | +| `station-login` (`@webex/cc-station-login`, `packages/contact-center/station-login`) | DRAFT | Tier-1 widget. Verify against `station-login/src/`. | +| `user-state` (`@webex/cc-user-state`, `packages/contact-center/user-state`) | DRAFT | Tier-1 widget. Verify state/idle-code logic against `user-state/src/helper.ts`. | +| `task` (`@webex/cc-task`, `packages/contact-center/task`) | DRAFT | Tier-1 bundle of sub-widgets CallControl, CallControlCAD, IncomingTask, OutdialCall, TaskList. Verify per-widget behavior against `task/src/{Widget}/index.tsx` and `task/src/helper.ts`. | +| `ui-logging` (`@webex/cc-ui-logging`, `packages/contact-center/ui-logging`) | DRAFT | Tier-2 telemetry. `withMetrics` HOC + `metricsLogger` in `ui-logging/src/`. | +| `test-fixtures` (`@webex/test-fixtures`, `packages/contact-center/test-fixtures`) | DRAFT | Tier-2 shared mocks/helpers in `test-fixtures/src/`. | +| `meetings-widgets` (`@webex/widgets`, `packages/@webex/widgets`) | DRAFT | Tier-2 legacy meetings family, separate from the CC widget family. Out of scope for CC rules below unless explicitly named. | ## Autonomy & Ask-First + - **May proceed:** read-only research; bug fixes and feature work scoped to a single CC widget package that follow the established Widget → Hook → Component → Store flow; adding unit/E2E tests; doc edits under `ai-docs/`; copy/string and styling tweaks. - **Ask first / plan + confirm:** changes to the dependency direction or to `cc-widgets/src/wc.ts` custom-element names/attributes (a public contract); changes to the store's observable shape or SDK access surface in `store/src/`; new third-party dependencies; touching the `@webex/widgets` legacy meetings family. - **Never without explicit human approval:** `git push`, opening/merging PRs, or any deploy. PRs target the `next` branch and are draft by default. ## Naming + Grounded in `patterns/typescript-patterns.md` and the real code: + - Interfaces are prefixed with `I` and PascalCase: `IUserState`, `IStationLoginProps`, `IContactCenter` (`store/src/store.types.ts`, `task/src/task.types.ts`). Never an un-prefixed `UserState` interface. - Components are PascalCase in `.tsx` files: `UserState.tsx`, `CallControl/index.tsx`. Hooks are camelCase with a `use` prefix in `.ts` files: `useUserState`, `useCallControl` (`*/src/helper.ts`). - Types are co-located in `{name}.types.ts` (e.g. `user-state/src/user-state.types.ts`); derive subsets with `Pick`/`Partial` rather than re-declaring (e.g. `IUserStateProps = Pick`). - Event/state names are enums in SCREAMING_SNAKE_CASE values, e.g. `CC_EVENTS.AGENT_STATE_CHANGED`, `TASK_EVENTS.TASK_INCOMING`. Constants are SCREAMING_SNAKE_CASE. ## Logging + - Use the `ui-logging` helpers — `withMetrics` HOC and `metricsLogger` (`ui-logging/src/index.ts`, `ui-logging/src/metricsLogger.ts`) — and the store `logger` passed into hooks. Calls carry a structured context object `{module, method}` (see `task/src/helper.ts` `loadBuddyAgents`: `logger.info('Loaded N buddy agents', {module: 'helper.ts', method: 'loadBuddyAgents'})`). - **NEVER log PII or credentials** (agent identifiers in sensitive contexts, dial numbers, tokens, session secrets). Severity: high. Verification: review + grep for new `console.*`/`logger.*` calls in a diff. See Security below. - Prefer the injected `logger` over bare `console.*`; `console.error` is tolerated only inside hook catch blocks where no logger is available. ## Error Handling + - Every widget MUST be wrapped in an `ErrorBoundary` (`react-error-boundary`) at its exported boundary, with `onError` routing to `store.onErrorCallback?.('WidgetName', error)` and a non-throwing fallback (`patterns/react-patterns.md`; pattern realized across `*/src/{Widget}/index.tsx`). Severity: high. Verification: review only. - SDK/async calls in hooks (`helper.ts`) MUST be wrapped in `try/catch`; on failure, log via the injected `logger` and invoke the optional `onError`-style callback rather than letting the rejection escape. Never swallow an error silently. - Surface user-friendly errors in the presentational component (loading/error/empty states); never leak raw SDK errors or stack traces to the UI. +## Styling + +- In `*.style.scss` / `*.scss` files, use `rem` (not `px`) for spacing, sizing, border-radius, border-width, + and box-shadow offsets, and Momentum theme tokens (`var(--mds-color-theme-*)`) for color instead of hex + codes; don't hardcode `font-size`/`font-weight` when the Momentum `Text` component's `type` prop already + controls it. Exceptions: media-query breakpoints and pixel-perfect image/SVG asset dimensions may stay + `px`. See [`rules/css-rem-units.md`](rules/css-rem-units.md). Severity: low. Verification: review only + (no Stylelint config in this repo yet). + ## Imports / Dependencies + **Dependency flow is one direction only** (`.sdd/manifest.json`; enforced by review, source: legacy task-router rule "Circular Dependency Prevention"): + ``` cc-widgets → widget packages (station-login, user-state, task) → cc-components → store → @webex/contact-center SDK ``` + - A widget package MUST NOT import from `@webex/cc-widgets`. `cc-components` MUST NOT import from any widget package. No package imports upstream. Severity: high — if a circular import is detected, STOP and refactor. - Access the SDK ONLY through the store: `store.cc.methodName()` / `store.getBuddyAgents()` (`task/src/helper.ts:519`). NEVER `import ... from '@webex/contact-center'` in widget or component code. Severity: high. - Import the store as the singleton default export: `import store from '@webex/cc-store'`. Never `new Store()`; the instance comes from `Store.getInstance()` (`store/src/store.ts:64`). - New third-party dependencies require maintainer approval (ask-first). ## Testing + Grounded in `patterns/testing-patterns.md`: + - Unit/component tests use Jest + React Testing Library and live in each package's `tests/` folder; E2E tests use Playwright under `playwright/` at repo root. - Each behavior gets both a positive test and the relevant negative/guard test (e.g. error path fires `onError`, callback NOT called on failure). Test behavior via `data-testid`, not CSS selectors or implementation details. - Mock the store with `@webex/test-fixtures` (`test-fixtures/src/`); never hit the real SDK. @@ -65,12 +84,14 @@ Grounded in `patterns/testing-patterns.md`: - Run tests with `yarn workspace @webex/{pkg} test:unit` (single package) or `yarn test:cc-widgets` (all CC); styles via `yarn test:styles`; E2E via `yarn test:e2e`. **Never** `npx jest` directly. ## Security + - No PII or credentials in logs (see Logging) — agent dial numbers, tokens, session/auth material must never reach `logger`/`console` or telemetry payloads. - Reach the SDK only through the store; never import the SDK directly (prevents bypassing the store's auth/state boundary). - No hardcoded secrets/tokens/keys anywhere (see Secrets Policy). Sanitize user-provided input rendered in the UI. - This repo owns NO persistent datastore — all domain data comes from the SDK at runtime, so there is no at-rest data-handling surface here (N/A by construction). A dedicated `SECURITY.md` is not yet present; these rules are the current security posture. ## Spec-Currency & Drift Thresholds + - Update the spec/docs in the SAME change as the code (spec-currency: `.sdd/coverage-policy.defaults.yaml` `specCurrency.sameChangeRequired: true`). - Drift thresholds mirror `.sdd/coverage-policy.defaults.yaml` (drift = share of spec claims no longer matching code): - AUTHORITATIVE ≤ 5% @@ -79,15 +100,19 @@ Grounded in `patterns/testing-patterns.md`: - NONE — no spec to drift from ## Secrets Policy + - No hardcoded secrets/tokens/keys/connection strings — ever. The widgets receive auth context from the host application via the SDK/store at runtime; nothing is sourced from a committed file. Never log secrets, never commit them. ## Concurrency & Async + The repo is reactive (MobX) and event-driven (SDK events), so these apply: + - All store mutations MUST go through `runInAction(() => { ... })` — never mutate observable state directly (`patterns/mobx-patterns.md`; realized in `store/src/storeEventsWrapper.ts`). Severity: high. Verification: review only. - Widgets that read store state MUST be wrapped in `observer()` from `mobx-react-lite` so re-renders track observable reads. Severity: high. - SDK event subscriptions registered in `useEffect` MUST be torn down in the cleanup return (`cc.on(...)` paired with `cc.off(...)`) to avoid duplicate handlers/leaks (`patterns/react-patterns.md`). - `cc` is held as `observable.ref` (`store/src/store.ts`) — replace the reference, don't deep-mutate the SDK instance. ## Maintenance + - Add a rule when a review correction recurs; remove it when a lint rule starts enforcing it. - Cross-reference: patterns → `patterns/` (`typescript-patterns.md`, `react-patterns.md`, `mobx-patterns.md`, `testing-patterns.md`); module specs → `SPEC_INDEX.md`. diff --git a/ai-docs/rules/css-rem-units.md b/ai-docs/rules/css-rem-units.md new file mode 100644 index 000000000..bc48448cb --- /dev/null +++ b/ai-docs/rules/css-rem-units.md @@ -0,0 +1,78 @@ + + +# Rule: Use rem units in component styles, not px + +> Start here → repo root [`AGENTS.md`](../../AGENTS.md) (agent entry, carries the critical rules) · router [`SPEC_INDEX.md`](../SPEC_INDEX.md). This is an `ai-docs/rules/` fill-in; the folder README explains generic-vs-per-language routing; the repo-wide rules digest is `../RULES.md`. +> Context-efficiency: link to canonical docs — don't duplicate them; one rule per file; defer to the linter where it enforces. + +## Rule + +In `*.style.scss` / `*.scss` files under `packages/contact-center/*/src/`, express spacing, sizing, +border-radius, border-width, and box-shadow offsets in `rem`, not hardcoded `px`. Use Momentum design +tokens (`var(--mds-color-theme-*)`) for color instead of hex codes, and avoid hardcoding `font-size` / +`font-weight` when the Momentum `Text` component's `type` prop already controls that typography. + +## Why + +This repo's component styles are built on the Momentum design system, which defines its own type scale +and spacing scale in `rem` (root-relative units respect the user/host app's base font size; `px` does +not). Hardcoded `px` values silently drift from the design tokens and break if a host app changes its +root font size for accessibility. This was flagged in review on PR #719 (`e911-modal.style.scss` used +`px` spacing and hex colors instead of the established convention). + +## How to follow + +- Convert `px` to `rem` at a 16px base (e.g. `8px` → `0.5rem`, `4px` → `0.25rem`, `1px` → `0.0625rem`). + See the inline `// Npx to rem` comments in + `packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss`. +- Reference: + ```scss + // from packages/contact-center/cc-components/src/components/task/CampaignErrorDialog/campaign-error-dialog.style.scss + .campaign-error-dialog { + width: 25rem; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: + 0rem 0.25rem 0.5rem 0rem rgba(0, 0, 0, 0.16), + 0rem 0rem 0.0625rem 0rem rgba(0, 0, 0, 0.16); + } + ``` + Incorrect (pre-fix `e911-modal.style.scss`): + ```scss + .e911-modal { + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + } + .e911-warning-box { + background-color: #fff3cd; // hardcoded hex instead of a Momentum token + border: 1px solid #ffc107; + } + .e911-modal-title { + font-size: 18px; // redundant: Text type="body-large-bold" already sets this + font-weight: 600; + } + ``` +- Use Momentum theme tokens for color, not hex: `var(--mds-color-theme-background-alert-warning-normal)`, + `var(--mds-color-theme-outline-warning-normal)`, `var(--mds-color-theme-text-warning-normal)`, + `var(--mds-color-theme-text-secondary-normal)`, `var(--mds-color-theme-text-accent-normal)`, + `var(--mds-color-theme-outline-primary-normal)` (see `call-control.styles.scss`, `user-state.scss`). +- Don't set `font-size`/`font-weight` in SCSS for text rendered through the Momentum `Text` component — + its `type` prop (e.g. `type="body-large-bold"`) already governs typography. +- **Exceptions:** media-query breakpoints (`@media (max-width: 600px)`) and exact pixel-perfect image/SVG + asset dimensions (e.g. a background-image sized to a specific icon export) may stay in `px` — see the + same `call-control.styles.scss` file for both cases. Don't over-apply the rule to these. + +## Enforced by + +Review only. `yarn test:styles` runs ESLint (`"test:styles": "eslint"` in each package's +`package.json`), not a CSS/SCSS linter — there is no Stylelint config in this repo, so nothing currently +bans `px` or hex colors in `.scss` files automatically. Consider adding Stylelint with +`declaration-property-unit-disallowed-list` (scoped to spacing/sizing properties) and `color-no-hex` to +make this automatic. diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 1439d9e0a..7f8cc7078 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 @@ -4,40 +4,47 @@ > Context-efficiency: link to canonical docs — don't duplicate them. Load specs on demand per `SPEC_INDEX.md`. ## Metadata -| Field | Value | -|---|---| -| Module id | `cc-components` | -| Source path(s) | `packages/contact-center/cc-components/src/` | -| Doc kind | Module spec | -| Coverage score | Pending coverage assessment | -| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | + +| Field | Value | +| --------------------------------------- | -------------------------------------------------------------------------------- | +| Module id | `cc-components` | +| Source path(s) | `packages/contact-center/cc-components/src/` | +| Doc kind | Module spec | +| Coverage score | Pending coverage assessment | +| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | | generated_by / approved_by / updated_at | generated_by `migration agent` / approved_by `pending` / updated_at `2026-06-29` | -| Validation status | not-run | +| Validation status | not-run | ## Evidence Rules + Every generated requirement below cites concrete source evidence using `file path`. Source evidence, test evidence, examples, assumptions, and gaps are kept separate so validators and future agents can distinguish truth from context. Test evidence is preferred for WHY. This repository's tests are the authoritative behavior record; commit history is not cited here. Where evidence is missing it is recorded as a gap rather than asserted. ## Source Material Register -| Source doc | Scope | Decision | Detail location or disposition | -|---|---|---|---| -| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/cc-components/ai-docs/AGENTS.md` | overview / API / examples | migrated / reconciled | Orientation → Overview/Purpose/Stack; component table → Public Surface; examples → Use Cases. Reconciled: archived table listed 7 components; code exports 11 (added Campaign* and RealTimeTranscript). | -| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/cc-components/ai-docs/ARCHITECTURE.md` | architecture / component table | migrated / reconciled | Component table, file structure, patterns, diagrams → Design Overview, Data Flow, Class/Component Relationships, Folder structure, Pitfalls. Conflict: archived doc imports `@momentum-design/components` and `@momentum-ui/react-collaboration` interchangeably; code uses both (see Stack). | -| `packages/contact-center/cc-components/src/` and `tests/` | source / tests | reference-only (ground truth) | All requirements, props, and component inventory grounded against live source and `tests/components/`. | + +| Source doc | Scope | Decision | Detail location or disposition | +| --------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/cc-components/ai-docs/AGENTS.md` | overview / API / examples | migrated / reconciled | Orientation → Overview/Purpose/Stack; component table → Public Surface; examples → Use Cases. Reconciled: archived table listed 7 components; code exports 11 (added Campaign\* and RealTimeTranscript). | +| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/cc-components/ai-docs/ARCHITECTURE.md` | architecture / component table | migrated / reconciled | Component table, file structure, patterns, diagrams → Design Overview, Data Flow, Class/Component Relationships, Folder structure, Pitfalls. Conflict: archived doc imports `@momentum-design/components` and `@momentum-ui/react-collaboration` interchangeably; code uses both (see Stack). | +| `packages/contact-center/cc-components/src/` and `tests/` | source / tests | reference-only (ground truth) | All requirements, props, and component inventory grounded against live source and `tests/components/`. | ## Overview + `cc-components` is the presentation layer for Webex Contact Center widgets. It is a library of pure, presentational React function components — each receives all data and callbacks via props and renders Momentum UI primitives. Components do not import or read the MobX store, do not call the SDK, and hold only transient local UI state (open/closed menus, selected dropdown values, input text). Business logic and store/SDK access live one layer up, in the widget packages (`station-login`, `user-state`, `task`) that compose these components. The package exports two surfaces from `src/index.ts` (React components + their prop types) and `src/wc.ts` (the same components wrapped as custom elements via `@r2wc/react-to-web-component`). A maintainer should start at `src/index.ts` to see the public component set, then open the component directory under `src/components/` (each has `*.tsx`, a `*.types.ts` or shared `task.types.ts`, a `*.utils.ts(x)` for extracted logic, and a `*.scss`). Shared, cross-component logic lives in `src/utils/index.ts` (`formatTime`, `getMediaTypeInfo`) and `src/hooks/` (`useIntersectionObserver`). -The component set spans the contact-center agent surface: station login, agent state, the task lifecycle (incoming task, call control with consult/transfer, CAD-enabled call control, task list, outdial), live transcript, and campaign-preview dialing (countdown, error dialog, campaign task card/popover/list-item). +The component set spans the contact-center agent surface: station login (including the E911 emergency-service acknowledgment modal), agent state, the task lifecycle (incoming task, call control with consult/transfer, CAD-enabled call control, task list, outdial), live transcript, and campaign-preview dialing (countdown, error dialog, campaign task card/popover/list-item). ## Purpose / Responsibility + Owns the presentational React UI primitives for contact-center widgets: render agent/task UI from props and emit user intent back through callback props. Does NOT own state management, SDK access, business logic, or web-component registration into the host (the actual custom-element registration into the host app is `cc-widgets`' responsibility; `wc.ts` here only defines `component-cc-*` elements for the library build). ## Stack + TypeScript 5.6, React 18 (peer `react`/`react-dom` `>=18.3.1`), function components with hooks. UI primitives from `@momentum-ui/react-collaboration` (peer `>=26.197.0`) and `@momentum-design/components/dist/react`. Web-component wrapping via `@r2wc/react-to-web-component` `2.0.3`. Metrics via `@webex/cc-ui-logging` (`withMetrics` HOC). Types consumed from `@webex/cc-store`. Test stack: Jest 29 + React Testing Library 16 + `@testing-library/jest-dom`, jsdom environment, snapshot tests alongside behavioral tests. Build: `tsc` for the type build, Webpack 5 for the bundle. Test command: `yarn workspace @webex/cc-components test:unit`. ## Folder / Package Structure + ``` packages/contact-center/cc-components/src/ ├── index.ts # React component + type barrel (public surface) @@ -74,41 +81,46 @@ tests/hooks/ # Hook tests ``` ## Key Files (source of truth) -| File | Holds | -|---|---| -| `src/index.ts` | The public React component set and re-exported type barrels — authoritative export list. | -| `src/wc.ts` | Custom-element tag names (`component-cc-*`) and the r2wc prop type maps per component. | -| `src/components/task/task.types.ts` | Shared task prop interfaces and the `Pick`-derived component prop types (`CallControlComponentProps`, `IncomingTaskComponentProps`, `TaskListComponentProps`, `OutdialCallComponentProps`, `RealTimeTranscriptComponentProps`), plus `MEDIA_CHANNEL`, `TaskState`, `ControlVisibility`, campaign types. | -| `src/components/StationLogin/station-login.types.ts` | `IStationLoginProps` and the `StationLoginComponentProps` Pick. | -| `src/components/UserState/user-state.types.ts` | `IUserState`, `UserStateComponentsProps` Pick, `AgentUserState` enum. | -| `src/components/StationLogin/constants.ts` | Login labels/error strings — re-exported from the barrel; never hardcode these elsewhere. | -| `src/components/task/constants.ts` | Task UI label/string constants (e.g. `CAMPAIGN_CALL`, `WRAP_UP`). | -| `src/utils/index.ts` | `formatTime` (timer formatting) and `getMediaTypeInfo` (media icon/label mapping). | + +| File | Holds | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/index.ts` | The public React component set and re-exported type barrels — authoritative export list. | +| `src/wc.ts` | Custom-element tag names (`component-cc-*`) and the r2wc prop type maps per component. | +| `src/components/task/task.types.ts` | Shared task prop interfaces and the `Pick`-derived component prop types (`CallControlComponentProps`, `IncomingTaskComponentProps`, `TaskListComponentProps`, `OutdialCallComponentProps`, `RealTimeTranscriptComponentProps`), plus `MEDIA_CHANNEL`, `TaskState`, `ControlVisibility`, campaign types. | +| `src/components/StationLogin/station-login.types.ts` | `IStationLoginProps` and the `StationLoginComponentProps` Pick. | +| `src/components/UserState/user-state.types.ts` | `IUserState`, `UserStateComponentsProps` Pick, `AgentUserState` enum. | +| `src/components/StationLogin/constants.ts` | Login labels/error strings — re-exported from the barrel; never hardcode these elsewhere. | +| `src/components/task/constants.ts` | Task UI label/string constants (e.g. `CAMPAIGN_CALL`, `WRAP_UP`). | +| `src/utils/index.ts` | `formatTime` (timer formatting) and `getMediaTypeInfo` (media icon/label mapping). | ## Public Surface + Consumed as an imported SDK/code API. The React barrel (`src/index.ts`) is the primary surface; `src/wc.ts` exposes the same components as custom elements for the library build. Exact prop schemas live in the `*.types.ts` files (linked below); the root contract index (`CONTRACTS.md`) documents how the consuming `cc-widgets`/widget layer re-exposes these as custom elements. -| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | -|---|---|---|---|---|---|---| -| `cc-components.StationLoginComponent` | SDK | `StationLoginComponent` (`StationLoginComponentProps`) | Agent login: device/team selection, login/logout, multiple-login alert, profile mode | semver; props are `Pick`ed — adding optional props = minor, removing/renaming a picked prop = major | `src/components/StationLogin/station-login.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.UserStateComponent` | SDK | `UserStateComponent` (`UserStateComponentsProps`) | Agent state dropdown + idle codes + state timer | semver as above | `src/components/UserState/user-state.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CallControlComponent` | SDK | `CallControlComponent` (`CallControlComponentProps`) | Call control buttons: hold/resume, mute, record, end, wrapup, consult/transfer/conference | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.OutdialCallComponent` | SDK | `OutdialCallComponent` (`OutdialCallComponentProps`) | Outbound dialpad, ANI selection, address-book search | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.RealTimeTranscriptComponent` | SDK | `RealTimeTranscriptComponent` (`RealTimeTranscriptComponentProps`) | Renders sorted live transcript entries; empty state when none | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CampaignCountdownComponent` | SDK | `CampaignCountdownComponent` (`CampaignCountdownProps`) | Campaign preview offer countdown; fires `onTimeout` at zero | semver as above | `src/components/task/CampaignCountdown/campaign-countdown.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CampaignErrorDialogComponent` | SDK | `CampaignErrorDialogComponent` (`CampaignErrorDialogProps`) | Modal shown when a campaign action (accept/skip/remove/cancel) fails | semver as above | `src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.CampaignTaskComponent` | SDK | `CampaignTaskComponent` (`CampaignTaskProps`) | Campaign preview card: accept/skip/remove, countdown, error dialog | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-components.wc` | SDK | `@webex/cc-components/wc` → custom elements `component-cc-user-state`, `component-cc-station-login`, `component-cc-call-control`, `component-cc-call-control-cad`, `component-cc-incoming-task`, `component-cc-task-list`, `component-cc-out-dial-call`, `component-cc-realtime-transcript` | Custom-element build of the components | tag names are breaking surface; r2wc prop type map is part of the contract | `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | +| -------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | +| `cc-components.StationLoginComponent` | SDK | `StationLoginComponent` (`StationLoginComponentProps`) | Agent login: device/team selection, login/logout, multiple-login alert, profile mode | semver; props are `Pick`ed — adding optional props = minor, removing/renaming a picked prop = major | `src/components/StationLogin/station-login.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.UserStateComponent` | SDK | `UserStateComponent` (`UserStateComponentsProps`) | Agent state dropdown + idle codes + state timer | semver as above | `src/components/UserState/user-state.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CallControlComponent` | SDK | `CallControlComponent` (`CallControlComponentProps`) | Call control buttons: hold/resume, mute, record, end, wrapup, consult/transfer/conference | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CallControlCADComponent` | SDK | `CallControlCADComponent` (`CallControlComponentProps`) | Call control with customer/queue header and agent-viewable CAD global variables | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.IncomingTaskComponent` | SDK | `IncomingTaskComponent` (`IncomingTaskComponentProps`) | Incoming task notification with Answer/Decline | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.TaskListComponent` | SDK | `TaskListComponent` (`TaskListComponentProps`) | Active + incoming task list; renders campaign preview when enabled | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.OutdialCallComponent` | SDK | `OutdialCallComponent` (`OutdialCallComponentProps`) | Outbound dialpad, ANI selection, address-book search | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.RealTimeTranscriptComponent` | SDK | `RealTimeTranscriptComponent` (`RealTimeTranscriptComponentProps`) | Renders sorted live transcript entries; empty state when none | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CampaignCountdownComponent` | SDK | `CampaignCountdownComponent` (`CampaignCountdownProps`) | Campaign preview offer countdown; fires `onTimeout` at zero | semver as above | `src/components/task/CampaignCountdown/campaign-countdown.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CampaignErrorDialogComponent` | SDK | `CampaignErrorDialogComponent` (`CampaignErrorDialogProps`) | Modal shown when a campaign action (accept/skip/remove/cancel) fails | semver as above | `src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.CampaignTaskComponent` | SDK | `CampaignTaskComponent` (`CampaignTaskProps`) | Campaign preview card: accept/skip/remove, countdown, error dialog | semver as above | `src/components/task/task.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.E911Modal` | SDK | `E911Modal` (`E911ModalProps`) | Emergency-service (E911) acknowledgment modal shown on BROWSER station login; Cancel is the only dismissal path, Save & Continue is gated on the checkbox and disabled while the save is in flight | semver as above; not yet in `wc.ts` (React-only, no custom-element wrapper) | `src/components/StationLogin/E911Modal/e911-modal.types.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-components.wc` | SDK | `@webex/cc-components/wc` → custom elements `component-cc-user-state`, `component-cc-station-login`, `component-cc-call-control`, `component-cc-call-control-cad`, `component-cc-incoming-task`, `component-cc-task-list`, `component-cc-out-dial-call`, `component-cc-realtime-transcript` | Custom-element build of the components | tag names are breaking surface; r2wc prop type map is part of the contract | `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: + - Component props are derived with `Pick<...>` over a larger interface (e.g. `IStationLoginProps`, `ControlProps`); only the picked keys are public. Adding an optional picked prop is additive (minor); removing/renaming a picked prop, or narrowing a prop's type, is breaking (major). - Custom-element tag names in `wc.ts` (`component-cc-*`) and their r2wc prop type maps are a breaking surface — renaming a tag or changing a prop's r2wc type (`json`/`string`/`function`/...) breaks host consumers. - `wc.ts` aliases `CallControlCADComponent` to `../CallControl/call-control` (imports `CallControlComponent` under the `CallControlCADComponent` name); the distinct CAD component is `src/components/task/CallControlCAD/call-control-cad.tsx`. See Pitfalls. ## Requires (dependencies) + - `@webex/cc-store` (workspace:\*) — type-only import surface here (`ITask`, `ILogger`, `IContactCenter`, `IdleCode`, `IWrapupCode`, `BuddyDetails`, etc.) and constants such as `ERROR_TRIGGERING_IDLE_CODES`, `LoginOptions`, `DESKTOP`. Components consume types/constants, not the store singleton. - `@webex/cc-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. @@ -118,25 +130,28 @@ Compatibility notes: - `@webex/test-fixtures` (workspace:\*, dev) — shared test mocks. ## Requirements -| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | -|---|---|---|---|---|---|---| -| `CC-COMPONENTS-R-001` | Components are pure presentational: they receive data + callbacks via props and never read the MobX store or call the SDK directly. | Keeps presentation decoupled from state/SDK so components are testable in isolation and reusable across widgets. | `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx` (props-only; no `@webex/cc-store` singleton import) | `tests/components/StationLogin/station-login.tsx` (renders from props, mocks callbacks) | None | PRESENT | -| `CC-COMPONENTS-R-002` | The public React surface is exactly the 11 components exported from `index.ts` plus the re-exported type barrels. | Defines the supported import surface; consumers must not import internal subcomponents. | `src/index.ts` | `tests/components/StationLogin/station-login.tsx`, `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` (import the exported components) | None | PRESENT | -| `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-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 | -| `CC-COMPONENTS-R-010` | `OutdialCallComponent` validates the entered destination, supports dialpad / ANI / address-book tabs, disables the outdial action while a telephony task is active, and calls `startOutdial(destination, origin?)`. | Outbound dialing must validate input and not start a second call over an active one. | `src/components/task/OutdialCall/outdial-call.tsx`, `src/components/task/OutdialCall/constants.ts` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-011` | `RealTimeTranscriptComponent` sorts `liveTranscriptEntries` by ascending `timestamp` before rendering and shows an empty-state message when there are no entries. | Transcript must read chronologically and degrade gracefully when empty. | `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-012` | Campaign preview UI: `CampaignTaskComponent` renders accept/skip/remove + countdown and triggers the configured auto-action on timeout; failed actions open `CampaignErrorDialogComponent` with the mapped `CampaignErrorType`; `CampaignCountdownComponent` fires `onTimeout` at zero. | Campaign preview offers are time-boxed; failures and timeouts must be surfaced and auto-handled. | `src/components/task/CampaignTask/campaign-task.tsx`, `src/components/task/CampaignErrorDialog/campaign-error-dialog.tsx` (+ `.types.ts` `CAMPAIGN_ACTION_ERROR_MAP`, `ERROR_TITLES`), `src/components/task/CampaignCountdown/campaign-countdown.tsx` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | PRESENT | -| `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | -| `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | -| `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | + +| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `CC-COMPONENTS-R-001` | Components are pure presentational: they receive data + callbacks via props and never read the MobX store or call the SDK directly. | Keeps presentation decoupled from state/SDK so components are testable in isolation and reusable across widgets. | `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx` (props-only; no `@webex/cc-store` singleton import) | `tests/components/StationLogin/station-login.tsx` (renders from props, mocks callbacks) | None | PRESENT | +| `CC-COMPONENTS-R-002` | The public React surface is exactly the 12 components exported from `index.ts` (11 base components plus `E911Modal`) plus the re-exported type barrels. | Defines the supported import surface; consumers must not import internal subcomponents. | `src/index.ts` | `tests/components/StationLogin/station-login.tsx`, `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/RealtimeTranscript/realtime-transcript.tsx`, `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (import the exported components) | `E911Modal` is not yet wrapped as a custom element in `wc.ts` - React-only for now | PRESENT | +| `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-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 | +| `CC-COMPONENTS-R-010` | `OutdialCallComponent` validates the entered destination, supports dialpad / ANI / address-book tabs, disables the outdial action while a telephony task is active, and calls `startOutdial(destination, origin?)`. | Outbound dialing must validate input and not start a second call over an active one. | `src/components/task/OutdialCall/outdial-call.tsx`, `src/components/task/OutdialCall/constants.ts` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-011` | `RealTimeTranscriptComponent` sorts `liveTranscriptEntries` by ascending `timestamp` before rendering and shows an empty-state message when there are no entries. | Transcript must read chronologically and degrade gracefully when empty. | `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-012` | Campaign preview UI: `CampaignTaskComponent` renders accept/skip/remove + countdown and triggers the configured auto-action on timeout; failed actions open `CampaignErrorDialogComponent` with the mapped `CampaignErrorType`; `CampaignCountdownComponent` fires `onTimeout` at zero. | Campaign preview offers are time-boxed; failures and timeouts must be surfaced and auto-handled. | `src/components/task/CampaignTask/campaign-task.tsx`, `src/components/task/CampaignErrorDialog/campaign-error-dialog.tsx` (+ `.types.ts` `CAMPAIGN_ACTION_ERROR_MAP`, `ERROR_TITLES`), `src/components/task/CampaignCountdown/campaign-countdown.tsx` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | PRESENT | +| `CC-COMPONENTS-R-013` | `formatTime` renders `HH:MM:SS` for durations ≥ 1 hour and `MM:SS` otherwise, with zero-padding; `getMediaTypeInfo` maps media type/channel to icon/label/className/brand-visual, falling back to telephony/chat defaults. | Timers and media badges must format consistently across all task components. | `src/utils/index.ts` | `tests/components/task/CallControl/call-control.utils.tsx`, snapshot tests under `tests/components/task/**/__snapshots__/` exercise formatted output | No dedicated `tests/utils/` file found for `formatTime`/`getMediaTypeInfo` (exercised indirectly via component/utils tests) | WEAK | +| `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | +| `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | +| `CC-COMPONENTS-R-016` | `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 | ## Design Overview + Every component follows the same shape: a typed function component destructures props, derives display data through pure helpers in a co-located `*.utils.ts(x)`, renders Momentum primitives, and calls back through callback props on user interaction. Local `useState` holds only transient UI (open menus, selected-but-not-yet-submitted values, input text) — never domain state. Top-level components are wrapped in `withMetrics`. This keeps each component unit-testable with plain props and jest mocks and is the reason the archived "presentational pattern" guidance still holds. Logic that is non-trivial or shared is pulled out of the JSX: per-component utils (`station-login.utils.tsx`, `call-control.utils.ts`, `task-list.utils.ts`, etc.) and library-wide utils (`src/utils/index.ts`: `formatTime`, `getMediaTypeInfo`). `task.types.ts` is the shared type hub for the task family — the larger `ControlProps`/`TaskProps` interfaces describe the full data set, and each component's public prop type is a `Pick` of the keys it actually uses, which is why the public surface is intentionally narrower than the interfaces. @@ -144,6 +159,7 @@ Logic that is non-trivial or shared is pulled out of the JSX: per-component util Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header + `GlobalVariablesPanel`; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes the same components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual registration into a host app happens in `cc-widgets`. ## Data Flow + In-process React props/callbacks only — there is no network, queue, or socket transport in this module. Data flows down as props (sourced by the widget layer from the store/SDK), is transformed by pure utils into render data, rendered via Momentum primitives, and user interaction flows back up by invoking callback props. ```mermaid @@ -164,14 +180,15 @@ flowchart LR ``` ## Sequence Diagram(s) + These components share one interaction pattern (props in → local UI state → callback out); they differ only in which callbacks fire. One representative sequence plus its failure branch covers the module; the campaign timeout/error path is the one non-trivial async branch and is included. Sequence coverage: -| Operation group | Diagram | Failure / recovery coverage | -|---|---|---| +| Operation group | Diagram | Failure / recovery coverage | +| ----------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | User-action components (login, state change, call control, accept/decline, outdial) | "Props-in / callback-out interaction" | Error props (`loginFailure`, `saveError`) rendered as error UI; no internal retry — recovery owned by widget layer | -| Campaign preview offer (countdown + action) | "Campaign preview timeout & error" | Countdown timeout auto-action; failed action opens error dialog (alt branch) | +| Campaign preview offer (countdown + action) | "Campaign preview timeout & error" | Countdown timeout auto-action; failed action opens error dialog (alt branch) | ```mermaid sequenceDiagram @@ -214,6 +231,7 @@ sequenceDiagram ``` ## Class / Component Relationships + ```mermaid graph TD Index["index.ts (public barrel)"] @@ -243,9 +261,11 @@ graph TD CampTask --> CampError CampListItem --> CampCountdown ``` + The exported components are leaves of `index.ts`. Composition is one-directional: `Task` is the shared row reused by `IncomingTask` and `TaskList`; `CallControlCAD` decorates `CallControl` with a CAD header, `GlobalVariablesPanel`, and `TaskTimer`; `CallControl` owns the consult/transfer subtree under `CallControlCustom/` plus `AutoWrapupTimer`; the campaign family composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. Prop types are unified in `task.types.ts` via `Pick` over `ControlProps`/`TaskProps`. ## Use Cases + - **UC-1 Agent logs in:** Widget passes `teams`, `loginOptions`, `deviceType`, and handlers → `StationLoginComponent` renders selectors → agent selects device/team, optionally enters DN → clicks Continue/Save → component calls `login`/`saveLoginOptions`; `loginFailure`/`saveError` props render error UI. Evidence: `src/components/StationLogin/station-login.tsx`, `tests/components/StationLogin/station-login.tsx`. - **UC-2 Agent changes state:** `UserStateComponent` shows idle-code dropdown with current state + elapsed time → agent selects a code → `setAgentStatus(auxCodeId)` fires. Evidence: `src/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.tsx`. - **UC-3 Agent controls an active call:** `CallControlComponent` builds buttons from `controlVisibility` → agent presses hold/mute/record/end/wrapup or opens consult/transfer popover → matching callback fires; wrapup requires a selected reason. Evidence: `src/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.tsx`. @@ -257,10 +277,13 @@ The exported components are leaves of `index.ts`. Composition is one-directional UI flow per use case is detailed in the UI Flow section below. ## State Model + These components hold only transient, local UI state via React `useState` (e.g. open consult/transfer menu, selected-but-unsubmitted wrapup reason and id, mute-button disabled flag in `call-control.tsx`; selected tab, destination text, validation flag, selected ANI in `outdial-call.tsx`). They hold no domain/application state and never read or mutate the MobX store — all persistent state lives in `@webex/cc-store`, owned by the widget layer. Transitions are driven directly by user events and reset on prop changes/remount. No store slices, reducers, or actions are defined in this module (evidence: no MobX or store-singleton import in `src/components/**`). ## UI Flow + These are UI components; the non-happy-path states are part of the contract. + - **StationLogin:** login screen vs. profile mode; device-type select drives dial-number input visibility; Desktop option hidden when `hideDesktopLogin`; multiple-login alert when `showMultipleLoginAlert`; error states from `loginFailure`/`saveError`; Save/Continue disabled until valid. (`src/components/StationLogin/station-login.tsx`) - **UserState:** dropdown of idle/custom states with current state highlighted; `isSettingAgentStatus` shows a busy/disabled state; error-triggering idle codes styled distinctly; state timer renders via `formatTime`. (`src/components/UserState/user-state.tsx`) - **CallControl / CallControlCAD:** button visibility/enablement driven by `controlVisibility`; consult/transfer popover with Agents/Queues/Dial Number/Entry Point tabs (empty-state when no results, loading spinner while fetching); auto-wrapup countdown bar; wrapup requires reason selection before submit. (`src/components/task/CallControl/`, `src/components/task/CallControlCAD/`) @@ -270,11 +293,13 @@ These are UI components; the non-happy-path states are part of the contract. - **Campaign preview:** countdown bar (urgent styling near zero); accept "Connecting..." state; disabled accept/skip/remove flags; error dialog modal on failure. (`src/components/task/CampaignTask/`, `src/components/task/CampaignCountdown/`, `src/components/task/CampaignErrorDialog/`) ## Host Integration & Theming + - Components render Momentum UI primitives (`@momentum-ui/react-collaboration`, `@momentum-design/components/dist/react`) and inherit Momentum theming tokens (e.g. CSS custom properties such as `--mds-color-theme-background-glass-normal` referenced in `task.types.ts` defaults). They assume the host provides Momentum theming/CSS; no `ThemeProvider` is mounted inside this package. - React peer requirement: `react`/`react-dom` `>=18.3.1`, provided by the host. - The `wc.ts` build registers custom elements `component-cc-user-state`, `component-cc-station-login`, `component-cc-call-control`, `component-cc-call-control-cad`, `component-cc-incoming-task`, `component-cc-task-list`, `component-cc-out-dial-call`, `component-cc-realtime-transcript`, each guarded by a `customElements.get(...)` check before `define`. The host-facing custom elements (`widget-cc-*`) and their registration are owned by `cc-widgets`; see [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md). ## Pitfalls + - `wc.ts` imports `CallControlCADComponent` from `../CallControl/call-control` (i.e. it wraps the plain `CallControlComponent`, not the CAD component at `CallControlCAD/call-control-cad.tsx`). The `component-cc-call-control-cad` custom element therefore does NOT render the CAD header from `call-control-cad.tsx`. Confirm intended before relying on the custom-element CAD variant. Evidence: `src/wc.ts` import block. - Public prop types are `Pick`s over larger interfaces (`IStationLoginProps`, `ControlProps`, `TaskProps`). A field can exist on the interface yet not be public — only keys in the `Pick` are part of the contract. Don't infer a prop is supported just because it's on the interface. - Components do not own state: passing a new object/array/callback identity each render (instead of memoized) causes avoidable re-renders, especially for list components (`TaskList`, `OutdialCall`). Memoize props in the widget layer. @@ -283,6 +308,7 @@ These are UI components; the non-happy-path states are part of the contract. - Campaign types (`CampaignCallProcessingDetails`) are bridge types for SDK fields not yet in the installed SDK typings; they can drift from the runtime payload until the SDK is updated. Evidence: `src/components/task/task.types.ts`. ## Module Do's / Don'ts + - DO: keep components props-only — pass all data and callbacks in; never import the `@webex/cc-store` singleton or call the SDK here. - DO: derive display data in co-located `*.utils.ts(x)` (and shared logic in `src/utils`) so components stay thin and unit-testable. - DO: add new public components/types through `src/index.ts` and, for the custom-element build, register them in `src/wc.ts` with an explicit r2wc prop type map guarded by `customElements.get`. @@ -291,29 +317,33 @@ These are UI components; the non-happy-path states are part of the contract. - DON'T: widen a component's public surface by exporting internal subcomponents (e.g. `Task`, `CallControlCustom/*`) from the barrel. ## Export Stability + This package is published (`@webex/cc-components`, `main` → `dist/index.js`, `types` → `dist/types/index.d.ts`, and a `./wc` subpath export). The `.d.ts` of the `index.ts` barrel and the `task.types.ts`/`*.types.ts` exports are the type surface. Semver sensitivity: adding an optional prop to a `Pick`ed component type or adding a new exported component is a minor; removing/renaming a picked prop, narrowing a prop type, removing an exported component, or renaming/removing a `component-cc-*` custom element (or changing its r2wc prop type) is a major. Evidence: `package.json` (`exports`, `version`), `src/index.ts`, `src/wc.ts`. ## Test-Case Strategy (module) + Each component is tested in isolation with React Testing Library: render from a minimal props object, assert rendered UI (positive) and assert callbacks fire on interaction / error props render error UI (negative), with snapshot tests guarding stable markup. Utils have dedicated `*.utils.tsx` tests asserting pure transformations (e.g. button building, sorting, data extraction). Edge cases covered include empty task list/incoming task (hidden render), `hideDesktopLogin` across modes, campaign timeout/error, and transcript empty + sort. Gaps: no dedicated test file for `src/utils/index.ts` (`formatTime`/`getMediaTypeInfo`) and no explicit assertion that components are `withMetrics`-wrapped. -| Behavior / Requirement | Existing test evidence | Gap | -|---|---|---| -| `CC-COMPONENTS-R-001` | `tests/components/StationLogin/station-login.tsx`, `tests/components/UserState/user-state.tsx`, `tests/components/task/CallControl/call-control.tsx` | None | -| `CC-COMPONENTS-R-002` | Import sites across `tests/components/**` | No single test asserting the full export list | -| `CC-COMPONENTS-R-003` | `tests/components/StationLogin/station-login.tsx` (actions + failure + save error) | None | -| `CC-COMPONENTS-R-004` | `tests/components/StationLogin/station-login.tsx` (hideDesktopLogin cases) | None | -| `CC-COMPONENTS-R-005` | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | -| `CC-COMPONENTS-R-006` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | None | -| `CC-COMPONENTS-R-007` | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | -| `CC-COMPONENTS-R-008` | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | -| `CC-COMPONENTS-R-009` | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | -| `CC-COMPONENTS-R-010` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | -| `CC-COMPONENTS-R-011` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | -| `CC-COMPONENTS-R-012` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | -| `CC-COMPONENTS-R-013` | `tests/components/task/CallControl/call-control.utils.tsx`, component snapshots | No dedicated `formatTime`/`getMediaTypeInfo` unit test | -| `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | -| `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | +| Behavior / Requirement | Existing test evidence | Gap | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `CC-COMPONENTS-R-001` | `tests/components/StationLogin/station-login.tsx`, `tests/components/UserState/user-state.tsx`, `tests/components/task/CallControl/call-control.tsx` | None | +| `CC-COMPONENTS-R-002` | Import sites across `tests/components/**` | No single test asserting the full export list | +| `CC-COMPONENTS-R-003` | `tests/components/StationLogin/station-login.tsx` (actions + failure + save error) | None | +| `CC-COMPONENTS-R-004` | `tests/components/StationLogin/station-login.tsx` (hideDesktopLogin cases) | None | +| `CC-COMPONENTS-R-005` | `tests/components/UserState/user-state.tsx`, `tests/components/UserState/user-state.utils.tsx` | None | +| `CC-COMPONENTS-R-006` | `tests/components/task/CallControl/call-control.tsx`, `tests/components/task/CallControl/call-control.utils.tsx` | None | +| `CC-COMPONENTS-R-007` | `tests/components/task/CallControlCAD/call-control-cad.tsx` | None | +| `CC-COMPONENTS-R-008` | `tests/components/task/IncomingTask/incoming-task.tsx`, `tests/components/task/IncomingTask/incoming-task.utils.tsx` | None | +| `CC-COMPONENTS-R-009` | `tests/components/task/TaskList/task-list.tsx`, `tests/components/task/TaskList/task-list.utils.tsx` | None | +| `CC-COMPONENTS-R-010` | `tests/components/task/OutdialCall/out-dial-call.tsx` | None | +| `CC-COMPONENTS-R-011` | `tests/components/task/RealtimeTranscript/realtime-transcript.tsx` | None | +| `CC-COMPONENTS-R-012` | `tests/components/task/CampaignTask/campaign-task.test.tsx`, `tests/components/task/CampaignErrorDialog/campaign-error-dialog.tsx`, `tests/components/task/CampaignCountdown/campaign-countdown.tsx` | None | +| `CC-COMPONENTS-R-013` | `tests/components/task/CallControl/call-control.utils.tsx`, component snapshots | No dedicated `formatTime`/`getMediaTypeInfo` unit test | +| `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | +| `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | +| `CC-COMPONENTS-R-016` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` | None | ## Traceability + - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) - Coverage state & contracts baseline: `.sdd/manifest.json` diff --git a/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.constants.ts b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.constants.ts new file mode 100644 index 000000000..bfa67cdc6 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.constants.ts @@ -0,0 +1,13 @@ +export const E911ModalLabels = { + TITLE: 'Emergency Service Notification', + WARNING_TITLE: 'Important', + WARNING_MESSAGE: + 'If your address has changed, update it in the Emergency Callback Number (ECBN) section of your profile before making any calls.', + DIALING_TITLE: 'Dialing Emergency Services', + DIALING_MESSAGE: + 'When you dial an emergency number (e.g., 911), your call will be routed to the appropriate emergency services based on your registered address. Ensure your address is always up to date to receive timely assistance.', + CHECKBOX_LABEL: 'I have read the notification', + CANCEL: 'Cancel', + SAVE_AND_CONTINUE: 'Save & Continue', + SAVE_ERROR_MESSAGE: 'Failed to save. Please try again.', +}; diff --git a/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.style.scss b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.style.scss new file mode 100644 index 000000000..05bd19d4f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.style.scss @@ -0,0 +1,53 @@ +.e911-modal { + // Cancel is the only way to dismiss this modal - hide the Dialog's built-in close ("X") button. + &::part(dialog-close-btn) { + display: none; + } + + .e911-warning-box { + background-color: var(--mds-color-theme-background-alert-warning-normal); + border: 0.0625rem solid var(--mds-color-theme-outline-warning-normal); + border-radius: 0.25rem; + padding: 0.75rem; + margin-bottom: 1rem; + + .e911-warning-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: var(--mds-color-theme-text-warning-normal); + } + + .e911-warning-message { + color: var(--mds-color-theme-text-warning-normal); + } + } + + .e911-dialing-section { + margin-bottom: 1.25rem; + + .e911-dialing-title { + margin-bottom: 0.5rem; + } + + .e911-dialing-message { + color: var(--mds-color-theme-text-secondary-normal); + } + } + + .e911-checkbox-container { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.25rem; + + .e911-checkbox-label { + cursor: pointer; + } + } + + .e911-save-error { + color: var(--mds-color-theme-text-error-normal); + } +} diff --git a/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.tsx b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.tsx new file mode 100644 index 000000000..15b494f7f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.tsx @@ -0,0 +1,107 @@ +import React, {useEffect, useState} from 'react'; +import {Button, Text, Icon, Checkbox, Dialog} from '@momentum-design/components/dist/react'; +import {E911ModalProps} from './e911-modal.types'; +import {E911ModalLabels} from './e911-modal.constants'; +import './e911-modal.style.scss'; + +const E911Modal: React.FC = ({isOpen, onSaveAndContinue, onCancel}) => { + const [isChecked, setIsChecked] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + + useEffect(() => { + if (!isOpen) { + setIsChecked(false); + setIsSaving(false); + setSaveError(''); + } + }, [isOpen]); + + const handleCheckboxChange = () => { + setIsChecked(!isChecked); + }; + + const handleCancel = () => { + setIsChecked(false); + onCancel(); + }; + + const handleSaveAndContinue = async () => { + if (!isChecked || isSaving) { + return; + } + + setIsSaving(true); + setSaveError(''); + + try { + await onSaveAndContinue(); + } catch { + setSaveError(E911ModalLabels.SAVE_ERROR_MESSAGE); + } finally { + setIsSaving(false); + } + }; + + return ( + +
+
+
+ + + {E911ModalLabels.WARNING_TITLE} + +
+ + {E911ModalLabels.WARNING_MESSAGE} + +
+ +
+ + {E911ModalLabels.DIALING_TITLE} + + + {E911ModalLabels.DIALING_MESSAGE} + +
+ +
+ +
+ + {saveError && ( + + {saveError} + + )} +
+ + + +
+ ); +}; + +export default E911Modal; diff --git a/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.types.ts b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.types.ts new file mode 100644 index 000000000..88bfb868f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/StationLogin/E911Modal/e911-modal.types.ts @@ -0,0 +1,5 @@ +export interface E911ModalProps { + isOpen: boolean; + onSaveAndContinue: () => Promise; + onCancel: () => void; +} diff --git a/packages/contact-center/cc-components/src/index.ts b/packages/contact-center/cc-components/src/index.ts index fc43b8061..718cbf142 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -9,6 +9,7 @@ import CampaignErrorDialogComponent from './components/task/CampaignErrorDialog/ import CampaignCountdownComponent from './components/task/CampaignCountdown/campaign-countdown'; import CampaignTaskComponent from './components/task/CampaignTask/campaign-task'; import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript'; +import E911Modal from './components/StationLogin/E911Modal/e911-modal'; export { UserStateComponent, @@ -22,8 +23,10 @@ export { CampaignCountdownComponent, CampaignTaskComponent, RealTimeTranscriptComponent, + E911Modal, }; export * from './components/StationLogin/constants'; +export * from './components/StationLogin/E911Modal/e911-modal.types'; export * from './components/StationLogin/station-login.types'; export * from './components/UserState/user-state.types'; export * from './components/task/task.types'; diff --git a/packages/contact-center/cc-components/tests/components/StationLogin/E911Modal/e911-modal.test.tsx b/packages/contact-center/cc-components/tests/components/StationLogin/E911Modal/e911-modal.test.tsx new file mode 100644 index 000000000..bb9f145db --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/StationLogin/E911Modal/e911-modal.test.tsx @@ -0,0 +1,203 @@ +import React from 'react'; +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import E911Modal from '../../../../src/components/StationLogin/E911Modal/e911-modal'; +import {E911ModalLabels} from '../../../../src/components/StationLogin/E911Modal/e911-modal.constants'; + +jest.mock('@webex/cc-ui-logging', () => ({ + withMetrics: (component: React.ComponentType>) => component, +})); + +describe('E911Modal', () => { + const defaultProps = { + isOpen: true, + onSaveAndContinue: jest.fn().mockResolvedValue(undefined), + onCancel: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('e911-modal')).toBeInTheDocument(); + }); + + it('should display the modal title', () => { + render(); + // The Dialog custom element renders headerText in its shadow DOM, so assert via the + // headerText property @lit/react sets on the element rather than a light-DOM text query. + const dialog = screen.getByTestId('e911-modal') as HTMLElement & {headerText?: string}; + expect(dialog.headerText).toBe(E911ModalLabels.TITLE); + }); + + it('should display the warning message', () => { + render(); + expect(screen.getByText(E911ModalLabels.WARNING_MESSAGE)).toBeInTheDocument(); + }); + + it('should display the dialing section', () => { + render(); + expect(screen.getByText(E911ModalLabels.DIALING_TITLE)).toBeInTheDocument(); + expect(screen.getByText(E911ModalLabels.DIALING_MESSAGE)).toBeInTheDocument(); + }); + + it('should display the checkbox with label', () => { + render(); + expect(screen.getByTestId('e911-checkbox')).toBeInTheDocument(); + }); + + it('should not call onSaveAndContinue when button clicked without checkbox checked', () => { + render(); + const saveButton = screen.getByTestId('e911-save-button'); + + fireEvent.click(saveButton); + + // Button click should not trigger callback when checkbox is unchecked + expect(defaultProps.onSaveAndContinue).not.toHaveBeenCalled(); + }); + + it('should enable Save & Continue button when checkbox is checked', async () => { + render(); + const checkbox = screen.getByTestId('e911-checkbox'); + + fireEvent(checkbox, new CustomEvent('change', {detail: {checked: true}})); + + await waitFor(() => { + const saveButton = screen.getByTestId('e911-save-button'); + expect(saveButton).not.toBeDisabled(); + }); + }); + + it('should call onCancel when Cancel button is clicked', () => { + render(); + const cancelButton = screen.getByTestId('e911-cancel-button'); + + fireEvent.click(cancelButton); + + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it('should call onSaveAndContinue when Save & Continue is clicked with checkbox checked', async () => { + render(); + const checkbox = screen.getByTestId('e911-checkbox'); + + fireEvent(checkbox, new CustomEvent('change', {detail: {checked: true}})); + + await waitFor(() => { + const saveButton = screen.getByTestId('e911-save-button'); + expect(saveButton).not.toBeDisabled(); + }); + + const saveButton = screen.getByTestId('e911-save-button'); + fireEvent.click(saveButton); + + expect(defaultProps.onSaveAndContinue).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + }); + }); + + it('should disable Save & Continue and Cancel while a save is in flight, and re-enable them once it resolves', async () => { + let resolveSave: () => void; + const onSaveAndContinue = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveSave = resolve; + }) + ); + render(); + const checkbox = screen.getByTestId('e911-checkbox'); + fireEvent(checkbox, new CustomEvent('change', {detail: {checked: true}})); + + const saveButton = screen.getByTestId('e911-save-button'); + const cancelButton = screen.getByTestId('e911-cancel-button'); + await waitFor(() => expect(saveButton).not.toBeDisabled()); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(saveButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + + // A second click while saving must not fire another call - guards against the double-click + // race that could otherwise fire concurrent createUserPreference/updateUserPreference calls. + fireEvent.click(saveButton); + expect(onSaveAndContinue).toHaveBeenCalledTimes(1); + + resolveSave(); + + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + expect(cancelButton).not.toBeDisabled(); + }); + }); + + it('should show a user-facing error and re-enable the buttons when onSaveAndContinue rejects', async () => { + const onSaveAndContinue = jest.fn().mockRejectedValue(new Error('boom')); + render(); + const checkbox = screen.getByTestId('e911-checkbox'); + fireEvent(checkbox, new CustomEvent('change', {detail: {checked: true}})); + + const saveButton = screen.getByTestId('e911-save-button'); + await waitFor(() => expect(saveButton).not.toBeDisabled()); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId('e911-save-error')).toHaveTextContent(E911ModalLabels.SAVE_ERROR_MESSAGE); + }); + expect(saveButton).not.toBeDisabled(); + expect(screen.getByTestId('e911-cancel-button')).not.toBeDisabled(); + }); + + it('should clear a previous save error when the modal is reopened', async () => { + const onSaveAndContinue = jest.fn().mockRejectedValue(new Error('boom')); + const {rerender} = render(); + const checkbox = screen.getByTestId('e911-checkbox'); + fireEvent(checkbox, new CustomEvent('change', {detail: {checked: true}})); + + const saveButton = screen.getByTestId('e911-save-button'); + await waitFor(() => expect(saveButton).not.toBeDisabled()); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByTestId('e911-save-error')).toBeInTheDocument(); + }); + + rerender(); + rerender(); + + expect(screen.queryByTestId('e911-save-error')).not.toBeInTheDocument(); + }); + + it('should set visible on the Dialog when isOpen changes to true', () => { + const {rerender} = render(); + + rerender(); + + const dialog = screen.getByTestId('e911-modal') as HTMLElement & {visible?: boolean}; + expect(dialog.visible).toBe(true); + }); + + it('should reset checkbox state when modal closes', () => { + const {rerender} = render(); + + rerender(); + + // Modal should attempt to close - the actual close behavior depends on dialog.open state + expect(screen.getByTestId('e911-modal')).toBeInTheDocument(); + }); + + it('should not call onCancel when the Dialog fires close (built-in close button or Escape key) - Cancel is the only way to dismiss', () => { + render(); + const dialog = screen.getByTestId('e911-modal'); + + dialog.dispatchEvent(new CustomEvent('close')); + + expect(defaultProps.onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/contact-center/station-login/ai-docs/station-login-spec.md b/packages/contact-center/station-login/ai-docs/station-login-spec.md index 68ed0d8a9..7cce3862f 100644 --- a/packages/contact-center/station-login/ai-docs/station-login-spec.md +++ b/packages/contact-center/station-login/ai-docs/station-login-spec.md @@ -4,17 +4,19 @@ > Context-efficiency: link to canonical docs — don't duplicate them. Load specs on demand per `SPEC_INDEX.md`. ## Metadata -| Field | Value | -|---|---| -| Module id | `station-login` | -| Source path(s) | `packages/contact-center/station-login/src/` | -| Doc kind | Module spec | -| Coverage score | Pending coverage assessment | -| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | -| generated_by / approved_by / updated_at | migration agent / pending / 2026-06-29 | -| Validation status | not-run | + +| Field | Value | +| --------------------------------------- | --------------------------------------------------- | +| Module id | `station-login` | +| Source path(s) | `packages/contact-center/station-login/src/` | +| Doc kind | Module spec | +| Coverage score | Pending coverage assessment | +| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | +| generated_by / approved_by / updated_at | migration agent / pending / 2026-06-29 | +| Validation status | not-run | ## Evidence Rules + Every generated requirement below must cite concrete source evidence using `file path`. Separate source evidence, test evidence, examples, assumptions, and gaps so validators and future agents can distinguish truth from context. Test evidence is preferred for WHY. Commit evidence is allowed only when the @@ -23,12 +25,14 @@ conflicting, ask a focused discovery question before finalizing the requirement; as approved unknowns only when the human explicitly defers or does not know. ## Source Material Register -| Source doc | Scope | Decision | Detail location or disposition | -|---|---|---|---| -| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/station-login/ai-docs/AGENTS.md` | overview / API | migrated | Orientation → Overview/Purpose; props → Public Surface; usage examples → Use Cases; error callback → Error Handling | -| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/station-login/ai-docs/ARCHITECTURE.md` | architecture | reconciled | Layer table → Class/Component Relationships; data flow + sequences → Data Flow / Sequence Diagram(s); troubleshooting → Pitfalls. Old "renders blank screen" / silent-fail notes mapped to Error Handling; over-generalized `store.login()` arrow in the archived diagram corrected — the hook calls `cc.stationLogin()` directly, not `store.login()` | + +| Source doc | Scope | Decision | Detail location or disposition | +| --------------------------------------------------------------------------------------------------- | -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/station-login/ai-docs/AGENTS.md` | overview / API | migrated | Orientation → Overview/Purpose; props → Public Surface; usage examples → Use Cases; error callback → Error Handling | +| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/station-login/ai-docs/ARCHITECTURE.md` | architecture | reconciled | Layer table → Class/Component Relationships; data flow + sequences → Data Flow / Sequence Diagram(s); troubleshooting → Pitfalls. Old "renders blank screen" / silent-fail notes mapped to Error Handling; over-generalized `store.login()` arrow in the archived diagram corrected — the hook calls `cc.stationLogin()` directly, not `store.login()` | ## Overview + `station-login` is the agent station-login widget for Webex Contact Center. It lets an agent pick a team and a device/login type (Desktop/`BROWSER` WebRTC, `EXTENSION`, or `AGENT_DN` dial number), log in to and out of their station, sign out of Contact Center, and — in profile mode — update those login options @@ -49,6 +53,7 @@ read `src/helper.ts` (all business logic and SDK integration). Prop and state sh `@webex/cc-components`. ## Purpose / Responsibility + Owns the agent station-login UI flow: team + device-type selection, login, logout, CC sign-out, profile (login-option) updates, and the multiple-login alert/Continue flow. Does NOT own the SDK instance, the observable agent state (`teams`, `deviceType`, `dialNumber`, `teamId`, `isAgentLoggedIn`, @@ -56,12 +61,14 @@ observable agent state (`teams`, `deviceType`, `dialNumber`, `teamId`, `isAgentL `@webex/cc-components` respectively. ## Stack + TypeScript 5.6.3, React `>=18.3.1` (functional component + hooks), MobX via `mobx-react-lite` `^4.1.0` (`observer`), `react-error-boundary` `^6.0.0`. Tests: Jest 29 + React Testing Library 16 (jsdom). Build: `tsc` (types) and Webpack 5 (`build:src`). Published as ESM/CJS package `@webex/cc-station-login` (`main: dist/index.js`, `types: dist/types/index.d.ts`). No datastore or messaging of its own. ## Folder / Package Structure + ``` station-login/ ├── src/ @@ -77,38 +84,42 @@ station-login/ ``` ## Key Files (source of truth) -| File | Holds | -|---|---| -| `src/index.ts` | Package export barrel; the public surface is whatever this re-exports (`StationLogin`) | -| `src/station-login/index.tsx` | Public widget, prop-to-hook wiring, store reads, ErrorBoundary → `store.onErrorCallback('StationLogin', error)` | -| `src/station-login/station-login.types.ts` | Authoritative public prop type `StationLoginProps` and hook input `UseStationLoginProps` (both `Pick` from `IStationLoginProps`) | -| `src/helper.ts` | `useStationLogin` — login/logout/saveLoginOptions/handleContinue/handleCCSignOut logic, `isLoginOptionsChanged` comparison, SDK event subscriptions | -| `@webex/cc-components` `components/StationLogin/station-login.types.ts` | Canonical `IStationLoginProps` / `LoginOptionsState`; do not redefine prop shapes here | + +| File | Holds | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/index.ts` | Package export barrel; the public surface is whatever this re-exports (`StationLogin`) | +| `src/station-login/index.tsx` | Public widget, prop-to-hook wiring, store reads, ErrorBoundary → `store.onErrorCallback('StationLogin', error)` | +| `src/station-login/station-login.types.ts` | Authoritative public prop type `StationLoginProps` and hook input `UseStationLoginProps` (both `Pick` from `IStationLoginProps`) | +| `src/helper.ts` | `useStationLogin` — login/logout/saveLoginOptions/handleContinue/handleCCSignOut logic, `isLoginOptionsChanged` comparison, SDK event subscriptions | +| `@webex/cc-components` `components/StationLogin/station-login.types.ts` | Canonical `IStationLoginProps` / `LoginOptionsState`; do not redefine prop shapes here | ## Public Surface -| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | -|---|---|---|---|---|---|---| + +| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | +| ------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | `cc-widgets.StationLogin` | SDK / Web Component | React component `StationLogin`; mounted in `@webex/cc-widgets` as custom element `widget-cc-station-login` | Agent station login/logout, CC sign-out, profile-option update, multi-login alert | Stable semver; the custom-element tag name and the `profileMode`/`onLogin`/`onLogout`/`onCCSignOut` prop surface are breaking changes | `src/station-login/station-login.types.ts` (`StationLoginProps`), `IStationLoginProps` in `@webex/cc-components` | `../../../../ai-docs/CONTRACTS.md` | Public props (`StationLoginProps`, from `src/station-login/station-login.types.ts`): -| Prop | Type | Required | Notes | -|---|---|---|---| -| `profileMode` | `boolean` | Yes | `true` = profile/save mode; `false` = login/logout mode | -| `onLogin` | `() => void` | No | Invoked on login success (and on mount if already logged in — `helper.ts`) | -| `onLogout` | `() => void` | No | Invoked on `AGENT_LOGOUT_SUCCESS` | -| `onCCSignOut` | `() => void` | No | Invoked after CC sign-out; presence enables the sign-out handler | -| `onSaveStart` | `() => void` | No | Invoked when a profile save begins | -| `onSaveEnd` | `(isComplete: boolean) => void` | No | Invoked when a profile save resolves (`true`) or fails / no-change (`false`) | -| `teamId` | `string` | No | Default/seed team id | -| `doStationLogout` | `boolean` | No | Defaults to `true` when omitted/null; if `false`, CC sign-out skips station logout | -| `hideDesktopLogin` | `boolean` | No | Hides the Desktop (`BROWSER`) option in dropdown | -| `allowInternationalDn` | `boolean` | No | Use international dial-number regex instead of agentConfig/US fallback | +| Prop | Type | Required | Notes | +| ---------------------- | ------------------------------- | -------- | ---------------------------------------------------------------------------------- | +| `profileMode` | `boolean` | Yes | `true` = profile/save mode; `false` = login/logout mode | +| `onLogin` | `() => void` | No | Invoked on login success (and on mount if already logged in — `helper.ts`) | +| `onLogout` | `() => void` | No | Invoked on `AGENT_LOGOUT_SUCCESS` | +| `onCCSignOut` | `() => void` | No | Invoked after CC sign-out; presence enables the sign-out handler | +| `onSaveStart` | `() => void` | No | Invoked when a profile save begins | +| `onSaveEnd` | `(isComplete: boolean) => void` | No | Invoked when a profile save resolves (`true`) or fails / no-change (`false`) | +| `teamId` | `string` | No | Default/seed team id | +| `doStationLogout` | `boolean` | No | Defaults to `true` when omitted/null; if `false`, CC sign-out skips station logout | +| `hideDesktopLogin` | `boolean` | No | Hides the Desktop (`BROWSER`) option in dropdown | +| `allowInternationalDn` | `boolean` | No | Use international dial-number regex instead of agentConfig/US fallback | Compatibility notes: + - Adding a new optional prop is a minor change; removing a prop or changing the custom-element tag name is a major (breaking) change. ## Requires (dependencies) + - `@webex/cc-store` (`workspace:*`) — MobX singleton; provides `cc` (SDK), `teams`, `loginOptions`, `deviceType`, `dialNumber`, `teamId`, `isAgentLoggedIn`, `showMultipleLoginAlert`, `logger`, `CC_EVENTS`, `setCCCallback`/`removeCCCallback`, `setShowMultipleLoginAlert`, `registerCC`, `onErrorCallback`. - `@webex/cc-components` (`workspace:*`) — `StationLoginComponent` (presentational), `IStationLoginProps`/`StationLoginComponentProps`/`LoginOptionsState` types. - `@webex/contact-center` (the SDK, via the store's `cc`) — `stationLogin()`, `stationLogout()`, `updateAgentProfile()`, `deregister()`; types `StationLoginSuccessResponse`, `LogoutSuccess`, `AgentProfileUpdate`, `LoginOption`. @@ -116,20 +127,27 @@ Compatibility notes: - Peer: `react`/`react-dom` `>=18.3.1`, `@momentum-ui/react-collaboration` `>=26.201.9`. ## Requirements -| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | -|---|---|---|---|---|---|---| -| `STATION-LOGIN-R-001` | `login()` calls `cc.stationLogin({teamId, loginOption, dialNumber})`; on success sets `loginSuccess` and clears `loginFailure`, on failure sets `loginFailure` and clears `loginSuccess` | Login result must drive UI success/error display | `src/helper.ts` (`login`) | `tests/helper.ts` "should set loginSuccess on successful login and set loginFailure to undefined", "should set loginFailure on failed login" | none | PRESENT | -| `STATION-LOGIN-R-002` | `logout()` calls `cc.stationLogout({logoutReason})` and sets `logoutSuccess` on success; on failure it logs and does not throw | Logout must update state and never crash the widget | `src/helper.ts` (`logout`) | `tests/helper.ts` "should set logoutSuccess on successful logout", "should log error on logout failure" | none | PRESENT | -| `STATION-LOGIN-R-003` | The `onLogin` callback fires when the agent becomes logged in (on `AGENT_STATION_LOGIN_SUCCESS` and on mount when already logged in); `onLogout` fires on `AGENT_LOGOUT_SUCCESS`. Both are guarded so absent callbacks are a no-op | Host app needs login/logout lifecycle hooks without crashing when omitted | `src/helper.ts` (`handleLogin`/`handleLogout`, `setCCCallback` effect, mount effect) | `tests/helper.ts` "should set loginSuccess on successful login without onLogin callback", "should not call logout callback if not present" | none | PRESENT | -| `STATION-LOGIN-R-004` | `saveLoginOptions()` short-circuits when `isLoginOptionsChanged` is false: sets `saveError` to "No changes detected…" and calls `onSaveEnd(false)` without calling the SDK | Avoids no-op profile writes and gives the host a deterministic failure signal | `src/helper.ts` (`saveLoginOptions`, `isLoginOptionsChanged`) | `tests/helper.ts` "should not save if isLoginOptionsChanged is false" | none | PRESENT | -| `STATION-LOGIN-R-005` | On a real change, `saveLoginOptions()` calls `onSaveStart()`, calls `cc.updateAgentProfile()` with `{loginOption, teamId}` (plus `dialNumber` only when deviceType ≠ `BROWSER`), and on success copies `currentLoginOptions`→`originalLoginOptions` and calls `onSaveEnd(true)` | Profile update must persist only changed options and resync the baseline so the Save button disables | `src/helper.ts` (`saveLoginOptions`) | `tests/helper.ts` "should call updateAgentProfile and update originalLoginOptions on save when changed", "should call updateAgentProfile with no dialNumber when deviceType is BROWSER" | none | PRESENT | -| `STATION-LOGIN-R-006` | When `cc.updateAgentProfile()` rejects, `saveError` is set to the error message and `onSaveEnd(false)` is called | Caller must be able to surface profile-save failures | `src/helper.ts` (`saveLoginOptions` `.catch`) | `tests/helper.ts` "should handle updateAgentProfile errors", "should handle errors in saveLoginOptions main logic" | none | PRESENT | -| `STATION-LOGIN-R-007` | `handleContinue()` clears the multiple-login alert (`store.setShowMultipleLoginAlert(false)`) then calls `store.registerCC()` to force re-registration | Agents already logged in elsewhere must be able to continue/take over the session | `src/helper.ts` (`handleContinue`) | `tests/helper.ts` "should call handleContinue and set device type", "should call handleContinue with agent not logged in", "should call handleContinue and handle error" | none | PRESENT | -| `STATION-LOGIN-R-008` | `handleCCSignOut()` calls `cc.stationLogout()` then `cc.deregister()` only when `doStationLogout` AND `isAgentLoggedIn`; otherwise it skips straight to invoking `onCCSignOut()`. `doStationLogout` defaults to `true` when omitted | Lets profile-mode hosts sign out without dropping the station, while default behavior fully logs out | `src/helper.ts` (`handleCCSignOut`, `doStationLogout` default) | `tests/helper.ts` "should call stationLogout when doStationLogout is not passed", "should not call stationLogout if doStationLogout is false", "should handle error if stationLogout fails in onCCSignOut", "should handle error if deregister fails in onCCSignOut" | none | PRESENT | -| `STATION-LOGIN-R-009` | The widget is wrapped in an `ErrorBoundary` that renders an empty fragment and routes the error to `store.onErrorCallback('StationLogin', error)` | A render/hook error must not blank-crash the host and must be reported with the component name | `src/station-login/index.tsx` (`ErrorBoundary`) | `tests/station-login/index.tsx` "should render empty fragment when ErrorBoundary catches an error" | none | PRESENT | -| `STATION-LOGIN-R-010` | `StationLoginInternal` is an `observer()` that reads store state and forwards it plus hook results into `StationLoginComponent` (including `dialNumberRegex = cc?.agentConfig?.regexUS`, `hideDesktopLogin`, `allowInternationalDn`) | Re-render must be driven by observable store changes and props must reach the presentational component intact | `src/station-login/index.tsx` (`StationLoginInternal`) | `tests/station-login/index.tsx` "renders StationLoginPresentational with correct props" | DN-regex selection (`allowInternationalDn`) is enforced inside `@webex/cc-components`, not this package | PRESENT | + +| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| `STATION-LOGIN-R-001` | `login()` calls `cc.stationLogin({teamId, loginOption, dialNumber})`; on success sets `loginSuccess` and clears `loginFailure`, on failure sets `loginFailure` and clears `loginSuccess` | Login result must drive UI success/error display | `src/helper.ts` (`login`) | `tests/helper.ts` "should set loginSuccess on successful login and set loginFailure to undefined", "should set loginFailure on failed login" | none | PRESENT | +| `STATION-LOGIN-R-002` | `logout()` calls `cc.stationLogout({logoutReason})` and sets `logoutSuccess` on success; on failure it logs and does not throw | Logout must update state and never crash the widget | `src/helper.ts` (`logout`) | `tests/helper.ts` "should set logoutSuccess on successful logout", "should log error on logout failure" | none | PRESENT | +| `STATION-LOGIN-R-003` | The `onLogin` callback fires when the agent becomes logged in (on `AGENT_STATION_LOGIN_SUCCESS` and on mount when already logged in); `onLogout` fires on `AGENT_LOGOUT_SUCCESS`. Both are guarded so absent callbacks are a no-op | Host app needs login/logout lifecycle hooks without crashing when omitted | `src/helper.ts` (`handleLogin`/`handleLogout`, `setCCCallback` effect, mount effect) | `tests/helper.ts` "should set loginSuccess on successful login without onLogin callback", "should not call logout callback if not present" | none | PRESENT | +| `STATION-LOGIN-R-004` | `saveLoginOptions()` short-circuits when `isLoginOptionsChanged` is false: sets `saveError` to "No changes detected…" and calls `onSaveEnd(false)` without calling the SDK | Avoids no-op profile writes and gives the host a deterministic failure signal | `src/helper.ts` (`saveLoginOptions`, `isLoginOptionsChanged`) | `tests/helper.ts` "should not save if isLoginOptionsChanged is false" | none | PRESENT | +| `STATION-LOGIN-R-005` | On a real change, `saveLoginOptions()` calls `onSaveStart()`, calls `cc.updateAgentProfile()` with `{loginOption, teamId}` (plus `dialNumber` only when deviceType ≠ `BROWSER`), and on success copies `currentLoginOptions`→`originalLoginOptions` and calls `onSaveEnd(true)` | Profile update must persist only changed options and resync the baseline so the Save button disables | `src/helper.ts` (`saveLoginOptions`) | `tests/helper.ts` "should call updateAgentProfile and update originalLoginOptions on save when changed", "should call updateAgentProfile with no dialNumber when deviceType is BROWSER" | none | PRESENT | +| `STATION-LOGIN-R-006` | When `cc.updateAgentProfile()` rejects, `saveError` is set to the error message and `onSaveEnd(false)` is called | Caller must be able to surface profile-save failures | `src/helper.ts` (`saveLoginOptions` `.catch`) | `tests/helper.ts` "should handle updateAgentProfile errors", "should handle errors in saveLoginOptions main logic" | none | PRESENT | +| `STATION-LOGIN-R-007` | `handleContinue()` clears the multiple-login alert (`store.setShowMultipleLoginAlert(false)`) then calls `store.registerCC()` to force re-registration | Agents already logged in elsewhere must be able to continue/take over the session | `src/helper.ts` (`handleContinue`) | `tests/helper.ts` "should call handleContinue and set device type", "should call handleContinue with agent not logged in", "should call handleContinue and handle error" | none | PRESENT | +| `STATION-LOGIN-R-008` | `handleCCSignOut()` calls `cc.stationLogout()` then `cc.deregister()` only when `doStationLogout` AND `isAgentLoggedIn`; otherwise it skips straight to invoking `onCCSignOut()`. `doStationLogout` defaults to `true` when omitted | Lets profile-mode hosts sign out without dropping the station, while default behavior fully logs out | `src/helper.ts` (`handleCCSignOut`, `doStationLogout` default) | `tests/helper.ts` "should call stationLogout when doStationLogout is not passed", "should not call stationLogout if doStationLogout is false", "should handle error if stationLogout fails in onCCSignOut", "should handle error if deregister fails in onCCSignOut" | none | PRESENT | +| `STATION-LOGIN-R-009` | The widget is wrapped in an `ErrorBoundary` that renders an empty fragment and routes the error to `store.onErrorCallback('StationLogin', error)` | A render/hook error must not blank-crash the host and must be reported with the component name | `src/station-login/index.tsx` (`ErrorBoundary`) | `tests/station-login/index.tsx` "should render empty fragment when ErrorBoundary catches an error" | none | PRESENT | +| `STATION-LOGIN-R-010` | `StationLoginInternal` is an `observer()` that reads store state and forwards it plus hook results into `StationLoginComponent` (including `dialNumberRegex = cc?.agentConfig?.regexUS`, `hideDesktopLogin`, `allowInternationalDn`) | Re-render must be driven by observable store changes and props must reach the presentational component intact | `src/station-login/index.tsx` (`StationLoginInternal`) | `tests/station-login/index.tsx` "renders StationLoginPresentational with correct props" | DN-regex selection (`allowInternationalDn`) is enforced inside `@webex/cc-components`, not this package | PRESENT | +| `STATION-LOGIN-R-011` | `checkE911ModalDisplay(deviceType)` is invoked as a gate (no-op unless `deviceType === DEVICE_TYPE_BROWSER`) from every point where the agent can end up on a `BROWSER` station: (1) after a successful `login()` using the just-submitted device type, (2) on mount when `store.isAgentLoggedIn` is already `true` with `store.deviceType === BROWSER` (covers page refresh), (3) inside `handleLogin` — the `AGENT_STATION_LOGIN_SUCCESS`/relogin callback — using `store.deviceType`, (4) after a successful multi-login takeover in `handleContinue()`, and (5) after a successful `saveLoginOptions()` profile save using the newly-saved `currentLoginOptions.deviceType` | The E911 emergency-dialing reminder must reach every agent who ends up on a `BROWSER`/WebRTC station regardless of how they got there (first login, refresh, relogin/takeover, or a profile-save device-type switch), not only the initial-login path | `src/helper.ts` (`checkE911ModalDisplay`, `login`, mount `useEffect`, `handleLogin`, `handleContinue`, `saveLoginOptions`) | `tests/helper.ts` `describe('checkE911ModalDisplay (via login)')`: "should show the E911 modal on successful BROWSER login when not already acknowledged", "should show the E911 modal on mount when already logged in with BROWSER (e.g. page refresh)", "should not show the E911 modal on mount when already logged in with a non-BROWSER device", "should show the E911 modal on AGENT_STATION_LOGIN_SUCCESS / relogin when store.deviceType is BROWSER", "should show the E911 modal after a successful multi-login takeover (handleContinue) with BROWSER", "should not show the E911 modal after handleContinue when store.deviceType is not BROWSER", "should show the E911 modal after saving login options with BROWSER as the new device type" | none | PRESENT | +| `STATION-LOGIN-R-012` | `checkE911ModalDisplay()` returns early when `deviceTypeToCheck !== DEVICE_TYPE_BROWSER`; otherwise it `await`s `store.fetchUserPreferences()` and calls `store.setShowE911Modal(true)` only when `store.isEmergencyModalAlreadyDisplayed` is falsy. The whole method is wrapped in try/catch that logs and swallows errors, so a preference-service failure never breaks login/relogin/profile-save | The modal must not reappear for agents who already acknowledged the reminder, and reading the acknowledgment state from the preference service must never crash the login/profile-save flow it's attached to | `src/helper.ts` (`checkE911ModalDisplay`) | `tests/helper.ts` "should skip the E911 modal for non-BROWSER device types", "should skip the E911 modal when the preference is already acknowledged", error-handling case asserting `logger.error` with `method: 'checkE911ModalDisplay'` | The `desktopPreference` JSON parse/merge and the persisted acknowledgment write — including which identifier (`userId` vs. CC `agentId`) is used — are owned by `@webex/cc-store` (`store/src/storeEventsWrapper.ts` `fetchUserPreferences`/`updateEmergencyModalAcknowledgment`); see that package's spec for the persistence contract, not duplicated here | PRESENT | +| `STATION-LOGIN-R-013` | `StationLoginInternal` imports and renders `E911Modal` (from `@webex/cc-components`), wired to `store.showE911Modal`/`store.setShowE911Modal` (R-012) via `onCancel`, and to `store.updateEmergencyModalAcknowledgment()` via `onSaveAndContinue` (rethrowing on failure so the modal can surface a save error and stay open for retry). The modal's own Cancel-only dismissal (built-in close ("X") button hidden via `::part(dialog-close-btn)`, and the Dialog's native `close` event — fired by the close button or Escape — intentionally left unhandled so it cannot invoke `onCancel`), and its save-error UI, are implemented entirely inside the `E911Modal` component in `@webex/cc-components`, not this package | Documents the module boundary: this package owns _when_ the modal is shown/acknowledged (wiring to the store), `@webex/cc-components` owns the modal's own dismiss/save-error UI | `src/station-login/index.tsx` (`StationLoginInternal`, `handleE911SaveAndContinue`, `handleE911Cancel`), `../../cc-components/src/components/StationLogin/E911Modal/e911-modal.tsx`, `../../cc-components/src/components/StationLogin/E911Modal/e911-modal.style.scss` | `tests/station-login/index.tsx` E911Modal describe block; `../../cc-components/tests/components/StationLogin/E911Modal/e911-modal.test.tsx` "should call onCancel when Cancel button is clicked", "should not call onCancel when the Dialog fires close (built-in close button or Escape key) - Cancel is the only way to dismiss" | Full requirement/test mapping for the modal's own dismiss/save-error behavior belongs in `cc-components-spec.md`, not this file | PRESENT | +| `STATION-LOGIN-R-014` | Because a host can mount more than one `StationLogin` against the same store singleton (e.g. a login widget plus a `profileMode` settings widget), only one instance renders `E911Modal` at a time: the first-mounted instance claims a module-level owner token on mount, releases it on unmount, and re-attempts the claim whenever `store.showE911Modal` changes so a surviving instance can take over if the owner unmounts | Prevents duplicate blocking E911 dialogs when multiple widget instances share the store, while still guaranteeing some mounted instance renders the modal after ownership changes hands | `src/station-login/index.tsx` (`e911ModalOwner`, `ownsE911Modal`, the `useEffect` keyed on `showE911Modal`) | `tests/station-login/index.tsx` "renders E911Modal in only one of multiple mounted StationLogin instances", "lets a surviving instance reclaim the E911Modal after the owning instance unmounts" | none | PRESENT | +| `STATION-LOGIN-R-015` | `checkE911ModalDisplay()` is single-flight at module scope: a module-level `e911CheckInFlight` promise (not a per-hook `useRef`) is shared by every `useStationLogin` instance, so concurrent callers — whether within one instance (e.g. `login()` racing the `AGENT_STATION_LOGIN_SUCCESS` callback) or across two instances mounted against the same store (e.g. the login widget plus a `profileMode` settings widget) — join the same in-flight `fetchUserPreferences()` call instead of each starting an independent read | Two independent reads racing the user's Save/Cancel action could otherwise reset `isEmergencyModalAlreadyDisplayed` from a stale read and reopen the modal after it was already acknowledged; per-hook scoping would only dedupe within one mounted instance, not across the multiple instances this package explicitly supports mounting together (R-014) | `src/helper.ts` (module-scoped `e911CheckInFlight`, `checkE911ModalDisplay`) | `tests/helper.ts` "should dedupe concurrent checkE911ModalDisplay calls (login() racing AGENT_STATION_LOGIN_SUCCESS) into a single fetchUserPreferences() call", "should dedupe concurrent checkE911ModalDisplay calls across two separate StationLogin instances sharing the store" | none | PRESENT | ## Design Overview + The widget is intentionally thin. `StationLogin` exists only to provide the `ErrorBoundary`; the real work is in `StationLoginInternal`, an `observer()` that pulls observable state off the store singleton and constructs the `StationLoginComponentProps` object. All mutating behavior is delegated to `useStationLogin`, @@ -150,6 +168,7 @@ and `AGENT_LOGOUT_SUCCESS`. The cleanup/`removeCCCallback` is intentionally comm avoid tearing down the shared store-level event wrapper. ## Data Flow + In-process React/MobX data flow (no network/queue transport owned by this module — the SDK owns the wire). Inputs are host props and observable store state; outputs are SDK calls and host callbacks. @@ -168,15 +187,16 @@ graph LR ``` ## Sequence Diagram(s) + Sequence coverage: -| Operation group | Diagram | Failure / recovery coverage | -|---|---|---| -| Station login | "Login flow" | `alt` success vs. failure branch (`loginFailure` set) | -| Station logout | folded into login diagram's `AGENT_LOGOUT_SUCCESS` path | logout `.catch` logs and no-ops | -| Profile option save | "Profile save flow" | no-change short-circuit + `updateAgentProfile` reject branch | -| CC sign-out | "CC sign-out flow" | conditional station logout/deregister + their failure handling | -| Multiple-login Continue | "Multiple-login Continue flow" | re-register failure logged | +| Operation group | Diagram | Failure / recovery coverage | +| ----------------------- | ------------------------------------------------------- | -------------------------------------------------------------- | +| Station login | "Login flow" | `alt` success vs. failure branch (`loginFailure` set) | +| Station logout | folded into login diagram's `AGENT_LOGOUT_SUCCESS` path | logout `.catch` logs and no-ops | +| Profile option save | "Profile save flow" | no-change short-circuit + `updateAgentProfile` reject branch | +| CC sign-out | "CC sign-out flow" | conditional station logout/deregister + their failure handling | +| Multiple-login Continue | "Multiple-login Continue flow" | re-register failure logged | ```mermaid sequenceDiagram @@ -267,6 +287,7 @@ sequenceDiagram ``` ## Class / Component Relationships + ```mermaid graph TD StationLogin -->|wraps| StationLoginInternal @@ -279,6 +300,7 @@ graph TD UseStationLoginProps -.Pick.-> IStationLoginProps StationLoginComponentProps -.Pick.-> IStationLoginProps ``` + `StationLogin` (exported) is a plain FC that mounts an `ErrorBoundary` around `StationLoginInternal`, an `observer()` FC. `StationLoginInternal` composes the `useStationLogin` hook's return with store-derived props into `StationLoginComponentProps` and renders the presentational `StationLoginComponent` from @@ -287,6 +309,7 @@ and the component prop type are all `Pick`s of the single canonical `IStationLog cc-components, so prop shapes never diverge. ## Use Cases + - **UC-1 Agent login:** Agent selects team + device type (+ dial number for `EXTENSION`/`AGENT_DN`), clicks Login → `login()` → `cc.stationLogin()` → success sets `loginSuccess`, `AGENT_STATION_LOGIN_SUCCESS` fires `onLogin`, widget re-renders logged-in. Evidence: `src/helper.ts`, `tests/helper.ts`. UI flow: login form → loading → logged-in view or inline error. - **UC-2 Agent logout:** Agent clicks Logout → `logout()` → `cc.stationLogout()` → `logoutSuccess` set, `onLogout` fires. Evidence: `src/helper.ts`, `tests/helper.ts`. UI flow: logged-in view → login form. - **UC-3 Update profile options:** In `profileMode`, agent edits device type/team/dial number → `isLoginOptionsChanged` enables Save → `saveLoginOptions()` → `cc.updateAgentProfile()` → `onSaveEnd(true)` and Save disables. Evidence: `src/helper.ts`, `tests/helper.ts`. UI flow: profile form → Save (disabled until changed) → success/error message. @@ -294,6 +317,7 @@ cc-components, so prop shapes never diverge. - **UC-5 Multiple-login Continue:** Agent already logged in elsewhere → store sets `showMultipleLoginAlert` → alert shown → agent clicks Continue → `handleContinue()` clears alert + `registerCC()` takes over the session. Evidence: `src/helper.ts`, `tests/helper.ts`. UI flow: alert dialog → Continue → logged-in view. ## UI Flow + - **Login screen (logged out):** Team dropdown, login-option/device-type selector (`EXTENSION`, `AGENT_DN`, Desktop/`BROWSER`), dial-number field (shown for non-`BROWSER` types), Login button. Desktop option hidden when `hideDesktopLogin` is set. - **Logged-in view:** Logout and/or Sign Out actions. - **Profile mode (`profileMode=true`):** Same selectors plus a Save button that is disabled until `isLoginOptionsChanged` is true. @@ -301,17 +325,19 @@ cc-components, so prop shapes never diverge. - **Validation:** Dial-number validation uses `dialNumberRegex` (`cc.agentConfig.regexUS`) or international regex when `allowInternationalDn` is set; the regex selection/validation itself is enforced in `@webex/cc-components`. ## Error Handling & Failure Modes -| Condition | Signal (error/code/result) | Caller recovery | -|---|---|---| -| `cc.stationLogin()` rejects | `loginFailure` set (Error), `loginSuccess` cleared; logged | Surface `loginFailure` in UI; agent retries | -| `cc.stationLogout()` rejects | Logged via `logger.error`; no state change, no throw | None required; agent may retry logout | -| `cc.updateAgentProfile()` rejects | `saveError` = error message; `onSaveEnd(false)` | Host shows error; agent edits and re-saves | -| No changed login options | `saveError` = "No changes detected…"; `onSaveEnd(false)` | None; expected no-op | -| `stationLogout`/`deregister` fail during CC sign-out | Logged; `onCCSignOut()` still invoked | Host proceeds with app sign-out | -| `registerCC()` fails on Continue | Logged ("Agent Relogin Failed" / caught error) | Agent retries Continue | -| Render/hook throws | ErrorBoundary → empty fragment + `store.onErrorCallback('StationLogin', error)` | Host's error callback surfaces a notification | + +| Condition | Signal (error/code/result) | Caller recovery | +| ---------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------- | +| `cc.stationLogin()` rejects | `loginFailure` set (Error), `loginSuccess` cleared; logged | Surface `loginFailure` in UI; agent retries | +| `cc.stationLogout()` rejects | Logged via `logger.error`; no state change, no throw | None required; agent may retry logout | +| `cc.updateAgentProfile()` rejects | `saveError` = error message; `onSaveEnd(false)` | Host shows error; agent edits and re-saves | +| No changed login options | `saveError` = "No changes detected…"; `onSaveEnd(false)` | None; expected no-op | +| `stationLogout`/`deregister` fail during CC sign-out | Logged; `onCCSignOut()` still invoked | Host proceeds with app sign-out | +| `registerCC()` fails on Continue | Logged ("Agent Relogin Failed" / caught error) | Agent retries Continue | +| Render/hook throws | ErrorBoundary → empty fragment + `store.onErrorCallback('StationLogin', error)` | Host's error callback surfaces a notification | ## Pitfalls + - **CC callback cleanup is intentionally disabled.** The `useEffect` that registers `setCCCallback` does NOT call `removeCCCallback` on unmount (commented out in `helper.ts`) because doing so tore down the shared store-level event wrapper for all consumers. Re-adding naive cleanup will break login/logout events repo-wide. - **`login()` uses hook-prop values, not the local form state.** `cc.stationLogin` is called with `{teamId: team, loginOption: deviceType, dialNumber}` where `deviceType`/`dialNumber` come from store-derived props and `team` from local `setTeam` state — not from `selectedDeviceType`/`dialNumberValue`. When changing the login payload, trace which value actually feeds `cc.stationLogin`. - **`isLoginOptionsChanged` ignores `dialNumber` for `BROWSER`.** Desktop/WebRTC has no dial number, so the comparison skips it; a stale `dialNumber` will not (and must not) enable Save in `BROWSER` mode. @@ -319,23 +345,27 @@ cc-components, so prop shapes never diverge. - **Archived-doc drift.** The pre-migration ARCHITECTURE diagram showed a `store.login()` call; the real code calls `cc.stationLogin()` directly from the hook. Trust `src/helper.ts`. ## Module Do's / Don'ts + - DO: route all SDK access through `props.cc` (from `store.cc`) inside the hook; never import the SDK directly. - DO: wrap every hook method body in try/catch and log via the SDK `logger` with `{module, method}` metadata. - DON'T: reintroduce `removeCCCallback` cleanup without verifying the shared store event wrapper survives. - DON'T: redefine prop shapes locally — `Pick` from `IStationLoginProps` in `@webex/cc-components`. ## Export Stability + `src/index.ts` re-exports only `StationLogin`. Adding an optional prop to `StationLoginProps` is a minor change; removing/renaming a prop, changing a callback signature, or renaming the `widget-cc-station-login` custom element is a major (breaking) change. Type surface ships from `dist/types/index.d.ts`. ## Host Integration & Theming + Consumed via `@webex/cc-widgets`, which wraps `StationLogin` as the custom element `widget-cc-station-login` (r2wc). The store must be initialized (`store.init(...)`) before the widget renders. Hosts typically wrap it in Momentum `ThemeProvider`/`IconProvider`; peer deps require `react`/`react-dom` `>=18.3.1` and `@momentum-ui/react-collaboration` `>=26.201.9`. Error reporting is wired through `store.onErrorCallback`. ## Test-Case Strategy (module) + Hook tests (`tests/helper.ts`) exercise login success/failure, logout success/failure, callback-present and callback-absent paths, profile save (no-change short-circuit, changed save, `BROWSER` no-dial-number, update-profile error), `handleContinue` (logged-in, not-logged-in, error), and CC sign-out @@ -344,20 +374,26 @@ asserting that try/catch swallows errors in each method. Widget tests (`tests/st assert prop wiring into `useStationLogin` and the ErrorBoundary empty-fragment behavior. Each major method has both a positive and a negative case. -| Behavior / Requirement | Existing test evidence | Gap | -|---|---|---| -| `STATION-LOGIN-R-001` | `tests/helper.ts` login success/failure cases | none | -| `STATION-LOGIN-R-002` | `tests/helper.ts` logout success + failure | none | -| `STATION-LOGIN-R-003` | `tests/helper.ts` callback-present / callback-absent | mount-already-logged-in `onLogin` path is implicit, not a dedicated assertion | -| `STATION-LOGIN-R-004` | `tests/helper.ts` "should not save if isLoginOptionsChanged is false" | none | -| `STATION-LOGIN-R-005` | `tests/helper.ts` save-when-changed + `BROWSER` no-dial-number | none | -| `STATION-LOGIN-R-006` | `tests/helper.ts` updateAgentProfile error cases | none | -| `STATION-LOGIN-R-007` | `tests/helper.ts` handleContinue (3 cases) | none | -| `STATION-LOGIN-R-008` | `tests/helper.ts` `#onCCSignOut` block (4 cases) | none | -| `STATION-LOGIN-R-009` | `tests/station-login/index.tsx` ErrorBoundary test | none | -| `STATION-LOGIN-R-010` | `tests/station-login/index.tsx` "renders … with correct props" | `hideDesktopLogin`/`allowInternationalDn` forwarding not asserted directly | +| Behavior / Requirement | Existing test evidence | Gap | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `STATION-LOGIN-R-001` | `tests/helper.ts` login success/failure cases | none | +| `STATION-LOGIN-R-002` | `tests/helper.ts` logout success + failure | none | +| `STATION-LOGIN-R-003` | `tests/helper.ts` callback-present / callback-absent | mount-already-logged-in `onLogin` path is implicit, not a dedicated assertion | +| `STATION-LOGIN-R-004` | `tests/helper.ts` "should not save if isLoginOptionsChanged is false" | none | +| `STATION-LOGIN-R-005` | `tests/helper.ts` save-when-changed + `BROWSER` no-dial-number | none | +| `STATION-LOGIN-R-006` | `tests/helper.ts` updateAgentProfile error cases | none | +| `STATION-LOGIN-R-007` | `tests/helper.ts` handleContinue (3 cases) | none | +| `STATION-LOGIN-R-008` | `tests/helper.ts` `#onCCSignOut` block (4 cases) | none | +| `STATION-LOGIN-R-009` | `tests/station-login/index.tsx` ErrorBoundary test | none | +| `STATION-LOGIN-R-010` | `tests/station-login/index.tsx` "renders … with correct props" | `hideDesktopLogin`/`allowInternationalDn` forwarding not asserted directly | +| `STATION-LOGIN-R-011` | `tests/helper.ts` `checkE911ModalDisplay (via login)` — 5 trigger-point cases (login, mount/refresh, relogin, multi-login takeover, profile save) | none | +| `STATION-LOGIN-R-012` | `tests/helper.ts` non-`BROWSER` skip, already-acknowledged skip, error-swallowing cases | Persistence correctness (merge, userId) is validated in `store/tests/storeEventsWrapper.ts`, not here | +| `STATION-LOGIN-R-013` | `tests/station-login/index.tsx` E911Modal describe block; `cc-components/tests/.../e911-modal.test.tsx` Cancel-button and native-`close`-event cases | Full dismiss/save-error behavior tested in `cc-components`, not here | +| `STATION-LOGIN-R-014` | `tests/station-login/index.tsx` "renders E911Modal in only one of multiple mounted StationLogin instances", "lets a surviving instance reclaim the E911Modal after the owning instance unmounts" | none | +| `STATION-LOGIN-R-015` | `tests/helper.ts` "should dedupe concurrent checkE911ModalDisplay calls (login() racing AGENT_STATION_LOGIN_SUCCESS)…", "should dedupe concurrent checkE911ModalDisplay calls across two separate StationLogin instances sharing the store" | none | ## Traceability + - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md` - Contracts: `../../../../ai-docs/CONTRACTS.md` (`cc-widgets.StationLogin`) - Coverage state & contracts baseline: `.sdd/manifest.json` diff --git a/packages/contact-center/station-login/src/helper.ts b/packages/contact-center/station-login/src/helper.ts index 6aa22c3ce..336d8e1fe 100644 --- a/packages/contact-center/station-login/src/helper.ts +++ b/packages/contact-center/station-login/src/helper.ts @@ -1,9 +1,16 @@ import {useEffect, useState} from 'react'; import {LogoutSuccess, AgentProfileUpdate, LoginOption, StationLoginSuccessResponse} from '@webex/contact-center'; import {UseStationLoginProps} from './station-login/station-login.types'; -import store, {CC_EVENTS} from '@webex/cc-store'; // we need to import as we are losing the context of this in store +import store, {CC_EVENTS, DEVICE_TYPE_BROWSER} from '@webex/cc-store'; // we need to import as we are losing the context of this in store import {LoginOptionsState} from '@webex/cc-components'; +// Single-flight guard for checkE911ModalDisplay - see its usage below. Module-scoped (not a +// useRef) because more than one StationLogin/useStationLogin instance can be mounted against the +// same store singleton at once (e.g. the login widget plus a profileMode settings widget); a +// per-instance ref would only dedupe within one instance, letting a second instance's concurrent +// fetchUserPreferences() read race the first and reopen the modal with a stale result. +let e911CheckInFlight: Promise | null = null; + export const useStationLogin = (props: UseStationLoginProps) => { const cc = props.cc; const loginCb = props.onLogin; @@ -80,7 +87,7 @@ export const useStationLogin = (props: UseStationLoginProps) => { // Compare logic for Save button const isLoginOptionsChanged = originalLoginOptions.deviceType !== currentLoginOptions.deviceType || - (currentLoginOptions.deviceType !== 'BROWSER' && + (currentLoginOptions.deviceType !== DEVICE_TYPE_BROWSER && originalLoginOptions.dialNumber !== currentLoginOptions.dialNumber) || originalLoginOptions.teamId !== currentLoginOptions.teamId; @@ -104,7 +111,7 @@ export const useStationLogin = (props: UseStationLoginProps) => { loginOption: currentLoginOptions.deviceType as LoginOption, teamId: currentLoginOptions.teamId || undefined, }; - if (currentLoginOptions.deviceType !== 'BROWSER') { + if (currentLoginOptions.deviceType !== DEVICE_TYPE_BROWSER) { payload.dialNumber = currentLoginOptions.dialNumber; } @@ -121,6 +128,8 @@ export const useStationLogin = (props: UseStationLoginProps) => { module: 'widget-station-login#helper.ts', method: 'saveLoginOptions', }); + // Covers switching to BROWSER via profile save while already logged in. + checkE911ModalDisplay(currentLoginOptions.deviceType); if (props.onSaveEnd) props.onSaveEnd(true); }) .catch((error: Error) => { @@ -146,6 +155,11 @@ export const useStationLogin = (props: UseStationLoginProps) => { if (loginCb && store.isAgentLoggedIn) { loginCb(); } + if (store.isAgentLoggedIn) { + // Covers page refresh while already logged in with BROWSER: login() never runs, so this is + // the only place that can trigger the E911 modal for an already-hydrated BROWSER session. + checkE911ModalDisplay(store.deviceType); + } } catch (error) { logger.error(`CC-Widgets: Error in useEffect (loginCb) - ${error.message}`, { module: 'widget-station-login#helper.ts', @@ -172,6 +186,9 @@ export const useStationLogin = (props: UseStationLoginProps) => { if (loginCb) { loginCb(); } + // Covers AGENT_STATION_LOGIN_SUCCESS / relogin events that aren't driven by this widget's + // own login() call (e.g. another tab/session logging in as BROWSER). + checkE911ModalDisplay(store.deviceType); } catch (error) { logger.error(`CC-Widgets: Error in handleLogin - ${error.message}`, { module: 'widget-station-login#helper.ts', @@ -223,6 +240,9 @@ export const useStationLogin = (props: UseStationLoginProps) => { module: 'widget-station-login#helper.ts', method: 'handleContinue', }); + // Covers the multi-login takeover flow: registerCC() completes the relogin but never + // runs the E911 preference check on its own. + checkE911ModalDisplay(store.deviceType); } else { logger.error(`Agent Relogin Failed`, { module: 'widget-station-login#helper.ts', @@ -237,6 +257,46 @@ export const useStationLogin = (props: UseStationLoginProps) => { } }; + const checkE911ModalDisplay = (deviceTypeToCheck: string): Promise => { + if (deviceTypeToCheck !== DEVICE_TYPE_BROWSER) { + return Promise.resolve(); + } + + // A single login can trigger this from more than one path at once (e.g. login()'s own + // success handler racing the AGENT_STATION_LOGIN_SUCCESS callback). Make the check + // single-flight so concurrent callers join the same fetchUserPreferences() call instead of + // each starting their own - two independent reads could otherwise race with the user's + // Save/Cancel action and reopen the modal with a stale "not yet acknowledged" result. + if (e911CheckInFlight) { + return e911CheckInFlight; + } + + const check = (async () => { + try { + await store.fetchUserPreferences(); + + if (!store.isEmergencyModalAlreadyDisplayed) { + store.setShowE911Modal(true); + logger.log('CC-Widgets: E911 modal displayed for BROWSER login', { + module: 'widget-station-login#helper.ts', + method: 'checkE911ModalDisplay', + }); + } + } catch (error) { + logger.error(`CC-Widgets: Error checking E911 modal display - ${error.message}`, { + module: 'widget-station-login#helper.ts', + method: 'checkE911ModalDisplay', + }); + } finally { + e911CheckInFlight = null; + } + })(); + + e911CheckInFlight = check; + + return check; + }; + const login = () => { try { cc.stationLogin({teamId: team, loginOption: deviceType, dialNumber}) @@ -247,6 +307,8 @@ export const useStationLogin = (props: UseStationLoginProps) => { }); setLoginSuccess(res); setLoginFailure(undefined); + + checkE911ModalDisplay(deviceType); }) .catch((error: Error) => { logger.error(`Error logging in: ${error}`, { diff --git a/packages/contact-center/station-login/src/station-login/index.tsx b/packages/contact-center/station-login/src/station-login/index.tsx index d66c9fee1..61ee73b96 100644 --- a/packages/contact-center/station-login/src/station-login/index.tsx +++ b/packages/contact-center/station-login/src/station-login/index.tsx @@ -1,12 +1,20 @@ -import React from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import store from '@webex/cc-store'; import {observer} from 'mobx-react-lite'; import {ErrorBoundary} from 'react-error-boundary'; -import {StationLoginComponent, StationLoginComponentProps} from '@webex/cc-components'; +import {StationLoginComponent, StationLoginComponentProps, E911Modal} from '@webex/cc-components'; import {useStationLogin} from '../helper'; import {StationLoginProps} from './station-login.types'; +// A host can mount more than one StationLogin (e.g. the normal login widget and a separate +// profileMode settings widget) against the same store singleton. E911Modal is driven by +// store.showE911Modal, so without this guard every mounted instance would render its own copy +// and a single BROWSER login would pop duplicate blocking dialogs. Only the first-mounted +// instance renders the modal; ownership is released on unmount so another instance can take over. + +let e911ModalOwner: symbol | null = null; + const StationLoginInternal: React.FunctionComponent = observer( ({ onLogin, @@ -32,6 +40,9 @@ const StationLoginInternal: React.FunctionComponent = observe setDialNumber, teamId, setTeamId, + showE911Modal, + setShowE911Modal, + updateEmergencyModalAcknowledgment, } = store; const result = useStationLogin({ @@ -49,6 +60,29 @@ const StationLoginInternal: React.FunctionComponent = observe doStationLogout, }); + const instanceIdRef = useRef(); + if (!instanceIdRef.current) { + instanceIdRef.current = Symbol('station-login-instance'); + } + const [ownsE911Modal, setOwnsE911Modal] = useState(false); + + useEffect(() => { + if (!e911ModalOwner) { + e911ModalOwner = instanceIdRef.current; + setOwnsE911Modal(true); + } + + return () => { + if (e911ModalOwner === instanceIdRef.current) { + e911ModalOwner = null; + } + }; + // Re-run whenever showE911Modal changes (not just on mount) so that if the current owner + // unmounts while another StationLogin instance stays mounted, that surviving instance gets + // a chance to reclaim ownership on the next login/relogin instead of the modal never + // rendering again for the rest of the session. + }, [showE911Modal]); + const dialNumberRegex = cc?.agentConfig?.regexUS; const props: StationLoginComponentProps = { ...result, @@ -67,7 +101,32 @@ const StationLoginInternal: React.FunctionComponent = observe allowInternationalDn, }; - return ; + const handleE911SaveAndContinue = async () => { + try { + await updateEmergencyModalAcknowledgment(); + } catch (error) { + logger.error('CC-Widgets: Failed to update E911 acknowledgment', { + module: 'widget-station-login#index.tsx', + method: 'handleE911SaveAndContinue', + error, + }); + // Rethrow so E911Modal can surface a user-facing error and keep the modal open for retry. + throw error; + } + }; + + const handleE911Cancel = () => { + setShowE911Modal(false); + }; + + return ( + <> + + {ownsE911Modal && ( + + )} + + ); } ); diff --git a/packages/contact-center/station-login/tests/helper.ts b/packages/contact-center/station-login/tests/helper.ts index d33d10769..a77e907d8 100644 --- a/packages/contact-center/station-login/tests/helper.ts +++ b/packages/contact-center/station-login/tests/helper.ts @@ -17,6 +17,7 @@ jest.mock('@webex/cc-store', () => { isAgentLoggedIn = value; // Update internal variable }), registerCC: jest.fn(), + deviceType: 'EXTENSION', setDeviceType: jest.fn(), setDialNumber: jest.fn(), setCurrentState: jest.fn(), @@ -26,9 +27,13 @@ jest.mock('@webex/cc-store', () => { setLogoutCallback: jest.fn(), setCCCallback: jest.fn(), removeCCCallback: jest.fn(), + fetchUserPreferences: jest.fn(), + setShowE911Modal: jest.fn(), + isEmergencyModalAlreadyDisplayed: false, CC_EVENTS: { AGENT_STATION_LOGIN_SUCCESS: 'AgentStationLoginSuccess', }, + DEVICE_TYPE_BROWSER: 'BROWSER', }; }); @@ -1024,4 +1029,314 @@ describe('useStationLogin Hook', () => { }); }); }); + + describe('checkE911ModalDisplay (via login)', () => { + afterEach(() => { + (store.fetchUserPreferences as jest.Mock).mockReset(); + (store.setShowE911Modal as jest.Mock).mockReset(); + (store as unknown as {isEmergencyModalAlreadyDisplayed: boolean}).isEmergencyModalAlreadyDisplayed = false; + (store as unknown as {deviceType: string}).deviceType = 'EXTENSION'; + store.setIsAgentLoggedIn(false); + }); + + it('should show the E911 modal on successful BROWSER login when not already acknowledged', async () => { + ccMock.stationLogin.mockResolvedValue(successResponse); + (store.fetchUserPreferences as jest.Mock).mockResolvedValue(undefined); + (store as unknown as {isEmergencyModalAlreadyDisplayed: boolean}).isEmergencyModalAlreadyDisplayed = false; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + }) + ); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).toHaveBeenCalled(); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + }); + + it('should skip the E911 modal for non-BROWSER device types', async () => { + ccMock.stationLogin.mockResolvedValue(successResponse); + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'EXTENSION', + }) + ); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).not.toHaveBeenCalled(); + expect(store.setShowE911Modal).not.toHaveBeenCalled(); + }); + }); + + it('should skip the E911 modal when the preference is already acknowledged', async () => { + ccMock.stationLogin.mockResolvedValue(successResponse); + (store.fetchUserPreferences as jest.Mock).mockResolvedValue(undefined); + (store as unknown as {isEmergencyModalAlreadyDisplayed: boolean}).isEmergencyModalAlreadyDisplayed = true; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + }) + ); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).toHaveBeenCalled(); + expect(store.setShowE911Modal).not.toHaveBeenCalled(); + }); + }); + + it('should log an error and not show the modal when fetchUserPreferences fails', async () => { + ccMock.stationLogin.mockResolvedValue(successResponse); + const fetchError = new Error('userPreference service not available'); + (store.fetchUserPreferences as jest.Mock).mockRejectedValue(fetchError); + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + }) + ); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(() => { + expect(store.setShowE911Modal).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('CC-Widgets: Error checking E911 modal display'), + expect.objectContaining({ + module: 'widget-station-login#helper.ts', + method: 'checkE911ModalDisplay', + }) + ); + }); + }); + + it('should show the E911 modal on mount when already logged in with BROWSER (e.g. page refresh)', async () => { + (store.fetchUserPreferences as jest.Mock).mockResolvedValue(undefined); + store.setIsAgentLoggedIn(true); + (store as unknown as {deviceType: string}).deviceType = 'BROWSER'; + + renderHook(() => useStationLogin(baseStationLoginProps)); + + await waitFor(() => { + expect(store.fetchUserPreferences).toHaveBeenCalled(); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + }); + + it('should not show the E911 modal on mount when already logged in with a non-BROWSER device', async () => { + store.setIsAgentLoggedIn(true); + (store as unknown as {deviceType: string}).deviceType = 'EXTENSION'; + + renderHook(() => useStationLogin(baseStationLoginProps)); + + await waitFor(() => { + expect(store.fetchUserPreferences).not.toHaveBeenCalled(); + expect(store.setShowE911Modal).not.toHaveBeenCalled(); + }); + }); + + it('should show the E911 modal on AGENT_STATION_LOGIN_SUCCESS / relogin when store.deviceType is BROWSER', async () => { + (store.fetchUserPreferences as jest.Mock).mockResolvedValue(undefined); + (store as unknown as {deviceType: string}).deviceType = 'BROWSER'; + jest.spyOn(store, 'setCCCallback').mockImplementation((event, cb) => { + ccMock.on(event, cb); + }); + const onSpy = jest.spyOn(ccMock, 'on'); + + renderHook(() => useStationLogin(baseStationLoginProps)); + + expect(ccMock.on).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, expect.any(Function)); + + act(() => { + (onSpy.mock.calls[0][1] as (payload: unknown) => void)({deviceType: 'BROWSER', auxCodeId: '0'}); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).toHaveBeenCalled(); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + }); + + it('should dedupe concurrent checkE911ModalDisplay calls (login() racing AGENT_STATION_LOGIN_SUCCESS) into a single fetchUserPreferences() call', async () => { + ccMock.stationLogin.mockResolvedValue(successResponse); + (store as unknown as {deviceType: string}).deviceType = 'BROWSER'; + + let resolveFetch: () => void = () => {}; + (store.fetchUserPreferences as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + jest.spyOn(store, 'setCCCallback').mockImplementation((event, cb) => { + ccMock.on(event, cb); + }); + const onSpy = jest.spyOn(ccMock, 'on'); + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + }) + ); + + const loginSuccessCb = onSpy.mock.calls.find((call) => call[0] === CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS)[1] as ( + payload: unknown + ) => void; + + act(() => { + // Simulate the real race: login()'s own success handler and the SDK's + // AGENT_STATION_LOGIN_SUCCESS callback both firing for the same login before + // fetchUserPreferences() resolves. + result.current.login(); + loginSuccessCb({deviceType: 'BROWSER', auxCodeId: '0'}); + }); + + await act(async () => { + resolveFetch(); + // Flush pending microtasks so both callers' continuations run. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(store.fetchUserPreferences).toHaveBeenCalledTimes(1); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + + it('should dedupe concurrent checkE911ModalDisplay calls across two separate StationLogin instances sharing the store', async () => { + // Regression test: the in-flight guard is module-scoped (not a per-hook useRef) so that + // two StationLogin instances mounted at once (e.g. the login widget plus a profileMode + // settings widget) join the same fetchUserPreferences() call instead of racing. + ccMock.stationLogin.mockResolvedValue(successResponse); + (store as unknown as {deviceType: string}).deviceType = 'BROWSER'; + + let resolveFetch: () => void = () => {}; + (store.fetchUserPreferences as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + const {result: resultA} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + }) + ); + const {result: resultB} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + deviceType: 'BROWSER', + }) + ); + + await act(async () => { + // Simulate two mounted instances both reacting to the same login. login()'s + // cc.stationLogin().then() is a microtask, so flush it before resolving + // fetchUserPreferences() to ensure both instances have actually reached + // checkE911ModalDisplay() by the time it resolves. + resultA.current.login(); + resultB.current.login(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + resolveFetch(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(store.fetchUserPreferences).toHaveBeenCalledTimes(1); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + + it('should show the E911 modal after a successful multi-login takeover (handleContinue) with BROWSER', async () => { + (store.fetchUserPreferences as jest.Mock).mockResolvedValue(undefined); + store.setIsAgentLoggedIn(true); + (store as unknown as {deviceType: string}).deviceType = 'BROWSER'; + + const {result} = renderHook(() => useStationLogin(baseStationLoginProps)); + + await act(async () => { + await result.current.handleContinue(); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).toHaveBeenCalled(); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + }); + + it('should not show the E911 modal after handleContinue when store.deviceType is not BROWSER', async () => { + store.setIsAgentLoggedIn(true); + (store as unknown as {deviceType: string}).deviceType = 'EXTENSION'; + + const {result} = renderHook(() => useStationLogin(baseStationLoginProps)); + + await act(async () => { + await result.current.handleContinue(); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).not.toHaveBeenCalled(); + expect(store.setShowE911Modal).not.toHaveBeenCalled(); + }); + }); + + it('should show the E911 modal after saving login options with BROWSER as the new device type', async () => { + (store.fetchUserPreferences as jest.Mock).mockResolvedValue(undefined); + const cc = {...ccMock, updateAgentProfile: jest.fn().mockResolvedValue({})}; + + const {result} = renderHook(() => + useStationLogin({ + ...baseStationLoginProps, + cc, + deviceType: 'EXTENSION', + }) + ); + + act(() => { + result.current.setCurrentLoginOptions({ + deviceType: 'BROWSER', + dialNumber: '', + teamId: 'team123', + }); + }); + + await act(async () => { + await result.current.saveLoginOptions(); + }); + + await waitFor(() => { + expect(store.fetchUserPreferences).toHaveBeenCalled(); + expect(store.setShowE911Modal).toHaveBeenCalledWith(true); + }); + }); + }); }); diff --git a/packages/contact-center/station-login/tests/station-login/index.tsx b/packages/contact-center/station-login/tests/station-login/index.tsx index c3cf16bd6..73a8ef11f 100644 --- a/packages/contact-center/station-login/tests/station-login/index.tsx +++ b/packages/contact-center/station-login/tests/station-login/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {render} from '@testing-library/react'; +import {render, screen} from '@testing-library/react'; import {StationLogin} from '../../src'; import * as helper from '../../src/helper'; import '@testing-library/jest-dom'; @@ -93,6 +93,40 @@ describe('StationLogin Component', () => { }); }); + describe('E911Modal single-owner rendering', () => { + it('renders E911Modal in only one of multiple mounted StationLogin instances', () => { + render( + <> + + + + ); + + // Two StationLogin widgets (e.g. the normal login widget + a profileMode settings widget) + // are commonly mounted together against the same store singleton - only one should render + // the E911Modal, otherwise a single BROWSER login would pop duplicate blocking dialogs. + expect(screen.getAllByTestId('e911-modal')).toHaveLength(1); + }); + + it('lets a surviving instance reclaim the E911Modal after the owning instance unmounts', () => { + const owner = render(); + const survivor = render(); + + expect(screen.getAllByTestId('e911-modal')).toHaveLength(1); + + owner.unmount(); + expect(screen.queryAllByTestId('e911-modal')).toHaveLength(0); + + (store as unknown as {showE911Modal: boolean}).showE911Modal = true; + // The observer-wrapped component memoizes on props, so re-rendering with the exact same + // props would bail out before ever re-reading the (test-mocked, non-reactive) store value. + // Change an unrelated prop to force React to re-invoke the component and pick up the update. + survivor.rerender(); + + expect(screen.getAllByTestId('e911-modal')).toHaveLength(1); + }); + }); + describe('ErrorBoundary Tests', () => { const mockOnErrorCallback = jest.fn(); store.onErrorCallback = mockOnErrorCallback; diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 69ab4bcfb..cdda48a0f 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -55,6 +55,8 @@ class Store implements IStore { dataCenter: string = ''; realtimeTranscriptionData: Partial[] = []; acceptedCampaignIds: Set = new Set(); + showE911Modal: boolean = false; + isEmergencyModalAlreadyDisplayed: boolean = false; constructor() { makeAutoObservable(this, { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 8635c184a..b52d436b2 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -73,6 +73,7 @@ interface IContactCenter { acceptPreviewContact(payload: PreviewContactPayload): Promise; skipPreviewContact(payload: PreviewContactPayload): Promise; removePreviewContact(payload: PreviewContactPayload): Promise; + userPreference?: IUserPreferenceService; } // To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 type IWebex = { @@ -167,6 +168,8 @@ interface IStore { dataCenter: string; realtimeTranscriptionData: Partial[]; acceptedCampaignIds: Set; + showE911Modal: boolean; + isEmergencyModalAlreadyDisplayed: boolean; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; } @@ -201,6 +204,10 @@ interface IStoreWrapper extends IStore { getAccessToken(): Promise; addAcceptedCampaign(interactionId: string): void; removeAcceptedCampaign(interactionId: string): void; + setShowE911Modal(value: boolean): void; + setIsEmergencyModalAlreadyDisplayed(value: boolean): void; + fetchUserPreferences(): Promise; + updateEmergencyModalAcknowledgment(): Promise; } interface IWrapupCode { @@ -362,6 +369,69 @@ export type Participant = { name?: string; }; +/** + * Desktop preference data structure containing E911 modal acknowledgment. + * @public + */ +export type DesktopPreference = { + isEmergencyModalAlreadyDisplayed?: boolean; +}; + +/** + * User preference response from the SDK. + * @public + */ +export type UserPreferenceResponse = { + id: string; + organizationId: string; + userId: string; + // The SDK returns the persisted desktopPreference JSON string nested under `preferences`, + // not as a top-level field - see CAI-7906. + preferences?: { + desktopPreference?: string; + }; + createdTime?: number; + lastUpdatedTime?: number; +}; + +/** + * Request payload for creating user preferences. + * @public + */ +export type CreateUserPreferenceRequest = { + userId: string; + desktopPreference: string; +}; + +/** + * Request payload for updating user preferences. + * @public + */ +export type UpdateUserPreferenceRequest = { + desktopPreference: string; +}; + +/** + * Query parameters for fetching user preferences. + * @public + */ +export type GetUserPreferenceParams = { + userId?: string; + page?: number; + pageSize?: number; +}; + +/** + * User Preference service interface. + * TODO: Remove once SDK exposes this interface - CAI-7906 + * @public + */ +export interface IUserPreferenceService { + getUserPreference(params?: GetUserPreferenceParams): Promise; + createUserPreference(data: CreateUserPreferenceRequest): Promise; + updateUserPreference(userId: string, data: UpdateUserPreferenceRequest): Promise; +} + /** Outbound type values that identify a campaign preview task. */ export const CAMPAIGN_PREVIEW_OUTBOUND_TYPES = ['STANDARD_PREVIEW_CAMPAIGN', 'DIRECT_PREVIEW_CAMPAIGN']; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index fc12ff465..7e7bb9a52 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -25,6 +25,7 @@ import { AgentLoginProfile, ERROR_TRIGGERING_IDLE_CODES, RealTimeTranscriptionEventPayload, + DesktopPreference, } from './store.types'; import Store from './store'; import { @@ -160,6 +161,14 @@ class StoreWrapper implements IStoreWrapper { return this.store.acceptedCampaignIds; } + get showE911Modal() { + return this.store.showE911Modal; + } + + get isEmergencyModalAlreadyDisplayed() { + return this.store.isEmergencyModalAlreadyDisplayed; + } + setDataCenter = (value: string): void => { this.store.dataCenter = value; }; @@ -581,6 +590,173 @@ class StoreWrapper implements IStoreWrapper { }); }; + setShowE911Modal = (value: boolean): void => { + runInAction(() => { + this.store.showE911Modal = value; + }); + }; + + setIsEmergencyModalAlreadyDisplayed = (value: boolean): void => { + runInAction(() => { + this.store.isEmergencyModalAlreadyDisplayed = value; + }); + }; + + fetchUserPreferences = async (): Promise => { + try { + if (!this.store.cc.userPreference) { + this.store.logger.warn('CC-Widgets: fetchUserPreferences(): userPreference service not available', { + module: 'storeEventsWrapper.ts', + method: 'fetchUserPreferences', + }); + throw new Error('userPreference service not available'); + } + + let response; + try { + response = await this.store.cc.userPreference.getUserPreference(); + } catch (getError) { + if ((getError as {statusCode?: number})?.statusCode === 404) { + // First-time user: no preference record has been created yet. This is not a failure - + // treat it the same as "not yet acknowledged" so the E911 modal can still be shown. + this.store.logger.info('CC-Widgets: fetchUserPreferences(): no user preference record exists yet', { + module: 'storeEventsWrapper.ts', + method: 'fetchUserPreferences', + }); + runInAction(() => { + this.store.isEmergencyModalAlreadyDisplayed = false; + }); + + return; + } + + throw getError; + } + + // The SDK returns the persisted desktopPreference JSON string nested under `preferences`, + // not as a top-level field - see CAI-7906. + const desktopPrefString = response?.preferences?.desktopPreference; + + let isEmergencyModalAlreadyDisplayed = false; + + if (desktopPrefString) { + try { + const desktopPref = JSON.parse(desktopPrefString) as DesktopPreference; + isEmergencyModalAlreadyDisplayed = desktopPref.isEmergencyModalAlreadyDisplayed ?? false; + } catch (parseError) { + this.store.logger.error('CC-Widgets: fetchUserPreferences(): failed to parse desktopPreference', { + module: 'storeEventsWrapper.ts', + method: 'fetchUserPreferences', + error: parseError, + }); + } + } + + runInAction(() => { + this.store.isEmergencyModalAlreadyDisplayed = isEmergencyModalAlreadyDisplayed; + }); + } catch (error) { + this.store.logger.error('CC-Widgets: fetchUserPreferences(): failed to fetch user preferences', { + module: 'storeEventsWrapper.ts', + method: 'fetchUserPreferences', + error, + }); + throw error; + } + }; + + updateEmergencyModalAcknowledgment = async (): Promise => { + try { + if (!this.store.cc.userPreference) { + this.store.logger.warn( + 'CC-Widgets: updateEmergencyModalAcknowledgment(): userPreference service not available', + { + module: 'storeEventsWrapper.ts', + method: 'updateEmergencyModalAcknowledgment', + } + ); + throw new Error('userPreference service not available'); + } + + let response; + let hasExistingRecord = true; + try { + response = await this.store.cc.userPreference.getUserPreference(); + } catch (getError) { + if ((getError as {statusCode?: number})?.statusCode === 404) { + // First-time user: no preference record exists yet, so there's nothing to merge and + // no userId to reuse. Fall through and create the record below. + hasExistingRecord = false; + } else { + throw getError; + } + } + + // The SDK returns the persisted desktopPreference JSON string nested under `preferences`, + // not as a top-level field - see CAI-7906. + const existingDesktopPrefString = response?.preferences?.desktopPreference; + + let existingDesktopPref: DesktopPreference = {}; + if (existingDesktopPrefString) { + try { + existingDesktopPref = JSON.parse(existingDesktopPrefString) as DesktopPreference; + } catch (parseError) { + this.store.logger.error( + 'CC-Widgets: updateEmergencyModalAcknowledgment(): failed to parse existing desktopPreference', + { + module: 'storeEventsWrapper.ts', + method: 'updateEmergencyModalAcknowledgment', + error: parseError, + } + ); + } + } + + const desktopPreference = JSON.stringify({ + ...existingDesktopPref, + isEmergencyModalAlreadyDisplayed: true, + }); + + if (hasExistingRecord) { + await this.store.cc.userPreference.updateUserPreference(response?.userId, { + desktopPreference, + }); + } else { + // createUserPreference's userId must be the CI user id, not the CC agentId - they can + // differ (see the existing-record update path above, which uses response.userId for the + // same reason). There's no existing preference record to read the CI id from here, so + // pull it from the underlying webex SDK instead of guessing with store.agentId. + // @ts-expect-error - webex internal device API not typed + const ciUserId: string = this.store.cc.webex.internal.device.userId; + + await this.store.cc.userPreference.createUserPreference({ + userId: ciUserId, + desktopPreference, + }); + } + + runInAction(() => { + this.store.isEmergencyModalAlreadyDisplayed = true; + this.store.showE911Modal = false; + }); + + this.store.logger.info( + 'CC-Widgets: updateEmergencyModalAcknowledgment(): successfully updated emergency modal acknowledgment', + { + module: 'storeEventsWrapper.ts', + method: 'updateEmergencyModalAcknowledgment', + } + ); + } catch (error) { + this.store.logger.error('CC-Widgets: updateEmergencyModalAcknowledgment(): failed to update user preferences', { + module: 'storeEventsWrapper.ts', + method: 'updateEmergencyModalAcknowledgment', + error, + }); + throw error; + } + }; + handleCampaignPreviewReservation = (event: ITask) => { const isCampaignPreview = this.isCampaignPreview(event); @@ -1115,6 +1291,8 @@ class StoreWrapper implements IStoreWrapper { this.store.acceptedCampaignIds = new Set(); this.realtimeTranscriptionListeners = {}; this.setLastConsultDestination(null); + this.setShowE911Modal(false); + this.setIsEmergencyModalAlreadyDisplayed(false); }); }; diff --git a/packages/contact-center/store/src/util.ts b/packages/contact-center/store/src/util.ts index c97351593..e1acaf975 100644 --- a/packages/contact-center/store/src/util.ts +++ b/packages/contact-center/store/src/util.ts @@ -6,7 +6,7 @@ export function getFeatureFlags(agentProfile: Profile) { 'isOutboundEnabledForAgent', 'isAdhocDialingEnabled', 'isCampaignManagementEnabled', - 'isEndCallEnabled', + 'isEndTaskEnabled', 'isEndConsultEnabled', 'agentPersonalStatsEnabled', 'isCallMonitoringEnabled', diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 5f84dbb3b..9daf8d296 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -53,6 +53,11 @@ jest.mock('../src/store', () => ({ credentials: { getUserToken: jest.fn(), }, + internal: { + device: { + userId: 'mock-ci-user-id', + }, + }, }, }, logger: { @@ -107,6 +112,8 @@ jest.mock('../src/store', () => ({ isDeclineButtonEnabled: false, isDigitalChannelsInitialized: false, acceptedCampaignIds: new Set(), + showE911Modal: false, + isEmergencyModalAlreadyDisplayed: false, setShowMultipleLoginAlert: jest.fn(), setCurrentState: jest.fn(), setLastStateChangeTimestamp: jest.fn(), @@ -1731,6 +1738,32 @@ describe('storeEventsWrapper', () => { expect(storeWrapper['store'].agentProfile).toEqual({}); }); + it('should reset showE911Modal and isEmergencyModalAlreadyDisplayed on logout', async () => { + const cc = storeWrapper['store'].cc; + const onSpy = jest.spyOn(cc, 'on'); + storeWrapper['store'].init = jest.fn().mockImplementation((_options, setupIncomingTaskHandler) => { + setupIncomingTaskHandler(cc); + return Promise.resolve(); + }); + + await storeWrapper.init(options); + + storeWrapper['store'].showE911Modal = true; + storeWrapper['store'].isEmergencyModalAlreadyDisplayed = true; + + act(() => { + onSpy.mock.calls[1][1]({}); + }); + + act(() => { + const logOutCb = onSpy.mock.calls.find((call) => call[0] === CC_EVENTS.AGENT_LOGOUT_SUCCESS)[1]; + logOutCb({}); + }); + + expect(storeWrapper['store'].showE911Modal).toBe(false); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(false); + }); + it('should handle task rejection event and call onTaskRejected with the provided reason', () => { const rejectTask = makeMockTask({ data: {interactionId: 'rejectTest', interaction: {state: 'connected'}}, @@ -2543,4 +2576,285 @@ describe('storeEventsWrapper', () => { }); }); }); + + describe('E911 Modal Methods', () => { + describe('setShowE911Modal', () => { + it('should set showE911Modal to true', () => { + storeWrapper.setShowE911Modal(true); + expect(storeWrapper['store'].showE911Modal).toBe(true); + }); + + it('should set showE911Modal to false', () => { + storeWrapper.setShowE911Modal(false); + expect(storeWrapper['store'].showE911Modal).toBe(false); + }); + }); + + describe('setIsEmergencyModalAlreadyDisplayed', () => { + it('should set isEmergencyModalAlreadyDisplayed to true', () => { + storeWrapper.setIsEmergencyModalAlreadyDisplayed(true); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(true); + }); + + it('should set isEmergencyModalAlreadyDisplayed to false', () => { + storeWrapper.setIsEmergencyModalAlreadyDisplayed(false); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(false); + }); + }); + + describe('fetchUserPreferences', () => { + it('should throw and warn if userPreference service is not available', async () => { + storeWrapper['store'].cc.userPreference = undefined; + + await expect(storeWrapper.fetchUserPreferences()).rejects.toThrow('userPreference service not available'); + + expect(storeWrapper['store'].logger.warn).toHaveBeenCalledWith( + 'CC-Widgets: fetchUserPreferences(): userPreference service not available', + expect.any(Object) + ); + }); + + it('should fetch and parse user preferences successfully', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({ + preferences: {desktopPreference: JSON.stringify({isEmergencyModalAlreadyDisplayed: true})}, + }), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await storeWrapper.fetchUserPreferences(); + + expect(mockUserPreference.getUserPreference).toHaveBeenCalled(); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(true); + }); + + it('should handle empty desktopPreference', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({ + preferences: {desktopPreference: null}, + }), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await storeWrapper.fetchUserPreferences(); + + expect(mockUserPreference.getUserPreference).toHaveBeenCalled(); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(false); + }); + + it('should reset isEmergencyModalAlreadyDisplayed to false when desktopPreference is missing, even if previously true', async () => { + storeWrapper['store'].isEmergencyModalAlreadyDisplayed = true; + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({ + preferences: {desktopPreference: null}, + }), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await storeWrapper.fetchUserPreferences(); + + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(false); + }); + + it('should handle parse error gracefully', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({ + preferences: {desktopPreference: 'invalid-json'}, + }), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await storeWrapper.fetchUserPreferences(); + + expect(storeWrapper['store'].logger.error).toHaveBeenCalledWith( + 'CC-Widgets: fetchUserPreferences(): failed to parse desktopPreference', + expect.any(Object) + ); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(false); + }); + + it('should throw error on API failure', async () => { + const mockError = new Error('API Error'); + const mockUserPreference = { + getUserPreference: jest.fn().mockRejectedValue(mockError), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await expect(storeWrapper.fetchUserPreferences()).rejects.toThrow('API Error'); + }); + + it('should treat a missing (404) preference record as not-yet-acknowledged instead of throwing', async () => { + const notFoundError = Object.assign(new Error('Not Found'), {statusCode: 404}); + const mockUserPreference = { + getUserPreference: jest.fn().mockRejectedValue(notFoundError), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + storeWrapper['store'].isEmergencyModalAlreadyDisplayed = true; + const errorCallCountBefore = (storeWrapper['store'].logger.error as jest.Mock).mock.calls.length; + + await expect(storeWrapper.fetchUserPreferences()).resolves.toBeUndefined(); + + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(false); + expect((storeWrapper['store'].logger.error as jest.Mock).mock.calls.length).toBe(errorCallCountBefore); + }); + }); + + describe('updateEmergencyModalAcknowledgment', () => { + it('should throw and warn if userPreference service is not available', async () => { + storeWrapper['store'].cc.userPreference = undefined; + + await expect(storeWrapper.updateEmergencyModalAcknowledgment()).rejects.toThrow( + 'userPreference service not available' + ); + + expect(storeWrapper['store'].logger.warn).toHaveBeenCalledWith( + 'CC-Widgets: updateEmergencyModalAcknowledgment(): userPreference service not available', + expect.any(Object) + ); + }); + + it('should update user preferences and store state successfully', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({userId: 'test-preference-user-id'}), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn().mockResolvedValue({}), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + // agentId (CC identifier) intentionally differs from the preference service's userId + storeWrapper['store'].agentId = 'test-agent-id'; + + await storeWrapper.updateEmergencyModalAcknowledgment(); + + expect(mockUserPreference.updateUserPreference).toHaveBeenCalledWith('test-preference-user-id', { + desktopPreference: JSON.stringify({isEmergencyModalAlreadyDisplayed: true}), + }); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(true); + expect(storeWrapper['store'].showE911Modal).toBe(false); + }); + + it('should merge the E911 flag into existing desktopPreference instead of overwriting it', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({ + userId: 'test-preference-user-id', + preferences: {desktopPreference: JSON.stringify({someOtherSetting: 'value'})}, + }), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn().mockResolvedValue({}), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + storeWrapper['store'].agentId = 'test-agent-id'; + + await storeWrapper.updateEmergencyModalAcknowledgment(); + + expect(mockUserPreference.updateUserPreference).toHaveBeenCalledWith('test-preference-user-id', { + desktopPreference: JSON.stringify({someOtherSetting: 'value', isEmergencyModalAlreadyDisplayed: true}), + }); + }); + + it('should handle unparsable existing desktopPreference gracefully when merging', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({ + userId: 'test-preference-user-id', + preferences: {desktopPreference: 'invalid-json'}, + }), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn().mockResolvedValue({}), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + storeWrapper['store'].agentId = 'test-agent-id'; + + await storeWrapper.updateEmergencyModalAcknowledgment(); + + expect(storeWrapper['store'].logger.error).toHaveBeenCalledWith( + 'CC-Widgets: updateEmergencyModalAcknowledgment(): failed to parse existing desktopPreference', + expect.any(Object) + ); + expect(mockUserPreference.updateUserPreference).toHaveBeenCalledWith('test-preference-user-id', { + desktopPreference: JSON.stringify({isEmergencyModalAlreadyDisplayed: true}), + }); + }); + + it('should throw error on API failure', async () => { + const mockError = new Error('Update Error'); + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({userId: 'test-preference-user-id'}), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn().mockRejectedValue(mockError), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await expect(storeWrapper.updateEmergencyModalAcknowledgment()).rejects.toThrow('Update Error'); + }); + + it('should use the preference service userId, not the CC agentId, when updating', async () => { + const mockUserPreference = { + getUserPreference: jest.fn().mockResolvedValue({userId: 'preference-user-id'}), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn().mockResolvedValue({}), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + storeWrapper['store'].agentId = 'cc-agent-id'; + + await storeWrapper.updateEmergencyModalAcknowledgment(); + + expect(mockUserPreference.updateUserPreference).toHaveBeenCalledWith('preference-user-id', expect.any(Object)); + expect(mockUserPreference.updateUserPreference).not.toHaveBeenCalledWith('cc-agent-id', expect.any(Object)); + }); + + it('should create a new preference record using the CI user id (not the CC agentId) when the user has none yet (404 on getUserPreference)', async () => { + const notFoundError = Object.assign(new Error('Not Found'), {statusCode: 404}); + const mockUserPreference = { + getUserPreference: jest.fn().mockRejectedValue(notFoundError), + createUserPreference: jest.fn().mockResolvedValue({}), + updateUserPreference: jest.fn().mockResolvedValue({}), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + // agentId (CC identifier) intentionally differs from the CI user id used by the + // preference service - createUserPreference must use the latter. + storeWrapper['store'].agentId = 'first-time-agent-id'; + // @ts-expect-error - webex internal device API not typed on IContactCenter + storeWrapper['store'].cc.webex = {internal: {device: {userId: 'first-time-ci-user-id'}}}; + + await storeWrapper.updateEmergencyModalAcknowledgment(); + + expect(mockUserPreference.createUserPreference).toHaveBeenCalledWith({ + userId: 'first-time-ci-user-id', + desktopPreference: JSON.stringify({isEmergencyModalAlreadyDisplayed: true}), + }); + expect(mockUserPreference.createUserPreference).not.toHaveBeenCalledWith( + expect.objectContaining({userId: 'first-time-agent-id'}) + ); + expect(mockUserPreference.updateUserPreference).not.toHaveBeenCalled(); + expect(storeWrapper['store'].isEmergencyModalAlreadyDisplayed).toBe(true); + expect(storeWrapper['store'].showE911Modal).toBe(false); + }); + + it('should rethrow non-404 errors from getUserPreference without creating a record', async () => { + const mockError = new Error('Server Error'); + const mockUserPreference = { + getUserPreference: jest.fn().mockRejectedValue(mockError), + createUserPreference: jest.fn(), + updateUserPreference: jest.fn(), + }; + storeWrapper['store'].cc.userPreference = mockUserPreference; + + await expect(storeWrapper.updateEmergencyModalAcknowledgment()).rejects.toThrow('Server Error'); + + expect(mockUserPreference.createUserPreference).not.toHaveBeenCalled(); + expect(mockUserPreference.updateUserPreference).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/contact-center/store/tests/util.ts b/packages/contact-center/store/tests/util.ts index b25d9804a..0aed0e8fa 100644 --- a/packages/contact-center/store/tests/util.ts +++ b/packages/contact-center/store/tests/util.ts @@ -9,6 +9,7 @@ describe('getFeatureFlags', () => { agentPersonalStatsEnabled: true, webRtcEnabled: true, allowConsultToQueue: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, isOutboundEnabledForAgent: false, isOutboundEnabledForTenant: false, diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 7013b4f7c..97b8565eb 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -204,9 +204,7 @@ function App() { const mediaType = task?.data?.interaction?.mediaType; const isSocial = mediaType === 'social'; const title = isSocial ? callAssociatedDetails?.customerName : callAssociatedDetails?.ani; - console.log( - `onTaskSelected invoked for task with title : ${title}, and mediaType : ${mediaType}` - ); + console.log(`onTaskSelected invoked for task with title : ${title}, and mediaType : ${mediaType}`); }; const onHoldResume = ({isHeld, task}) => { @@ -742,10 +740,9 @@ function App() { }} > Note: Disabling WebRTC registration prevents browser-based calling. When - enabled, the Incoming Task, Task List, Call Control, and Call Control with CAD - widgets will be unchecked and disabled because they depend on call handling. Set this - option before clicking the Init Widgets button - changes after SDK initialization will - not take effect. + enabled, the Incoming Task, Task List, Call Control, and Call Control with CAD widgets will + be unchecked and disabled because they depend on call handling. Set this option before + clicking the Init Widgets button - changes after SDK initialization will not take effect. @@ -906,6 +903,14 @@ function App() { allowInternationalDn={allowInternationalDn} /> +
+ +