From 679b10190047793d4acd5d2fcbd996708a25dff7 Mon Sep 17 00:00:00 2001 From: Rankush Kumar Date: Thu, 11 Dec 2025 16:58:13 +0530 Subject: [PATCH 1/2] docs(ai-docs): add patterns documentation - TypeScript, React, MobX, Web Components, Testing --- ai-docs/patterns/mobx-patterns.md | 740 ++++++++++++++++++ ai-docs/patterns/react-patterns.md | 827 +++++++++++++++++++++ ai-docs/patterns/testing-patterns.md | 806 ++++++++++++++++++++ ai-docs/patterns/typescript-patterns.md | 519 +++++++++++++ ai-docs/patterns/web-component-patterns.md | 618 +++++++++++++++ 5 files changed, 3510 insertions(+) create mode 100644 ai-docs/patterns/mobx-patterns.md create mode 100644 ai-docs/patterns/react-patterns.md create mode 100644 ai-docs/patterns/testing-patterns.md create mode 100644 ai-docs/patterns/typescript-patterns.md create mode 100644 ai-docs/patterns/web-component-patterns.md diff --git a/ai-docs/patterns/mobx-patterns.md b/ai-docs/patterns/mobx-patterns.md new file mode 100644 index 000000000..dc16b266b --- /dev/null +++ b/ai-docs/patterns/mobx-patterns.md @@ -0,0 +1,740 @@ +# MobX Patterns + +--- +Technology: MobX +Configuration: See [package.json](../../packages/contact-center/store/package.json) for version +Dependencies: See individual [package.json](../../packages/contact-center/*/package.json) files +Scope: Repository-wide +Last Updated: 2025-11-23 +--- + +> **For LLM Agents**: Add this file to context when working on MobX store, observables, or state management. +> +> **For Developers**: Update this file when committing MobX pattern changes. + +--- + +## Summary + +The codebase uses **MobX 6** with a **singleton store pattern** wrapped in a `StoreWrapper` class. The architecture separates core store state (`Store`) from business logic and event handling (`StoreWrapper`). Components consume the store using the `observer` HOC from `mobx-react-lite`, and state mutations are wrapped in `runInAction` for consistency. + +--- + +## Store Architecture + +### 1. **Singleton Pattern** + +**Core Store Class (`Store`):** +```typescript +class Store implements IStore { + private static instance: Store; + + constructor() { + makeAutoObservable(this, { + cc: observable.ref, + }); + } + + public static getInstance(): Store { + if (!Store.instance) { + console.log('Creating new store instance'); + Store.instance = new Store(); + } + return Store.instance; + } +} +``` + +**Pattern:** Single instance of `Store` created and shared across the application. + +--- + +### 2. **StoreWrapper Pattern** + +**Wrapper Class:** +```typescript +class StoreWrapper implements IStoreWrapper { + store: IStore; + onIncomingTask: ({task}: {task: ITask}) => void; + onTaskRejected?: (task: ITask, reason: string) => void; + onErrorCallback?: (widgetName: string, error: Error) => void; + + constructor() { + this.store = Store.getInstance(); + } + + // Proxy all properties with getters + get cc() { return this.store.cc; } + get teams() { return this.store.teams; } + // ... 20+ more getters + + // Methods that modify state + setDeviceType = (option: string): void => { + this.store.deviceType = option; + }; + + setCurrentTask = (task: ITask | null): void => { + runInAction(() => { + this.store.currentTask = task; + }); + }; +} + +const storeWrapper = new StoreWrapper(); +export default storeWrapper; +``` + +**Purpose:** +- **Proxy pattern**: Wraps core `Store` with computed getters and business logic +- **Event handlers**: Manages SDK event listeners and callbacks +- **Filtered data**: Transforms store data (e.g., filtering idle codes) +- **Single export**: `@webex/cc-store` exports the wrapper instance, not the raw store + +--- + +## MobX Observable Patterns + +### 1. **makeAutoObservable** + +**Convention:** Use `makeAutoObservable` for automatic observability + +```typescript +constructor() { + makeAutoObservable(this, { + cc: observable.ref, + }); +} +``` + +**Special handling:** +- `cc: observable.ref` - Contact center SDK instance treated as reference (not deep observable) +- All other properties automatically made observable +- All methods automatically made actions + +--- + +### 2. **Observable Properties** + +**Direct assignment for simple properties:** +```typescript +class Store { + teams: Team[] = []; + loginOptions: string[] = []; + agentId: string = ''; + currentTheme: string = 'LIGHT'; + isAgentLoggedIn = false; + deviceType: string = ''; + dialNumber: string = ''; + currentState: string = ''; + customState: ICustomState = null; + taskList: Record = {}; + featureFlags: {[key: string]: boolean} = {}; + // ... 20+ more observables +} +``` + +**Pattern:** All class properties are observable by default when using `makeAutoObservable`. + +--- + +### 3. **runInAction for Mutations** + +**Pattern:** Wrap state mutations in `runInAction` for batched updates + +**Simple setters:** +```typescript +setDeviceType = (option: string): void => { + this.store.deviceType = option; // Direct mutation (auto-action) +}; +``` + +**Complex mutations:** +```typescript +setCurrentTask = (task: ITask | null, isClicked: boolean = false): void => { + runInAction(() => { + let isSameTask = false; + if (task && this.currentTask) { + isSameTask = task.data.interactionId === this.currentTask.data.interactionId; + } + + this.store.currentTask = task ? + Object.assign(Object.create(Object.getPrototypeOf(task)), task) : null; + + if (this.onTaskSelected && !isSameTask && typeof isClicked !== 'undefined') { + this.onTaskSelected(task, isClicked); + } + }); +}; +``` + +**Guideline:** +- **Simple setters** (single property): Direct mutation is fine with `makeAutoObservable` +- **Complex logic** (multiple properties, conditionals): Use `runInAction` +- **Event handlers**: Always use `runInAction` for consistency + +--- + +### 4. **Computed Values (via Getters)** + +**Pattern:** Use getters in `StoreWrapper` to transform/filter store data + +```typescript +get idleCodes() { + return this.store.idleCodes.filter((code) => { + return Object.values(ERROR_TRIGGERING_IDLE_CODES).includes(code.name) || + !code.isSystem; + }); +} +``` + +**Convention:** Getters in `StoreWrapper` act as computed values (automatically tracked by MobX). + +--- + +## Observer Pattern + +### 1. **observer HOC from mobx-react-lite** + +**Pattern:** Wrap functional components with `observer` to track observables + +```typescript +import {observer} from 'mobx-react-lite'; +import store from '@webex/cc-store'; + +const StationLoginInternal: React.FunctionComponent = observer( + ({onLogin, onLogout, profileMode}) => { + const { + cc, + teams, + loginOptions, + logger, + isAgentLoggedIn, + deviceType, + dialNumber, + setDeviceType, + setDialNumber, + } = store; + + return ; + } +); +``` + +**Convention:** +- Import store singleton at top of file +- Destructure needed properties inside observer component +- Component auto-rerenders when used observables change + +--- + +### 2. **Two-Layer Component Pattern** + +**Pattern:** Split components into Internal (observer) + Wrapper (ErrorBoundary) + +```typescript +// Internal component with observer +const StationLoginInternal: React.FunctionComponent = observer( + ({onLogin, onLogout, onCCSignOut, profileMode}) => { + const {cc, teams, loginOptions, logger, isAgentLoggedIn} = store; + // ... component logic + return ; + } +); + +// Wrapper component with ErrorBoundary +const StationLogin: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('StationLogin', error); + }} + > + + + ); +}; + +export {StationLogin}; +``` + +**Purpose:** +- **Internal**: Handles MobX reactivity +- **Wrapper**: Handles error boundaries +- **Benefit**: Error boundary doesn't need to be an observer + +--- + +## Action Patterns + +### 1. **Simple Setters** + +**Pattern:** Arrow functions for simple mutations + +```typescript +setDeviceType = (option: string): void => { + this.store.deviceType = option; +}; + +setDialNumber = (input: string): void => { + this.store.dialNumber = input; +}; + +setShowMultipleLoginAlert = (value: boolean): void => { + this.store.showMultipleLoginAlert = value; +}; +``` + +--- + +### 2. **Complex Actions with runInAction** + +**Pattern:** Group related mutations in `runInAction` + +```typescript +refreshTaskList = (): void => { + runInAction(() => { + this.store.taskList = this.store.cc.taskManager.getAllTasks(); + const taskListKeys = Object.keys(this.store.taskList); + + if (taskListKeys.length === 0) { + if (this.currentTask) { + this.handleTaskRemove(this.currentTask); + } + this.setCurrentTask(null); + this.setState({reset: true}); + } else if (this.currentTask && this.store.taskList[this.currentTask.data.interactionId]) { + this.setCurrentTask(this.store.taskList[this.currentTask?.data?.interactionId]); + } else if (taskListKeys.length > 0) { + if (this.currentTask) { + this.handleTaskRemove(this.currentTask); + } + this.setCurrentTask(this.store.taskList[taskListKeys[0]]); + } + }); +}; +``` + +--- + +### 3. **Async Actions** + +**Pattern:** Promises with `runInAction` in `.then()` or use `runInAction` inside async functions + +```typescript +registerCC(webex?: WithWebex['webex']): Promise { + // ... validation + + return this.cc + .register() + .then((response: Profile) => { + // Implicit action from makeAutoObservable + this.featureFlags = getFeatureFlags(response); + this.teams = response.teams; + this.loginOptions = response.webRtcEnabled + ? response.loginVoiceOptions + : response.loginVoiceOptions.filter((option) => option !== 'BROWSER'); + this.agentId = response.agentId; + this.isAgentLoggedIn = response.isAgentLoggedIn; + // ... more assignments + }) + .catch((error) => { + this.logger.error(`Registration failed - ${error}`); + return Promise.reject(error); + }); +} +``` + +**Note:** With `makeAutoObservable`, mutations inside `then()` are automatically wrapped as actions. However, for clarity in event handlers, prefer explicit `runInAction`. + +--- + +## Event Handling Patterns + +### 1. **SDK Event Listeners** + +**Pattern:** Register event listeners in `setupIncomingTaskHandler` + +```typescript +setupIncomingTaskHandler = (ccSDK: IContactCenter) => { + const addEventListeners = () => { + ccSDK.on(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + ccSDK.on(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); + ccSDK.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); + ccSDK.on(TASK_EVENTS.TASK_MERGED, this.handleTaskMerged); + ccSDK.on(CC_EVENTS.AGENT_MULTI_LOGIN, this.handleMultiLoginCloseSession); + ccSDK.on(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogOut); + }; + + const removeEventListeners = () => { + ccSDK.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + ccSDK.off(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); + // ... more cleanup + }; +}; +``` + +**Pattern:** +- Define `addEventListeners` and `removeEventListeners` functions +- Register event handlers as class methods (arrow functions for `this` binding) +- Always provide cleanup (remove listeners) + +--- + +### 2. **Task Event Listeners** + +**Pattern:** Register task-specific events with `registerTaskEventListeners` + +```typescript +private registerTaskEventListeners = (task: ITask): void => { + task.on(TASK_EVENTS.TASK_END, this.handleTaskEnd); + task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); + task.on(TASK_EVENTS.TASK_REJECT, (reason) => this.handleTaskReject(task, reason)); + task.on(TASK_EVENTS.AGENT_WRAPPEDUP, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); + // ... 20+ more task events + + if (this.deviceType === DEVICE_TYPE_BROWSER) { + task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); + } +}; +``` + +**Cleanup pattern:** +```typescript +handleTaskRemove = (taskToRemove: ITask) => { + if (taskToRemove) { + taskToRemove.off(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); + taskToRemove.off(TASK_EVENTS.TASK_END, this.handleTaskEnd); + // ... remove all listeners + } + runInAction(() => { + this.setCurrentTask(null); + this.setState({reset: true}); + this.refreshTaskList(); + }); +}; +``` + +--- + +### 3. **Event Handlers Update Store** + +**Pattern:** Event handlers modify store state using `runInAction` + +```typescript +handleTaskAssigned = (event) => { + const task = event; + if (this.onTaskAssigned) { + this.onTaskAssigned(task); + } + runInAction(() => { + this.setCurrentTask(task); + this.setState({ + developerName: ENGAGED_LABEL, + name: ENGAGED_USERNAME, + }); + }); +}; + +handleStateChange = (data) => { + if (data && typeof data === 'object' && data.type === 'AgentStateChangeSuccess') { + const DEFAULT_CODE = '0'; + this.setCurrentState(data.auxCodeId?.trim() !== '' ? data.auxCodeId : DEFAULT_CODE); + this.setLastStateChangeTimestamp(data.lastStateChangeTimestamp); + this.setLastIdleCodeChangeTimestamp(data.lastIdleCodeChangeTimestamp); + } +}; +``` + +--- + +## Store Initialization Pattern + +### 1. **Two-Step Initialization** + +**Pattern:** `init()` → `registerCC()` + +```typescript +init(options: InitParams): Promise { + return this.store.init(options, this.setupIncomingTaskHandler); +} + +// In Store class: +init(options: InitParams, setupEventListeners): Promise { + if ('webex' in options) { + setupEventListeners(options.webex.cc); + return this.registerCC(options.webex); + } + + return new Promise((resolve, reject) => { + const webex = Webex.init({ + config: options.webexConfig, + credentials: { access_token: options.access_token }, + }); + + webex.once('ready', () => { + setupEventListeners(webex.cc); + this.registerCC(webex) + .then(() => resolve()) + .catch((error) => reject(error)); + }); + }); +} +``` + +**Flow:** +1. Consumer calls `store.init(options)` +2. Store initializes SDK (if needed) +3. `setupEventListeners` registers SDK event handlers +4. `registerCC()` fetches agent profile and populates store +5. Promise resolves when store is ready + +--- + +### 2. **Callback Registration Pattern** + +**Pattern:** Store exposes callback setters for widget events + +```typescript +setIncomingTaskCb = (callback: ({task}: {task: ITask}) => void): void => { + this.onIncomingTask = callback; +}; + +setTaskRejected = (callback: ((task: ITask, reason: string) => void) | undefined): void => { + this.onTaskRejected = callback; +}; + +setOnError = (callback: (widgetName: string, error: Error) => void) => { + this.onErrorCallback = callback; +}; +``` + +**Usage:** +- Widgets register callbacks to be notified of events +- Store invokes callbacks when events occur +- Pattern similar to event emitters + +--- + +## Store Usage in Components + +### 1. **Import and Destructure** + +```typescript +import store from '@webex/cc-store'; + +const UserStateInternal: React.FunctionComponent = observer( + ({onStateChange}) => { + const { + cc, + idleCodes, + agentId, + currentState, + lastStateChangeTimestamp, + customState, + logger, + } = store; + + // Component logic + } +); +``` + +--- + +### 2. **Pass to Custom Hooks** + +```typescript +const UserStateInternal: React.FunctionComponent = observer( + ({onStateChange}) => { + const { + cc, + idleCodes, + agentId, + currentState, + customState, + lastStateChangeTimestamp, + logger, + lastIdleCodeChangeTimestamp, + } = store; + + const props = { + ...useUserState({ + idleCodes, + agentId, + cc, + currentState, + customState, + lastStateChangeTimestamp, + logger, + onStateChange, + lastIdleCodeChangeTimestamp, + }), + customState, + logger, + }; + + return ; + } +); +``` + +**Pattern:** +- Observer component extracts store values +- Passes to custom hook for business logic +- Hook returns computed values/handlers +- Component renders with combined props + +--- + +### 3. **Store Mutations from Hooks** + +**Pattern:** Hooks can directly call store setters + +```typescript +// In helper.ts (custom hook) +import store from '@webex/cc-store'; + +export const useUserState = ({currentState, logger, ...}) => { + const setAgentStatus = (selectedCode) => { + logger.info('Updating currentState'); + store.setCurrentState(selectedCode); // Direct store mutation + }; + + const updateAgentState = (selectedCode) => { + // ... business logic + return cc.setAgentState({...}) + .then((response) => { + store.setLastStateChangeTimestamp(response.data.lastStateChangeTimestamp); + store.setLastIdleCodeChangeTimestamp(response.data.lastIdleCodeChangeTimestamp); + }); + }; + + return { setAgentStatus, isSettingAgentStatus, elapsedTime }; +}; +``` + +**Guideline:** Hooks can call store setters, but should receive store values as props (not import store directly in hook for testability). + +--- + +## Key Conventions to Enforce + +### ✅ DO: +1. **Use `makeAutoObservable`** in Store constructor with minimal overrides +2. **Use `observable.ref`** for SDK instances and external objects +3. **Wrap complex mutations** in `runInAction` for batched updates +4. **Use `observer` HOC** for all components that read store state +5. **Destructure store** at the top of observer components +6. **Use arrow functions** for store methods to preserve `this` context +7. **Register and cleanup** SDK event listeners properly +8. **Use singleton pattern** for store (single instance) +9. **Export store wrapper** instance, not the class +10. **Separate Internal (observer) and Wrapper (ErrorBoundary)** components + +### ❌ DON'T: +1. **Don't mutate store outside of actions** when using `runInAction` +2. **Don't use makeObservable** - prefer `makeAutoObservable` +3. **Don't make SDK objects deeply observable** - use `observable.ref` +4. **Don't forget to remove event listeners** in cleanup +5. **Don't import store in non-observer components** (only in observer components) +6. **Don't use `@observable` decorators** - use `makeAutoObservable` instead +7. **Don't create multiple store instances** - singleton only + +--- + +## Anti-Patterns Found + +### 1. **Inconsistent runInAction usage** +Some simple setters use direct mutation, others use `runInAction`. With `makeAutoObservable`, both work, but consistency would improve readability. + +**Recommendation:** Document when to use `runInAction` vs direct mutation. + +--- + +### 2. **Deep task cloning in setCurrentTask** +```typescript +this.store.currentTask = task ? + Object.assign(Object.create(Object.getPrototypeOf(task)), task) : null; +``` + +**Reason:** Preserving task prototype methods while creating observable copy. +**Recommendation:** Document this pattern for objects with methods. + +--- + +## Examples to Reference + +### Example 1: Store Singleton with makeAutoObservable +```typescript +class Store implements IStore { + private static instance: Store; + teams: Team[] = []; + isAgentLoggedIn = false; + + constructor() { + makeAutoObservable(this, { + cc: observable.ref, + }); + } + + public static getInstance(): Store { + if (!Store.instance) { + Store.instance = new Store(); + } + return Store.instance; + } +} +``` + +### Example 2: Observer Component with Store +```typescript +import {observer} from 'mobx-react-lite'; +import store from '@webex/cc-store'; + +const MyWidget = observer(({onEvent}) => { + const {cc, logger, currentState, setCurrentState} = store; + + return
setCurrentState('Available')}> + Current: {currentState} +
; +}); +``` + +### Example 3: Event Handler with runInAction +```typescript +handleTaskAssigned = (event) => { + const task = event; + runInAction(() => { + this.setCurrentTask(task); + this.setState({ + developerName: ENGAGED_LABEL, + name: ENGAGED_USERNAME, + }); + }); +}; +``` + +--- + +## Files Analyzed + +1. `/packages/contact-center/store/src/store.ts` (167 lines) +2. `/packages/contact-center/store/src/storeEventsWrapper.ts` (819 lines) +3. `/packages/contact-center/store/src/index.ts` (5 lines) +4. `/packages/contact-center/station-login/src/station-login/index.tsx` (77 lines) +5. `/packages/contact-center/user-state/src/user-state/index.tsx` (52 lines) +6. `/packages/contact-center/user-state/src/helper.ts` (296 lines) +7. `/packages/contact-center/task/src/IncomingTask/index.tsx` +8. `/packages/contact-center/task/src/TaskList/index.tsx` +9. `/packages/contact-center/task/src/CallControl/index.tsx` + +--- + +## Related Documentation + +- [TypeScript Patterns](./typescript-patterns.md) - Store type definitions +- [React Patterns](./react-patterns.md) - Observer components +- [Testing Patterns](./testing-patterns.md) - Mocking MobX store + diff --git a/ai-docs/patterns/react-patterns.md b/ai-docs/patterns/react-patterns.md new file mode 100644 index 000000000..e04d588a3 --- /dev/null +++ b/ai-docs/patterns/react-patterns.md @@ -0,0 +1,827 @@ +# React Patterns + +--- +Technology: React +Configuration: See [package.json](../../packages/contact-center/*/package.json) for version +Dependencies: See individual [package.json](../../packages/contact-center/*/package.json) files +Scope: Repository-wide +Last Updated: 2025-11-23 +--- + +> **For LLM Agents**: Add this file to context when working on React components, hooks, or component composition. +> +> **For Developers**: Update this file when committing React pattern changes. + +--- + +## Summary + +The codebase uses **React 18+ functional components** with **hooks** exclusively. The architecture follows a **three-layer pattern**: Widget components (MobX observers) → Custom hooks (business logic) → Presentational components (cc-components). Every widget is wrapped in `ErrorBoundary` from `react-error-boundary` with telemetry reporting. Custom hooks encapsulate SDK interactions, event listeners, and state management. + +--- + +## Component Architecture + +### 1. **Three-Layer Component Pattern** + +**Layer 1: Widget Components (Observers)** +- Located in widget packages (`station-login`, `user-state`, `task/*`) +- Import and observe store state +- Wrapped with `ErrorBoundary` +- Minimal logic, delegate to custom hooks + +**Layer 2: Custom Hooks** +- Located in `helper.ts` files in each widget package +- Encapsulate business logic, SDK calls, event listeners +- Manage local state with `useState`, `useRef` +- Return handlers and computed values + +**Layer 3: Presentational Components** +- Located in `cc-components` package +- Pure UI rendering with props +- No store access, no SDK interactions +- Reusable across widgets + +--- + +## Error Boundary Pattern + +### **Standard Error Boundary Wrapper** + +**Every widget follows this exact pattern:** + +```typescript +import {ErrorBoundary} from 'react-error-boundary'; +import store from '@webex/cc-store'; + +// Internal observer component +const WidgetInternal: React.FunctionComponent = observer((props) => { + // Widget logic +}); + +// External wrapper with ErrorBoundary +const Widget: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('WidgetName', error); + }} + > + + + ); +}; + +export {Widget}; +``` + +**Key elements:** +1. **Two-component split**: `WidgetInternal` (observer) + `Widget` (wrapper) +2. **Empty fallback**: `fallbackRender={() => <>}` - fails gracefully with no UI +3. **Error telemetry**: `store.onErrorCallback('WidgetName', error)` - reports to metrics +4. **Conditional callback**: `if (store.onErrorCallback)` - only call if registered + +**Benefits:** +- Isolates errors to individual widgets +- Prevents entire app crashes +- Reports errors for debugging/analytics +- Clean separation between observer and error handling + +--- + +## Observer Pattern + +### **MobX Observer Usage** + +```typescript +import {observer} from 'mobx-react-lite'; +import store from '@webex/cc-store'; + +const StationLoginInternal: React.FunctionComponent = observer( + ({onLogin, onLogout, onCCSignOut, profileMode}) => { + // 1. Destructure store values + const { + cc, + teams, + loginOptions, + logger, + isAgentLoggedIn, + deviceType, + dialNumber, + setDeviceType, + setDialNumber, + teamId, + setTeamId, + } = store; + + // 2. Call custom hook with store values + props + const result = useStationLogin({ + cc, + onLogin, + onLogout, + logger, + deviceType, + dialNumber, + teamId, + isAgentLoggedIn, + onCCSignOut, + }); + + // 3. Compose props from store + hook + props + const props: StationLoginComponentProps = { + ...result, + setDeviceType, + setDialNumber, + teams, + loginOptions, + deviceType, + isAgentLoggedIn, + logger, + profileMode, + }; + + // 4. Render presentational component + return ; + } +); +``` + +**Pattern breakdown:** +1. **Import store** - singleton instance from `@webex/cc-store` +2. **Wrap with observer** - automatically tracks store reads +3. **Destructure store** - only extract what's needed +4. **Pass to hook** - combine store values with props +5. **Compose final props** - merge store, hook results, and incoming props +6. **Render dumb component** - pass everything to presentational layer + +--- + +## Custom Hooks Patterns + +### **1. Event Listener Hook Pattern** + +```typescript +export const useIncomingTask = (props: UseTaskProps) => { + const {onAccepted, onRejected, deviceType, incomingTask, logger} = props; + + // Define callbacks + const taskAssignCallback = () => { + try { + if (onAccepted) onAccepted({task: incomingTask}); + } catch (error) { + logger?.error(`Error in taskAssignCallback - ${error.message}`); + } + }; + + const taskRejectCallback = () => { + try { + if (onRejected) onRejected({task: incomingTask}); + } catch (error) { + logger?.error(`Error in taskRejectCallback - ${error.message}`); + } + }; + + // Register event listeners on mount + useEffect(() => { + try { + if (!incomingTask) return; + + // Register listeners + store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask.data.interactionId); + + // Cleanup on unmount + return () => { + try { + store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask.data.interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask.data.interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask.data.interactionId); + } catch (error) { + logger?.error(`Error in cleanup - ${error.message}`); + } + }; + } catch (error) { + logger?.error(`Error in useIncomingTask useEffect - ${error.message}`); + } + }, [incomingTask]); + + // Return handlers + const accept = () => { + try { + if (!incomingTask?.data.interactionId) return; + incomingTask.accept().catch((error) => { + logger.error(`Error accepting task: ${error}`); + }); + } catch (error) { + logger?.error(`Error in accept - ${error.message}`); + } + }; + + return { incomingTask, accept, reject }; +}; +``` + +**Key patterns:** +- **Event listener registration** in `useEffect` +- **Cleanup function** to remove listeners on unmount +- **Dependency array** `[incomingTask]` - re-register when task changes +- **Try-catch everywhere** - defensive error handling +- **Logger context** - every log includes module + method +- **Return handlers** - expose actions to component + +--- + +### **2. Web Worker Hook Pattern** + +```typescript +export const useUserState = ({currentState, lastStateChangeTimestamp, logger, ...}) => { + const [elapsedTime, setElapsedTime] = useState(0); + const workerRef = useRef(null); + + // Define worker script inline + const workerScript = ` + let intervalId; + const startTimer = (startTime) => { + if (intervalId) clearInterval(intervalId); + intervalId = setInterval(() => { + const elapsedTime = Math.floor((Date.now() - startTime) / 1000); + self.postMessage({type: 'elapsedTime', elapsedTime}); + }, 1000); + }; + const stopTimer = () => { + if (intervalId) clearInterval(intervalId); + self.postMessage({type: 'stop'}); + }; + self.onmessage = (event) => { + if (event.data.type === 'start') { + startTimer(event.data.startTime); + } + if (event.data.type === 'stop') { + stopTimer(); + } + }; + `; + + // Initialize worker + useEffect(() => { + try { + const blob = new Blob([workerScript], {type: 'application/javascript'}); + const workerUrl = URL.createObjectURL(blob); + workerRef.current = new Worker(workerUrl); + + workerRef.current.postMessage({type: 'start', startTime: Date.now()}); + + workerRef.current.onmessage = (event) => { + if (event.data.type === 'elapsedTime') { + setElapsedTime(event.data.elapsedTime > 0 ? event.data.elapsedTime : 0); + } + }; + } catch (error) { + logger?.error(`Error initializing worker - ${error.message}`); + } + + // Cleanup worker on unmount + return () => { + try { + if (workerRef.current) { + workerRef.current.postMessage({type: 'stop'}); + workerRef.current.terminate(); + workerRef.current = null; + } + } catch (error) { + logger?.error(`Error in cleanup - ${error.message}`); + } + }; + }, []); + + // Reset timer when timestamp changes + useEffect(() => { + try { + if (workerRef.current && lastStateChangeTimestamp) { + workerRef.current.postMessage({type: 'reset', startTime: lastStateChangeTimestamp}); + } + } catch (error) { + logger?.error(`Error in timestamp useEffect - ${error.message}`); + } + }, [lastStateChangeTimestamp]); + + return { elapsedTime }; +}; +``` + +**Key patterns:** +- **Inline worker script** - defined as string template +- **Blob + Object URL** - create worker from script +- **useRef for worker** - persist across renders +- **Message-based communication** - `postMessage` / `onmessage` +- **Cleanup termination** - terminate worker on unmount +- **Multiple useEffects** - separate concerns (init vs. reset) + +--- + +### **3. Callback Hook Pattern** + +```typescript +export const useCallControl = (props: useCallControlProps) => { + const {currentTask, onHoldResume, onEnd, logger, ...} = props; + + // Define callbacks that invoke prop callbacks + const holdCallback = () => { + try { + if (onHoldResume) { + onHoldResume({ + isHeld: true, + task: currentTask, + }); + } + } catch (error) { + logger?.error(`Error in holdCallback - ${error.message}`); + } + }; + + const endCallCallback = () => { + try { + if (onEnd) { + onEnd({ task: currentTask }); + } + } catch (error) { + logger?.error(`Error in endCallCallback - ${error.message}`); + } + }; + + // Register task event listeners + useEffect(() => { + if (!currentTask?.data?.interactionId) return; + + const interactionId = currentTask.data.interactionId; + + store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); + + return () => { + store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); + }; + }, [currentTask]); + + // Return action handlers + const toggleHold = (hold: boolean) => { + try { + if (hold) { + currentTask.hold().catch((e) => logger.error(`Hold failed: ${e}`)); + } else { + currentTask.resume().catch((e) => logger.error(`Resume failed: ${e}`)); + } + } catch (error) { + logger?.error(`Error in toggleHold - ${error.message}`); + } + }; + + return { toggleHold }; +}; +``` + +**Pattern:** +- **Callback wrappers** - internal callbacks invoke props callbacks +- **Event-driven callbacks** - registered as task event listeners +- **Action handlers** - returned to component for user interactions +- **SDK call patterns** - always `.catch()` to handle errors + +--- + +### **4. useCallback and useMemo** + +```typescript +export const useCallControl = (props) => { + const {deviceType, featureFlags, currentTask, logger} = props; + const [buddyAgents, setBuddyAgents] = useState([]); + + // Memoized callback with dependencies + const loadBuddyAgents = useCallback(async () => { + try { + const agents = await store.getBuddyAgents(); + logger.info(`Loaded ${agents.length} buddy agents`); + setBuddyAgents(agents); + } catch (error) { + logger?.error(`Error loading buddy agents - ${error.message || error}`); + setBuddyAgents([]); + } + }, [logger]); + + const getEntryPoints = useCallback( + async ({page, pageSize, search}: PaginatedListParams) => { + try { + return await store.getEntryPoints({page, pageSize, search}); + } catch (error) { + logger?.error(`Error fetching entry points - ${error.message || error}`); + return {data: [], meta: {page: 0, totalPages: 0}}; + } + }, + [logger] + ); + + // Memoized computed value + const controlVisibility = useMemo( + () => getControlsVisibility(deviceType, featureFlags, currentTask, logger), + [deviceType, featureFlags, currentTask, logger] + ); + + return { loadBuddyAgents, getEntryPoints, controlVisibility }; +}; +``` + +**Pattern:** +- **useCallback** for async functions passed as props +- **useMemo** for expensive computations +- **Dependency arrays** carefully maintained +- **Error handling** in every async callback + +--- + +### **5. State Management with useRef** + +```typescript +export const useUserState = ({currentState, ...}) => { + const prevStateRef = useRef(currentState); + + useEffect(() => { + try { + if (prevStateRef.current !== currentState) { + // State changed, perform action + updateAgentState(currentState) + .then(() => { + prevStateRef.current = currentState; // Update ref after success + callOnStateChange(); + }) + .catch((error) => { + logger.error(`Failed to update state: ${error}`); + }); + } + } catch (error) { + logger?.error(`Error in currentState useEffect - ${error.message}`); + } + }, [currentState]); + + return { ... }; +}; +``` + +**Pattern:** +- **useRef for previous value** - detect changes +- **Update ref after success** - prevent re-triggering +- **Compare before action** - avoid unnecessary updates + +--- + +## Presentational Component Patterns + +### **1. Pure Functional Components** + +```typescript +const UserStateComponent: React.FunctionComponent = (props) => { + const { + idleCodes, + setAgentStatus, + isSettingAgentStatus, + elapsedTime, + currentState, + customState, + logger, + } = props; + + // Local computed values with useMemo + const previousSelectableState = useMemo( + () => getPreviousSelectableState(idleCodes, logger), + [idleCodes, logger] + ); + + const selectedKey = getSelectedKey(customState, currentState, idleCodes, logger); + const items = buildDropdownItems(customState, idleCodes, currentState, logger); + + return ( +
+ handleSelectionChange(key, currentState, setAgentStatus, logger)} + items={items} + > + {(item) => ( + + + {item.name} + + )} + + + {getTooltipText(customState, currentState, idleCodes, logger)} + + {formatTime(elapsedTime)} +
+ ); +}; + +export default withMetrics(UserStateComponent, 'UserState'); +``` + +**Patterns:** +- **All props passed in** - no external dependencies +- **useMemo for computations** - optimized rendering +- **Utility functions** - extracted to separate utils file +- **Data test IDs** - every element has `data-testid` +- **withMetrics HOC** - wraps component for telemetry + +--- + +### **2. withMetrics HOC** + +```typescript +import {withMetrics} from '@webex/cc-ui-logging'; + +const MyComponent: React.FunctionComponent = (props) => { + // Component implementation +}; + +export default withMetrics(MyComponent, 'ComponentName'); +``` + +**Pattern:** +- Last line of every presentational component +- Wraps component for performance/usage metrics +- Component name string for identification + +--- + +## Component Composition + +### **Standard Widget Structure** + +``` +packages/contact-center/station-login/ +├── src/ +│ ├── station-login/ +│ │ ├── index.tsx # Widget (observer + ErrorBoundary) +│ │ └── station-login.types.ts # Widget-specific types +│ ├── helper.ts # Custom hook (useStationLogin) +│ └── index.ts # Package entry (exports widget) +└── tests/ + └── station-login/ + └── index.tsx # Widget tests +``` + +**Flow:** +1. **index.tsx** - Widget component (observer wrapper) +2. **helper.ts** - Custom hook with business logic +3. **index.ts** - Re-exports widget for package consumers + +--- + +## Hooks Usage Patterns + +### **Common React Hooks** + +| Hook | Usage | Pattern | +|------|-------|---------| +| `useState` | Local component state | `const [value, setValue] = useState(initialValue)` | +| `useEffect` | Side effects, event listeners | Always with cleanup function | +| `useRef` | Mutable refs, worker instances | `const ref = useRef(null)` | +| `useCallback` | Memoize functions | For expensive functions or props | +| `useMemo` | Memoize values | For expensive computations | + +### **Custom Hook Naming** + +- **Pattern:** `use` (e.g., `useStationLogin`, `useUserState`, `useCallControl`) +- **Location:** `helper.ts` in widget package +- **Exports:** Named export, not default + +--- + +## Error Handling Patterns + +### **1. Try-Catch Everywhere** + +```typescript +const setAgentStatus = (selectedCode) => { + try { + logger.info('Updating currentState'); + store.setCurrentState(selectedCode); + } catch (error) { + logger?.error(`Error in setAgentStatus - ${error.message}`, { + module: 'useUserState', + method: 'setAgentStatus', + }); + } +}; +``` + +**Convention:** +- Every function wrapped in try-catch +- Log errors with context (module, method) +- Use optional chaining for logger (`logger?.error`) + +--- + +### **2. Promise Error Handling** + +```typescript +currentTask.accept() + .catch((error) => { + logger.error(`Error accepting task: ${error}`, { + module: 'useIncomingTask', + method: 'accept', + }); + }); +``` + +**Convention:** +- Always `.catch()` on promises +- Never rely on async/await without try-catch +- Log errors with context + +--- + +### **3. SDK Call Pattern** + +```typescript +const updateAgentState = (selectedCode) => { + setIsSettingAgentStatus(true); + + return cc.setAgentState({state: chosenState, auxCodeId}) + .then((response) => { + logger.log('Agent state set successfully'); + if ('data' in response) { + store.setLastStateChangeTimestamp(response.data.lastStateChangeTimestamp); + } + }) + .catch((error) => { + logger.error(`Error setting agent state: ${error}`); + store.setCurrentState(prevStateRef.current); // Rollback on error + throw error; + }) + .finally(() => { + setIsSettingAgentStatus(false); + }); +}; +``` + +**Pattern:** +- Set loading state before call +- Update store on success +- **Rollback on error** (restore previous state) +- Clear loading state in `finally` +- Re-throw error for upstream handling + +--- + +## Key Conventions to Enforce + +### ✅ DO: +1. **Use functional components only** - no class components +2. **Use `observer` from `mobx-react-lite`** for store-connected components +3. **Wrap every widget** with `ErrorBoundary` from `react-error-boundary` +4. **Split components** into Internal (observer) + Wrapper (ErrorBoundary) +5. **Extract business logic** to custom hooks in `helper.ts` +6. **Use try-catch** in every function +7. **Always cleanup** event listeners in `useEffect` return +8. **Add `data-testid`** to every interactive element +9. **Use `useCallback`** for functions passed as props +10. **Use `useMemo`** for expensive computations +11. **Log with context** (module, method) on every log +12. **Use `useRef`** for mutable values (workers, previous state) +13. **Destructure props** at top of component +14. **Return cleanup functions** from `useEffect` +15. **Use `withMetrics` HOC** on presentational components + +### ❌ DON'T: +1. **Don't use class components** - functional only +2. **Don't import store** in presentational components +3. **Don't forget ErrorBoundary** on widgets +4. **Don't skip cleanup** in useEffect +5. **Don't ignore promise errors** - always `.catch()` +6. **Don't mutate refs** during render +7. **Don't use empty dependency arrays** without justification +8. **Don't skip try-catch** in event handlers +9. **Don't use inline functions** in props without useCallback (if expensive) +10. **Don't mix business logic** into presentational components + +--- + +## Anti-Patterns Found + +### 1. **Inconsistent dependency arrays** +Some `useEffect` hooks have incomplete dependency arrays. + +**Recommendation:** Use ESLint `react-hooks/exhaustive-deps` rule. + +--- + +### 2. **Worker script as string literal** +Web Workers defined as inline strings make testing difficult. + +**Recommendation:** Extract to separate files when possible, or document pattern clearly. + +--- + +## Examples to Reference + +### Example 1: Complete Widget Structure +```typescript +// station-login/src/station-login/index.tsx +import React from 'react'; +import store from '@webex/cc-store'; +import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; +import {StationLoginComponent} from '@webex/cc-components'; +import {useStationLogin} from '../helper'; + +const StationLoginInternal: React.FunctionComponent = observer( + ({onLogin, onLogout, profileMode}) => { + const {cc, teams, loginOptions, logger, isAgentLoggedIn} = store; + + const result = useStationLogin({ + cc, onLogin, onLogout, logger, isAgentLoggedIn + }); + + return ; + } +); + +const StationLogin: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('StationLogin', error); + }} + > + + + ); +}; + +export {StationLogin}; +``` + +### Example 2: Custom Hook with Event Listeners +```typescript +export const useIncomingTask = (props: UseTaskProps) => { + const {incomingTask, onAccepted, logger} = props; + + const taskAssignCallback = () => { + try { + if (onAccepted) onAccepted({task: incomingTask}); + } catch (error) { + logger?.error(`Error - ${error.message}`); + } + }; + + useEffect(() => { + if (!incomingTask) return; + + store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); + + return () => { + store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); + }; + }, [incomingTask]); + + const accept = () => { + try { + incomingTask.accept().catch((error) => logger.error(`Error: ${error}`)); + } catch (error) { + logger?.error(`Error - ${error.message}`); + } + }; + + return { accept }; +}; +``` + +--- + +## Files Analyzed + +1. `/packages/contact-center/station-login/src/station-login/index.tsx` (77 lines) +2. `/packages/contact-center/user-state/src/user-state/index.tsx` (52 lines) +3. `/packages/contact-center/task/src/IncomingTask/index.tsx` +4. `/packages/contact-center/task/src/TaskList/index.tsx` +5. `/packages/contact-center/task/src/CallControl/index.tsx` +6. `/packages/contact-center/task/src/CallControlCAD/index.tsx` +7. `/packages/contact-center/station-login/src/helper.ts` (332 lines) +8. `/packages/contact-center/user-state/src/helper.ts` (296 lines) +9. `/packages/contact-center/task/src/helper.ts` (1002 lines) +10. `/packages/contact-center/cc-components/src/components/UserState/user-state.tsx` (100 lines) +11. `/packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx` (352 lines) + +--- + +## Related Documentation + +- [TypeScript Patterns](./typescript-patterns.md) - Component type definitions +- [MobX Patterns](./mobx-patterns.md) - Observer components +- [Web Component Patterns](./web-component-patterns.md) - React to WC conversion +- [Testing Patterns](./testing-patterns.md) - Component testing + diff --git a/ai-docs/patterns/testing-patterns.md b/ai-docs/patterns/testing-patterns.md new file mode 100644 index 000000000..71ddaca0c --- /dev/null +++ b/ai-docs/patterns/testing-patterns.md @@ -0,0 +1,806 @@ +# Testing Patterns + +--- +Technology: Jest + Playwright +Configuration: See [jest.config.js](../../jest.config.js) and [playwright.config.ts](../../playwright.config.ts) +Dependencies: See [package.json](../../packages/contact-center/*/package.json) files for versions +Scope: Repository-wide +Last Updated: 2025-11-23 +--- + +> **For LLM Agents**: Add this file to context when working on tests, mocking, or test infrastructure. +> +> **For Developers**: Update this file when committing testing pattern changes. + +--- + +## Summary + +The codebase uses **Jest** for unit/integration tests and **Playwright** for E2E tests. Jest tests follow a consistent pattern: mock the store, spy on hooks, test component rendering and error boundaries. Playwright tests use a **TestManager** class for multi-agent/multi-session scenarios with real backend integration. All tests emphasize `data-testid` attributes for reliable selectors. + +--- + +## Testing Stack + +### **Unit/Integration Tests** +- **Framework:** Jest 29.7.0 +- **Testing Library:** @testing-library/react 16.0.1, @testing-library/jest-dom 6.6.2 +- **Environment:** jsdom +- **Coverage:** Jest built-in coverage + +### **E2E Tests** +- **Framework:** Playwright (@playwright/test) +- **Browser:** Chrome (Desktop) +- **Parallelization:** One worker per user set (multi-agent support) +- **Retry:** 1 retry for suite tests +- **Reporter:** HTML reporter + +--- + +## Jest Configuration + +### **Root Configuration** + +**File:** `jest.config.js` + +```javascript +module.exports = { + rootDir: '.', + setupFilesAfterEnv: ['/jest.setup.js'], + moduleNameMapper: { + '^.+\\.(css|less|scss)$': 'babel-jest', + }, + testEnvironment: 'jsdom', + testMatch: ['**/tooling/tests/**/*.js'], + transformIgnorePatterns: [ + '/node_modules/(?!(@momentum-design/components|@momentum-ui/react-collaboration|@lit|lit|cheerio|react-error-boundary))', + ], + transform: { + '\\.[jt]sx?$': 'babel-jest', + '\\.[jt]s?$': 'babel-jest', + }, + moduleDirectories: ['node_modules', 'src'], +}; +``` + +**Key points:** +- **jsdom environment** - simulates browser DOM +- **CSS mocking** - CSS files transformed with babel-jest +- **Transform ignore patterns** - includes specific node_modules packages +- **Babel transform** - for JSX and TS files + +--- + +### **Package-Level Configuration** + +**Pattern:** Each package extends root config + +```javascript +// station-login/jest.config.js +const jestConfig = require('../../../jest.config.js'); + +jestConfig.rootDir = '../../../'; +jestConfig.testMatch = ['**/station-login/tests/**/*.ts', '**/station-login/tests/**/*.tsx']; + +module.exports = jestConfig; +``` + +**Convention:** Override `rootDir` and `testMatch` for each package. + +--- + +## Jest Test Patterns + +### **1. Widget Component Test Pattern** + +**File structure:** +``` +packages/contact-center/station-login/ +├── src/ +│ ├── station-login/index.tsx +│ └── helper.ts +└── tests/ + └── station-login/index.tsx +``` + +**Standard widget test:** +```typescript +import React from 'react'; +import {render} from '@testing-library/react'; +import {StationLogin} from '../../src'; +import * as helper from '../../src/helper'; +import '@testing-library/jest-dom'; +import store from '@webex/cc-store'; + +// 1. Mock store +jest.mock('@webex/cc-store', () => { + const originalStore = jest.requireActual('@webex/cc-store'); + + return { + ...originalStore, + cc: { + on: () => {}, + off: () => {}, + }, + teams: ['team123', 'team456'], + loginOptions: ['EXTENSION', 'AGENT_DN', 'BROWSER'], + deviceType: 'BROWSER', + dialNumber: '12345', + logger: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, + isAgentLoggedIn: false, + setCCCallback: jest.fn(), + onErrorCallback: jest.fn(), + }; +}); + +describe('StationLogin Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // 2. Test component renders with correct props + it('renders StationLoginPresentational with correct props', () => { + const useStationLoginSpy = jest.spyOn(helper, 'useStationLogin'); + const loginCb = jest.fn(); + + render(); + + expect(useStationLoginSpy).toHaveBeenCalledWith({ + cc: expect.any(Object), + onLogin: loginCb, + logger: expect.any(Object), + deviceType: 'BROWSER', + dialNumber: '12345', + isAgentLoggedIn: false, + }); + }); + + // 3. Test ErrorBoundary + describe('ErrorBoundary Tests', () => { + it('should render empty fragment when ErrorBoundary catches an error', () => { + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + + jest.spyOn(helper, 'useStationLogin').mockImplementation(() => { + throw new Error('Test error in useStationLogin'); + }); + + const {container} = render(); + + expect(container.firstChild).toBeNull(); + expect(store.onErrorCallback).toHaveBeenCalledWith( + 'StationLogin', + Error('Test error in useStationLogin') + ); + }); + }); +}); +``` + +**Pattern breakdown:** +1. **Mock store** - Use `jest.mock()` to mock `@webex/cc-store` +2. **Spy on hooks** - Use `jest.spyOn(helper, 'useHook')` to verify calls +3. **Suppress console.error** - Prevent ErrorBoundary errors from cluttering output +4. **Test render** - Verify component renders and hook called with correct props +5. **Test ErrorBoundary** - Mock hook to throw, verify fallback and callback + +--- + +### **2. Store Mock Pattern** + +**Full mock with spread:** +```typescript +jest.mock('@webex/cc-store', () => { + const originalStore = jest.requireActual('@webex/cc-store'); + + return { + ...originalStore, // Spread original for types/constants + cc: { + on: jest.fn(), + off: jest.fn(), + }, + idleCodes: [], + agentId: 'testAgentId', + logger: { + log: jest.fn(), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + currentState: '0', + customState: null, + onErrorCallback: jest.fn(), + }; +}); +``` + +**Benefits:** +- Preserves original types/enums +- Overrides runtime values +- Consistent across tests + +--- + +### **3. Web Worker Mock Pattern** + +```typescript +describe('UserState Component', () => { + let workerMock; + + beforeEach(() => { + workerMock = { + postMessage: jest.fn(), + terminate: jest.fn(), + onmessage: null, + }; + + global.Worker = jest.fn(() => workerMock); + global.URL.createObjectURL = jest.fn(() => 'blob:http://localhost:3000/12345'); + + if (typeof window.HTMLElement.prototype.attachInternals !== 'function') { + window.HTMLElement.prototype.attachInternals = jest.fn(); + } + }); + + it('renders UserStateComponent with correct props', () => { + const useUserStateSpy = jest.spyOn(helper, 'useUserState'); + render(); + expect(useUserStateSpy).toHaveBeenCalledTimes(1); + }); +}); +``` + +**Pattern:** +- Mock `Worker` constructor +- Mock `URL.createObjectURL` +- Mock `HTMLElement.prototype.attachInternals` (for Web Components) + +--- + +### **4. Store Unit Test Pattern** + +**Testing the store itself:** +```typescript +import {makeAutoObservable} from 'mobx'; +import Webex from '@webex/contact-center'; +import store from '../src/store'; +import {mockProfile} from '@webex/test-fixtures'; + +jest.mock('mobx', () => ({ + makeAutoObservable: jest.fn(), + observable: {ref: jest.fn()}, +})); + +jest.mock('@webex/contact-center', () => ({ + init: jest.fn(() => ({ + once: jest.fn((event, callback) => { + if (event === 'ready') { + callback(); + } + }), + cc: { + register: jest.fn().mockResolvedValue(mockProfile), + LoggerProxy: { + error: jest.fn(), + log: jest.fn(), + }, + }, + })), +})); + +describe('Store', () => { + let storeInstance; + let mockWebex; + + beforeEach(() => { + storeInstance = store.getInstance(); + mockWebex = Webex.init({ + config: {anyConfig: true}, + credentials: {access_token: 'fake_token'}, + }); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should initialize with default values', () => { + expect(storeInstance.teams).toEqual([]); + expect(storeInstance.isAgentLoggedIn).toBe(false); + expect(makeAutoObservable).toHaveBeenCalledWith(storeInstance, { + cc: expect.any(Function), + }); + }); + + describe('registerCC', () => { + it('should initialise store values on successful register', async () => { + const mockResponse = { + teams: [{id: 'team1', name: 'Team 1'}], + agentId: 'agent1', + isAgentLoggedIn: true, + }; + mockWebex.cc.register.mockResolvedValue(mockResponse); + + await storeInstance.registerCC(mockWebex); + + expect(storeInstance.teams).toEqual(mockResponse.teams); + expect(storeInstance.agentId).toEqual(mockResponse.agentId); + }); + }); +}); +``` + +**Pattern:** +- Mock MobX +- Mock Webex SDK +- Use fake timers for async tests +- Test initial state and mutations + +--- + +## Playwright Configuration + +### **Configuration File** + +**File:** `playwright.config.ts` + +```typescript +import {defineConfig, devices} from '@playwright/test'; +import dotenv from 'dotenv'; +import {USER_SETS} from './playwright/test-data'; + +dotenv.config({path: path.resolve(__dirname, '.env')}); + +export default defineConfig({ + testDir: './playwright', + timeout: 180000, + webServer: { + command: 'yarn workspace samples-cc-react-app serve', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, + retries: 0, + fullyParallel: true, + workers: Object.keys(USER_SETS).length, // Dynamic worker count + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'OAuth: Get Access Token', + testMatch: /global\.setup\.ts/, + }, + // Dynamic test projects from USER_SETS + ...Object.entries(USER_SETS).map(([setName, setData], index) => { + return { + name: setName, + dependencies: ['OAuth: Get Access Token'], + fullyParallel: false, + retries: 1, + testMatch: [`**/suites/${setData.TEST_SUITE}`], + use: { + ...devices['Desktop Chrome'], + channel: 'chrome', + launchOptions: { + args: [ + `--use-fake-ui-for-media-stream`, + `--use-fake-device-for-media-stream`, + `--use-file-for-fake-audio-capture=${dummyAudioPath}`, + `--remote-debugging-port=${9221 + index}`, + `--window-position=${index * 1300},0`, + ], + }, + }, + }; + }), + ], +}); +``` + +**Key features:** +- **Dynamic projects** - One project per user set (multi-agent) +- **OAuth setup** - Global setup for token +- **Fake media** - Fake audio/video for WebRTC +- **Parallel workers** - One per user set +- **Remote debugging** - Different port per worker + +--- + +## Playwright Test Patterns + +### **1. TestManager Pattern** + +```typescript +import {TestManager} from '../test-manager'; + +export default function createUserStateTests() { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.basicSetup(browser); + + // Login agent + await telephonyLogin( + testManager.agent1Page, + LOGIN_MODE.EXTENSION, + process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] + ); + + await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible(); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should verify initial state is Meeting', async () => { + const state = await getCurrentState(testManager.agent1Page); + if (state !== USER_STATES.MEETING) + throw new Error('Initial state is not Meeting'); + }); +} +``` + +**TestManager responsibilities:** +- Browser/page management +- Multi-agent support +- Multi-session support +- Console log capture +- Environment variable access + +--- + +### **2. Utility Function Pattern** + +```typescript +// Utils/userStateUtils.ts +export async function getCurrentState(page: Page): Promise { + const stateElement = page.getByTestId('state-select'); + return await stateElement.innerText(); +} + +export async function changeUserState(page: Page, state: string) { + await page.getByTestId('state-select').click(); + await page.getByTestId(`state-item-${state}`).click(); + await page.waitForTimeout(2000); +} + +export async function verifyCurrentState(page: Page, expectedState: string) { + const currentState = await getCurrentState(page); + expect(currentState).toBe(expectedState); +} + +export async function getStateElapsedTime(page: Page): Promise { + return await page.getByTestId('elapsed-time').innerText(); +} +``` + +**Pattern:** +- Extract common actions to utilities +- Use `data-testid` for selectors +- Return values for assertions +- Encapsulate waits + +--- + +### **3. Multi-Session Test Pattern** + +```typescript +test('should test multi-session synchronization', async () => { + // Create multi-session page + if (!testManager.multiSessionAgent1Page) { + if (!testManager.multiSessionContext) { + testManager.multiSessionContext = await testManager.agent1Context.browser()!.newContext(); + } + testManager.multiSessionAgent1Page = await testManager.multiSessionContext.newPage(); + } + + await testManager.setupMultiSessionPage(); + const multiSessionPage = testManager.multiSessionAgent1Page!; + + // Change state in first session + await changeUserState(testManager.agent1Page, USER_STATES.MEETING); + await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); + + // Verify state synchronized in second session + await multiSessionPage.waitForTimeout(3000); + await verifyCurrentState(multiSessionPage, USER_STATES.MEETING); + + // Compare timers + const [timer1, timer2] = await Promise.all([ + getStateElapsedTime(testManager.agent1Page), + getStateElapsedTime(multiSessionPage), + ]); + + const parseTimer = (timer: string) => { + const parts = timer.split(':'); + return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + }; + + expect(Math.abs(parseTimer(timer1) - parseTimer(timer2))).toBeLessThanOrEqual(2); +}); +``` + +**Pattern:** +- Create second context/page for multi-session +- Perform action in first session +- Verify synchronization in second session +- Use `Promise.all` for parallel checks + +--- + +### **4. Console Validation Pattern** + +```typescript +export async function validateConsoleStateChange( + page: Page, + expectedState: string, + consoleMessages: string[] +): Promise { + const found = consoleMessages.some(msg => + msg.includes('onStateChange called') && + msg.includes(expectedState) + ); + return found; +} + +export async function checkCallbackSequence( + page: Page, + state: string, + consoleMessages: string[] +): Promise { + const callbackIndex = consoleMessages.findIndex(msg => + msg.includes('onStateChange called') + ); + const apiSuccessIndex = consoleMessages.findIndex(msg => + msg.includes('Agent state set successfully') + ); + + return callbackIndex > -1 && + apiSuccessIndex > -1 && + callbackIndex > apiSuccessIndex; +} + +// In TestManager +constructor(projectName: string) { + this.consoleMessages = []; + // Capture console logs in beforeAll + this.agent1Page.on('console', msg => { + this.consoleMessages.push(msg.text()); + }); +} +``` + +**Pattern:** +- Capture console logs in TestManager +- Validate callback invocation +- Check event sequence +- Use for debugging and verification + +--- + +## Test Data Patterns + +### **data-testid Convention** + +**Every interactive element has a `data-testid`:** +```typescript +// Component +
+ + ... + + {formatTime(elapsedTime)} +
+ +// Test +await page.getByTestId('state-select').click(); +await page.getByTestId('state-item-Available').click(); +const time = await page.getByTestId('elapsed-time').innerText(); +``` + +**Naming convention:** +- Use kebab-case +- Descriptive, hierarchical names +- Include dynamic parts (e.g., `state-item-${name}`) + +--- + +## Test Fixtures + +### **Fixture Pattern** + +**Location:** `packages/contact-center/test-fixtures/src/` + +```typescript +// incomingTaskFixtures.ts +export const mockIncomingTask = { + data: { + interactionId: 'interaction123', + interaction: { + mediaType: 'telephony', + state: 'connected', + }, + }, + accept: jest.fn().mockResolvedValue({}), + decline: jest.fn().mockResolvedValue({}), + hold: jest.fn().mockResolvedValue({}), + resume: jest.fn().mockResolvedValue({}), + end: jest.fn().mockResolvedValue({}), + on: jest.fn(), + off: jest.fn(), +}; + +// taskListFixtures.ts +export const mockTaskList = { + 'interaction123': mockIncomingTask, + 'interaction456': {...}, +}; +``` + +**Usage:** +```typescript +import {mockIncomingTask} from '@webex/test-fixtures'; + +test('should accept task', () => { + render(); + // ... +}); +``` + +--- + +## Key Conventions to Enforce + +### ✅ DO: +1. **Mock store** in every widget test +2. **Spy on hooks** to verify calls +3. **Test ErrorBoundary** for every widget +4. **Suppress console.error** in beforeEach +5. **Restore mocks** in afterEach +6. **Use data-testid** for selectors +7. **Extract common actions** to utility functions +8. **Use TestManager** for Playwright tests +9. **Capture console logs** for validation +10. **Use fake timers** for async tests +11. **Clear mocks** before each test +12. **Test multi-session** scenarios where relevant +13. **Validate callback sequence** with console logs +14. **Use fixtures** for complex mock data + +### ❌ DON'T: +1. **Don't use CSS selectors** - use `data-testid` +2. **Don't skip ErrorBoundary tests** - required for every widget +3. **Don't forget to cleanup** in afterEach/afterAll +4. **Don't use real timers** for time-dependent tests +5. **Don't skip console.error suppression** - clutters output +6. **Don't hardcode test data** - use fixtures or constants +7. **Don't test implementation details** - test behavior +8. **Don't skip multi-session tests** for shared state widgets + +--- + +## Anti-Patterns Found + +### 1. **Hardcoded waits** +```typescript +await page.waitForTimeout(3000); +``` + +**Issue:** Brittle, slows tests +**Recommendation:** Use `waitFor` with conditions when possible + +--- + +### 2. **Manual timer parsing** +```typescript +const parseTimer = (timer: string) => { + const parts = timer.split(':'); + return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); +}; +``` + +**Recommendation:** Extract to utility function, use in all tests + +--- + +## Examples to Reference + +### Example 1: Widget Unit Test +```typescript +import {render} from '@testing-library/react'; +import {UserState} from '../../src'; +import * as helper from '../../src/helper'; +import store from '@webex/cc-store'; + +jest.mock('@webex/cc-store', () => ({ + cc: {on: jest.fn(), off: jest.fn()}, + idleCodes: [], + agentId: 'testAgentId', + logger: {log: jest.fn(), error: jest.fn()}, + onErrorCallback: jest.fn(), +})); + +describe('UserState Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('renders with correct props', () => { + const spy = jest.spyOn(helper, 'useUserState'); + render(); + expect(spy).toHaveBeenCalledWith({ + cc: expect.any(Object), + idleCodes: [], + agentId: 'testAgentId', + logger: expect.any(Object), + }); + }); +}); +``` + +### Example 2: Playwright E2E Test +```typescript +import {test, expect} from '@playwright/test'; +import {TestManager} from '../test-manager'; + +export default function createTests() { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + testManager = new TestManager(testInfo.project.name); + await testManager.basicSetup(browser); + }); + + test.afterAll(async () => { + await testManager.cleanup(); + }); + + test('should change state', async () => { + await testManager.agent1Page.getByTestId('state-select').click(); + await testManager.agent1Page.getByTestId('state-item-Available').click(); + + const state = await testManager.agent1Page.getByTestId('state-select').innerText(); + expect(state).toBe('Available'); + }); +} +``` + +--- + +## Files Analyzed + +1. `/jest.config.js` (21 lines) +2. `/packages/contact-center/station-login/jest.config.js` (7 lines) +3. `/packages/contact-center/station-login/tests/station-login/index.tsx` (113 lines) +4. `/packages/contact-center/user-state/tests/user-state/index.tsx` (102 lines) +5. `/packages/contact-center/store/tests/store.ts` (100+ lines) +6. `/playwright.config.ts` (67 lines) +7. `/playwright/tests/user-state-test.spec.ts` (150+ lines) + +--- + +## Related Documentation + +- [React Patterns](./react-patterns.md) - Component testing strategies +- [MobX Patterns](./mobx-patterns.md) - Store mocking techniques +- [TypeScript Patterns](./typescript-patterns.md) - Type mocking + diff --git a/ai-docs/patterns/typescript-patterns.md b/ai-docs/patterns/typescript-patterns.md new file mode 100644 index 000000000..603a7b8db --- /dev/null +++ b/ai-docs/patterns/typescript-patterns.md @@ -0,0 +1,519 @@ +# TypeScript Patterns + +--- +Technology: TypeScript +Configuration: [root tsconfig.json](../../tsconfig.json) +Dependencies: See individual [package.json](../../packages/contact-center/*/package.json) files +Scope: Repository-wide +Last Updated: 2025-11-23 +--- + +> **For LLM Agents**: Add this file to context when working on TypeScript code, interfaces, or type definitions. +> +> **For Developers**: Update this file when committing TypeScript pattern changes. + +--- + +## Naming Conventions + +**Components:** +- PascalCase: `UserState.tsx`, `StationLogin.tsx` +- Component files use `.tsx` extension + +**Hooks:** +- camelCase with `use` prefix: `useUserState.ts`, `useStationLogin.ts` +- Hook files use `.ts` extension + +**Types/Interfaces:** +- PascalCase with `I` prefix: `IUserState`, `IStationLoginProps` +- Located in `{component}.types.ts` files + +**Constants:** +- SCREAMING_SNAKE_CASE: `MAX_RETRY_COUNT`, `DEFAULT_TIMEOUT` +- Grouped in constants files or at top of modules + +**Files:** +- Widget entry: `packages/*/src/{widget}/index.tsx` +- Helpers/Hooks: `packages/*/src/helper.ts` +- Types: `packages/*/src/{widget}/{widget}.types.ts` + +## Import Patterns + +**Store:** +```typescript +import store from '@webex/cc-store'; +``` + +**Components:** +```typescript +import {Component} from '@webex/cc-components'; +``` + +**Hooks:** +```typescript +import {useUserState} from '../helper'; +``` + +**Types:** +```typescript +import {IUserState} from './user-state.types'; +``` + +**MobX:** +```typescript +import {observer} from 'mobx-react-lite'; +import {runInAction} from 'mobx'; +``` + +--- + +## Summary + +The codebase uses TypeScript with a centralized configuration and consistent patterns across all packages. TypeScript strict mode is **partially enabled** (`alwaysStrict: true` but not full `strict: true`). The project emphasizes type safety through interfaces, type aliases, and utility types, with a clear separation between widget-level and component-level type definitions. + +--- + +## TypeScript Configuration + +### Root Configuration (`tsconfig.json`) + +**Location:** `/packages/contact-center/../../../tsconfig.json` + +**Key Settings:** +- `alwaysStrict: true` - Enforces strict mode in emitted JavaScript +- `strict: false` - Full strict mode **NOT enabled** +- `allowJs: true` - Allows JavaScript files +- `allowSyntheticDefaultImports: true` - Enables synthetic default imports +- `experimentalDecorators: true` - Required for MobX decorators +- `isolatedModules: true` - Required for Babel transpilation +- `module: "commonjs"` - CommonJS module system +- `target: "ES6"` - ES6 compilation target +- `jsx: "react"` - React JSX support +- `skipLibCheck: true` - Skip type checking of declaration files +- `types: ["jest"]` - Global Jest types + +### Package-Level Configurations + +All packages (`station-login`, `user-state`, `store`, `cc-components`, `cc-widgets`, `task`, `ui-logging`, `test-fixtures`) extend the root configuration: + +```json +{ + "extends": "../../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types" + } +} +``` + +**Notable Exception:** `store` package uses `moduleResolution: "NodeNext"` and `module: "NodeNext"` for modern resolution. + +--- + +## Interface Patterns + +### 1. **Interface Naming Convention** + +**Pattern:** Prefix interfaces with `I` + +**Examples:** +```typescript +interface IContactCenter { ... } +interface IStore { ... } +interface IStoreWrapper extends IStore { ... } +interface ILogger { ... } +interface IWrapupCode { ... } +interface IUserState { ... } +interface IStationLoginProps { ... } +``` + +**Enforcement:** Consistent across all packages, especially in `store.types.ts` and component type files. + +--- + +### 2. **Type Aliases vs Interfaces** + +**When to use `type`:** +- Union types: `type ICustomState = ICustomStateSet | ICustomStateReset` +- Intersection types: `type WithWebex = { webex: {...} }` +- Utility type compositions: `type UseTaskProps = Pick & Partial<...>` +- Simple object shapes without extension needs + +**When to use `interface`:** +- Component props: `interface IStationLoginProps { ... }` +- Store contracts: `interface IStore { ... }` +- Extensible structures: `interface IStoreWrapper extends IStore { ... }` +- API contracts: `interface IContactCenter { ... }` + +**Examples:** +```typescript +// Type for union +type ICustomState = ICustomStateSet | ICustomStateReset; + +// Interface for props +interface IUserState { + idleCodes: IdleCode[]; + logger: ILogger; + onStateChange?: (arg: IdleCode | ICustomState) => void; +} +``` + +--- + +### 3. **Pick and Partial Utility Types** + +**Heavy use of `Pick` and `Partial` to derive types** - This is a core pattern throughout the codebase. + +**Pattern 1: Pick specific props from parent interface** +```typescript +export type IUserStateProps = Pick; + +export type UseUserStateProps = Pick< + IUserState, + | 'idleCodes' + | 'agentId' + | 'cc' + | 'currentState' + | 'customState' + | 'lastStateChangeTimestamp' + | 'logger' + | 'onStateChange' + | 'lastIdleCodeChangeTimestamp' +>; +``` + +**Pattern 2: Combine Pick with Partial for optional props** +```typescript +export type IncomingTaskProps = Pick & + Partial>; + +export type StationLoginProps = Pick & + Partial>; +``` + +**Pattern 3: Pick from multiple interfaces with intersection** +```typescript +export type UseTaskProps = Pick & + Partial>; +``` + +**Benefit:** Ensures type consistency between component layers (widget → component) without duplication. + +--- + +### 4. **Optional Properties** + +**Convention:** Use `?` for optional properties + +```typescript +interface IUserState { + onStateChange?: (arg: IdleCode | ICustomState) => void; + lastStateChangeTimestamp?: number; + lastIdleCodeChangeTimestamp?: number; +} +``` + +**Alternative:** Use `Partial>` to make specific props optional when deriving types. + +--- + +### 5. **Function Type Signatures** + +**Callback Props:** +```typescript +onStateChange?: (arg: IdleCode | ICustomState) => void; +onLogin?: () => void; +onSaveEnd?: (isComplete: boolean) => void; +``` + +**Generic Functions:** +```typescript +type FetchPaginatedList = ( + params: PaginatedListParams +) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; + +type TransformPaginatedData = (item: T, page: number, index: number) => U; +``` + +**Event Handlers:** +```typescript +// eslint-disable-next-line @typescript-eslint/no-explicit-any +on: (event: string, callback: (data: any) => void) => void; +``` + +--- + +## Enums + +### Pattern: Named enums for constants + +**Examples:** +```typescript +export enum TASK_EVENTS { + TASK_INCOMING = 'task:incoming', + TASK_ASSIGNED = 'task:assigned', + TASK_HOLD = 'task:hold', + // ... 40+ task events +} + +export enum CC_EVENTS { + AGENT_DN_REGISTERED = 'agent:dnRegistered', + AGENT_LOGOUT_SUCCESS = 'agent:logoutSuccess', + AGENT_STATION_LOGIN_SUCCESS = 'agent:stationLoginSuccess', + // ... +} + +export enum ConsultStatus { + NO_CONSULTATION_IN_PROGRESS = 'No consultation in progress', + BEING_CONSULTED = 'beingConsulted', + CONSULT_INITIATED = 'consultInitiated', + // ... +} + +export enum AgentUserState { + Available = 'Available', + RONA = 'RONA', + Engaged = 'ENGAGED', +} +``` + +**Convention:** UPPERCASE for enum names representing events/constants, PascalCase for state enums. + +--- + +## Type Exports + +### Central Export Pattern + +**Each package has a `*.types.ts` file that exports all types:** + +```typescript +export type { + IContactCenter, + ITask, + Profile, + Team, + // ... all interfaces and types +}; + +export { + CC_EVENTS, + TASK_EVENTS, + ENGAGED_LABEL, + // ... all enums and constants +}; +``` + +**Widget packages export minimal types:** +```typescript +// user-state.types.ts +export type IUserStateProps = Pick; +export type UseUserStateProps = Pick; +``` + +--- + +## Import Patterns + +### 1. **SDK Type Imports** + +**Direct imports from `@webex/contact-center`:** +```typescript +import { + AgentLogin, + Profile, + ITask, + // ... +} from '@webex/contact-center'; +``` + +**Deep imports for types not exported (workaround):** +```typescript +import { + OutdialAniEntriesResponse, + OutdialAniParams, +} from 'node_modules/@webex/contact-center/dist/types/services/config/types'; +``` + +**Comment pattern for SDK issues:** +```typescript +// To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 +interface IContactCenter { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: (event: string, callback: (data: any) => void) => void; +} +``` + +### 2. **Internal Package Imports** + +```typescript +import store, {CC_EVENTS} from '@webex/cc-store'; +import {StationLoginComponent, StationLoginComponentProps} from '@webex/cc-components'; +import {IUserState} from '@webex/cc-components'; +``` + +--- + +## Documentation Patterns + +### JSDoc for Interfaces + +**Comprehensive JSDoc comments on interface properties:** + +```typescript +/** + * Interface representing the properties for the Station Login component. + */ +export interface IStationLoginProps { + /** + * Webex instance. + */ + cc: IContactCenter; + + /** + * Array of the team IDs that agent belongs to + */ + teams: Team[]; + + /** + * Handler to initiate the agent login + */ + login: () => void; + + /** + * Flag to indicate if the agent is logged in + */ + isAgentLoggedIn: boolean; + + // ... +} +``` + +**Convention:** Every property should have a JSDoc comment describing its purpose. + +--- + +## Key Conventions to Enforce + +### ✅ DO: +1. **Prefix interfaces with `I`**: `IStore`, `ILogger`, `IUserState` +2. **Use `Pick` and `Partial`** to derive widget types from component types +3. **Export all types** from a central `*.types.ts` file in each package +4. **Document every interface property** with JSDoc comments +5. **Use enums for event names and constants** instead of string literals +6. **Use `type` for unions, intersections, and utility compositions** +7. **Use `interface` for component props and extensible contracts** +8. **Mark optional props with `?`** or wrap in `Partial<>` +9. **Use explicit `void` return type** for callbacks +10. **Add TODO comments with JIRA links** for SDK workarounds + +### ❌ DON'T: +1. **Don't use `any`** without ESLint disable comment and explanation +2. **Don't duplicate type definitions** - use `Pick` to derive from source +3. **Don't mix `type` and `interface`** for the same use case +4. **Don't skip JSDoc** on public interfaces +5. **Don't use deep imports** from `node_modules` unless SDK types are unavailable + +--- + +## Anti-Patterns Found + +### 1. **Deep imports from node_modules** +```typescript +// ❌ ANTI-PATTERN +import {OutdialAniEntriesResponse} from 'node_modules/@webex/contact-center/dist/types/services/config/types'; +``` +**Reason:** These should be exported from SDK. Tracked as technical debt with JIRA link. + +### 2. **Use of `any` in SDK interface workaround** +```typescript +// ❌ NECESSARY EVIL (documented) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +on: (event: string, callback: (data: any) => void) => void; +``` +**Reason:** SDK doesn't properly type event callbacks. Disable comment required. + +### 3. **Partial strict mode** +- `alwaysStrict: true` but `strict: false` in root config +- **Impact:** Missing stricter null checks, implicit any, etc. +- **Recommendation:** Consider enabling full `strict: true` in future + +--- + +## Examples to Reference + +### Example 1: Widget Type Derivation +```typescript +// Component defines full interface +interface IUserState { + idleCodes: IdleCode[]; + agentId: string; + cc: IContactCenter; + currentState: string; + onStateChange?: (arg: IdleCode | ICustomState) => void; + // ... 10+ more properties +} + +// Widget picks only what it needs +export type IUserStateProps = Pick; + +// Helper hook picks different subset +export type UseUserStateProps = Pick< + IUserState, + | 'idleCodes' + | 'agentId' + | 'cc' + | 'currentState' + | 'customState' + | 'lastStateChangeTimestamp' + | 'logger' + | 'onStateChange' + | 'lastIdleCodeChangeTimestamp' +>; +``` + +### Example 2: Combining Picked and Partial Props +```typescript +export type StationLoginProps = + Pick & + Partial>; +``` + +### Example 3: Generic Type Definitions +```typescript +type FetchPaginatedList = ( + params: PaginatedListParams +) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; +``` + +--- + +## Files Analyzed + +1. `/packages/contact-center/tsconfig.json` (root) +2. `/packages/contact-center/station-login/tsconfig.json` +3. `/packages/contact-center/user-state/tsconfig.json` +4. `/packages/contact-center/store/tsconfig.json` +5. `/packages/contact-center/cc-components/tsconfig.json` +6. `/packages/contact-center/store/src/store.types.ts` (346 lines) +7. `/packages/contact-center/user-state/src/user-state.types.ts` +8. `/packages/contact-center/task/src/task.types.ts` +9. `/packages/contact-center/station-login/src/station-login/station-login.types.ts` +10. `/packages/contact-center/cc-components/src/components/StationLogin/station-login.types.ts` (247 lines) +11. `/packages/contact-center/cc-components/src/components/UserState/user-state.types.ts` +12. `/packages/contact-center/station-login/src/helper.ts` (332 lines) +13. `/packages/contact-center/station-login/src/station-login/index.tsx` (77 lines) +14. `/packages/contact-center/user-state/src/user-state/index.tsx` (52 lines) + +--- + +## Related Documentation + +- [MobX Patterns](./mobx-patterns.md) - MobX store with TypeScript types +- [React Patterns](./react-patterns.md) - React components with TypeScript +- [Testing Patterns](./testing-patterns.md) - TypeScript in tests + diff --git a/ai-docs/patterns/web-component-patterns.md b/ai-docs/patterns/web-component-patterns.md new file mode 100644 index 000000000..0162de134 --- /dev/null +++ b/ai-docs/patterns/web-component-patterns.md @@ -0,0 +1,618 @@ +# Web Component Patterns + +--- +Technology: Web Components (Custom Elements v1) +Configuration: See [cc-widgets/package.json](../../packages/contact-center/cc-widgets/package.json) +Dependencies: See [@r2wc package.json](../../packages/contact-center/cc-widgets/package.json) for version +Scope: Repository-wide +Last Updated: 2025-11-23 +--- + +> **For LLM Agents**: Add this file to context when working on Web Components, r2wc wrappers, or custom element registration. +> +> **For Developers**: Update this file when committing Web Component pattern changes. + +--- + +## Summary + +The codebase uses **`@r2wc/react-to-web-component`** (version 2.0.3) to wrap React components as Web Components. There are **two levels** of Web Component exports: +1. **Widget-level** (`cc-widgets/wc.ts`) - Wraps widget components with minimal props (callbacks only) +2. **Component-level** (`cc-components/wc.ts`) - Wraps presentational components with full props + +All Web Components are registered using `customElements.define()` with duplicate registration checks. The package exports both React components (`index.ts`) and Web Components (`wc.ts`) through package.json exports field. + +--- + +## Package Structure + +### **Dual Export Pattern** + +**package.json exports:** +```json +{ + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/types/index.d.ts" + }, + "./wc": { + "import": "./dist/wc.js", + "require": "./dist/wc.js", + "types": "./dist/types/wc.d.ts" + } + } +} +``` + +**Usage:** +```typescript +// Import React components +import {StationLogin, UserState} from '@webex/cc-widgets'; + +// Import Web Components (auto-registered) +import '@webex/cc-widgets/wc'; +// Now can use in HTML +``` + +--- + +## r2wc Wrapper Pattern + +### **1. Widget-Level Wrappers (cc-widgets)** + +**Location:** `packages/contact-center/cc-widgets/src/wc.ts` + +**Pattern:** +```typescript +import r2wc from '@r2wc/react-to-web-component'; +import {StationLogin} from '@webex/cc-station-login'; +import {UserState} from '@webex/cc-user-state'; +import store from '@webex/cc-store'; + +// Wrap widget with minimal props (only callbacks) +const WebUserState = r2wc(UserState, { + props: { + onStateChange: 'function', + }, +}); + +const WebStationLogin = r2wc(StationLogin, { + props: { + onLogin: 'function', + onLogout: 'function', + }, +}); + +const WebIncomingTask = r2wc(IncomingTask, { + props: { + incomingTask: 'json', + onAccepted: 'function', + onRejected: 'function', + }, +}); + +const WebTaskList = r2wc(TaskList, { + props: { + onTaskAccepted: 'function', + onTaskDeclined: 'function', + onTaskSelected: 'function', + }, +}); + +const WebCallControl = r2wc(CallControl, { + props: { + onHoldResume: 'function', + onEnd: 'function', + onWrapUp: 'function', + onRecordingToggle: 'function', + }, +}); + +const WebOutdialCall = r2wc(OutdialCall, {}); + +// Register all components +const components = [ + {name: 'widget-cc-user-state', component: WebUserState}, + {name: 'widget-cc-station-login', component: WebStationLogin}, + {name: 'widget-cc-incoming-task', component: WebIncomingTask}, + {name: 'widget-cc-task-list', component: WebTaskList}, + {name: 'widget-cc-call-control', component: WebCallControl}, + {name: 'widget-cc-outdial-call', component: WebOutdialCall}, + {name: 'widget-cc-call-control-cad', component: WebCallControlCAD}, +]; + +components.forEach(({name, component}) => { + if (!customElements.get(name)) { + customElements.define(name, component); + } +}); + +// Export store for external access +export {store}; +``` + +**Key characteristics:** +- **Minimal props** - only user-facing callbacks and input data +- **Store access hidden** - widgets access store internally +- **Convention**: `widget-cc-` for custom element names +- **Batch registration** - loop through components array +- **Store export** - also exports store for external initialization + +--- + +### **2. Component-Level Wrappers (cc-components)** + +**Location:** `packages/contact-center/cc-components/src/wc.ts` + +**Pattern:** +```typescript +import r2wc from '@r2wc/react-to-web-component'; +import UserStateComponent from './components/UserState/user-state'; +import StationLoginComponent from './components/StationLogin/station-login'; + +// Wrap presentational component with full props +const WebUserState = r2wc(UserStateComponent, { + props: { + idleCodes: 'json', + setAgentStatus: 'function', + isSettingAgentStatus: 'boolean', + elapsedTime: 'number', + lastIdleStateChangeElapsedTime: 'number', + currentState: 'string', + customState: 'json', + logger: 'function', + }, +}); + +if (!customElements.get('component-cc-user-state')) { + customElements.define('component-cc-user-state', WebUserState); +} + +const WebStationLogin = r2wc(StationLoginComponent, { + props: { + teams: 'json', + loginOptions: 'json', + login: 'function', + logout: 'function', + loginSuccess: 'json', + loginFailure: 'json', + logoutSuccess: 'json', + setDeviceType: 'function', + setDialNumber: 'function', + setTeam: 'function', + isAgentLoggedIn: 'boolean', + handleContinue: 'function', + deviceType: 'string', + showMultipleLoginAlert: 'boolean', + logger: 'function', + }, +}); + +if (!customElements.get('component-cc-station-login')) { + customElements.define('component-cc-station-login', WebStationLogin); +} + +// Shared props pattern +const commonPropsForCallControl: Record = { + currentTask: 'json', + audioRef: 'json', + wrapupCodes: 'json', + wrapupRequired: 'boolean', + toggleHold: 'function', + toggleRecording: 'function', + endCall: 'function', + wrapupCall: 'function', + isHeld: 'boolean', + setIsHeld: 'function', + consultTransferOptions: 'json', +}; + +const WebCallControlCADComponent = r2wc(CallControlCADComponent, { + props: commonPropsForCallControl, +}); + +const WebCallControl = r2wc(CallControlComponent, { + props: commonPropsForCallControl, +}); + +if (!customElements.get('component-cc-call-control-cad')) { + customElements.define('component-cc-call-control-cad', WebCallControlCADComponent); +} + +if (!customElements.get('component-cc-call-control')) { + customElements.define('component-cc-call-control', WebCallControl); +} +``` + +**Key characteristics:** +- **Full props** - all component props exposed +- **Shared props** - common props extracted to constants +- **Convention**: `component-cc-` for custom element names +- **Individual registration** - each component registered separately +- **Duplicate check** - `if (!customElements.get(name))` before defining + +--- + +## r2wc Type Mapping + +### **Supported Prop Types** + +| r2wc Type | TypeScript Type | Usage | +|-----------|-----------------|-------| +| `'string'` | `string` | Simple strings, IDs, names | +| `'number'` | `number` | Counts, timestamps, durations | +| `'boolean'` | `boolean` | Flags, states | +| `'function'` | `(...args: any[]) => any` | Callbacks, handlers, loggers | +| `'json'` | `object`, `array`, complex types | Objects, arrays, structured data | + +**Examples:** +```typescript +const WebComponent = r2wc(ReactComponent, { + props: { + // Primitives + deviceType: 'string', + elapsedTime: 'number', + isAgentLoggedIn: 'boolean', + + // Functions + onStateChange: 'function', + setAgentStatus: 'function', + logger: 'function', + + // Complex types + teams: 'json', // Team[] + idleCodes: 'json', // IdleCode[] + currentTask: 'json', // ITask + loginFailure: 'json', // Error + customState: 'json', // ICustomState + }, +}); +``` + +--- + +## Custom Element Registration + +### **Pattern 1: Batch Registration (cc-widgets)** + +```typescript +const components = [ + {name: 'widget-cc-user-state', component: WebUserState}, + {name: 'widget-cc-station-login', component: WebStationLogin}, + {name: 'widget-cc-incoming-task', component: WebIncomingTask}, +]; + +components.forEach(({name, component}) => { + if (!customElements.get(name)) { + customElements.define(name, component); + } +}); +``` + +**Benefits:** +- Easy to add new components +- Consistent registration logic +- Prevents duplicates automatically + +--- + +### **Pattern 2: Individual Registration (cc-components)** + +```typescript +if (!customElements.get('component-cc-user-state')) { + customElements.define('component-cc-user-state', WebUserState); +} +``` + +**Benefits:** +- Explicit control per component +- Clear which components are registered +- Easy to debug registration issues + +--- + +## Naming Conventions + +### **Custom Element Names** + +**Widget level:** +- **Pattern:** `widget-cc-` +- **Examples:** + - `widget-cc-user-state` + - `widget-cc-station-login` + - `widget-cc-incoming-task` + - `widget-cc-task-list` + - `widget-cc-call-control` + - `widget-cc-call-control-cad` + - `widget-cc-outdial-call` + +**Component level:** +- **Pattern:** `component-cc-` +- **Examples:** + - `component-cc-user-state` + - `component-cc-station-login` + - `component-cc-incoming-task` + - `component-cc-task-list` + - `component-cc-call-control` + - `component-cc-call-control-cad` + - `component-cc-out-dial-call` + +**Naming rules:** +- All lowercase +- Words separated by hyphens +- Must contain a hyphen (Web Component standard) +- Prefix indicates layer (`widget-cc-` vs `component-cc-`) + +--- + +## Usage Patterns + +### **HTML Usage** + +```html + + + + + + + + + + + + + + + + +``` + +--- + +### **React Component Import** + +```typescript +// Import React components (not Web Components) +import {StationLogin, UserState, store} from '@webex/cc-widgets'; + +function App() { + useEffect(() => { + store.init({ + access_token: 'YOUR_TOKEN', + webexConfig: {...} + }); + }, []); + + return ( +
+ console.log(state)} /> + console.log('Logged in')} /> +
+ ); +} +``` + +--- + +## Store Initialization Pattern + +### **Store Export** + +```typescript +// cc-widgets/src/wc.ts +import store from '@webex/cc-store'; + +// ... component registrations ... + +export {store}; // Export store for external init +``` + +**Usage:** +```typescript +import {store} from '@webex/cc-widgets/wc'; + +// Initialize store before using widgets +await store.init({ + access_token: 'token', + webexConfig: {...} +}); + +// Or with existing webex instance +await store.init({ + webex: { + cc: ccSDK, + logger: logger + } +}); +``` + +--- + +## React Component Export + +### **Component-Only Export** + +```typescript +// cc-widgets/src/index.ts +import {StationLogin} from '@webex/cc-station-login'; +import {UserState} from '@webex/cc-user-state'; +import {IncomingTask, TaskList, CallControl, CallControlCAD, OutdialCall} from '@webex/cc-task'; +import store from '@webex/cc-store'; +import '@momentum-ui/core/css/momentum-ui.min.css'; + +export {StationLogin, UserState, IncomingTask, CallControl, CallControlCAD, TaskList, OutdialCall, store}; +``` + +**Purpose:** +- React consumers import from `@webex/cc-widgets` (not `/wc`) +- Gets React components, not Web Components +- Also exports store for initialization +- Includes momentum UI styles + +--- + +## Key Conventions to Enforce + +### ✅ DO: +1. **Use r2wc version 2.0.3** for consistent behavior +2. **Check for duplicate registrations** with `customElements.get(name)` +3. **Use descriptive names** with `widget-cc-` or `component-cc-` prefix +4. **Map all component props** in r2wc config +5. **Use `'json'` type** for complex objects/arrays +6. **Use `'function'` type** for all callbacks +7. **Export store** from `wc.ts` for external initialization +8. **Batch register** widgets in cc-widgets (loop pattern) +9. **Individual register** components in cc-components (explicit pattern) +10. **Include both exports** in package.json (`.` and `./wc`) + +### ❌ DON'T: +1. **Don't skip duplicate checks** - may cause runtime errors +2. **Don't use uppercase** in custom element names +3. **Don't omit hyphens** in custom element names (required by spec) +4. **Don't forget prop mappings** - unmapped props won't work +5. **Don't use wrong type** - `'json'` for objects, `'function'` for callbacks +6. **Don't mix React and WC imports** - choose one per consumer +7. **Don't forget store initialization** - widgets won't work without it +8. **Don't register twice** - causes "already defined" errors + +--- + +## Anti-Patterns Found + +### 1. **Empty props object** +```typescript +const WebOutdialCall = r2wc(OutdialCall, {}); +``` + +**Issue:** Component has no props to configure. +**Recommendation:** If component truly has no props, document why. Otherwise, expose necessary props. + +--- + +### 2. **Type assertion for commonProps** +```typescript +const commonPropsForCallControl: Record = { + currentTask: 'json', + // ... +}; +``` + +**Recommendation:** This is actually a good pattern for shared props. Could extract to a type helper. + +--- + +## Examples to Reference + +### Example 1: Widget-Level Web Component +```typescript +import r2wc from '@r2wc/react-to-web-component'; +import {UserState} from '@webex/cc-user-state'; + +const WebUserState = r2wc(UserState, { + props: { + onStateChange: 'function', + }, +}); + +if (!customElements.get('widget-cc-user-state')) { + customElements.define('widget-cc-user-state', WebUserState); +} +``` + +### Example 2: Component-Level Web Component +```typescript +import r2wc from '@r2wc/react-to-web-component'; +import UserStateComponent from './components/UserState/user-state'; + +const WebUserState = r2wc(UserStateComponent, { + props: { + idleCodes: 'json', + setAgentStatus: 'function', + isSettingAgentStatus: 'boolean', + elapsedTime: 'number', + currentState: 'string', + customState: 'json', + logger: 'function', + }, +}); + +if (!customElements.get('component-cc-user-state')) { + customElements.define('component-cc-user-state', WebUserState); +} +``` + +### Example 3: Shared Props Pattern +```typescript +const commonPropsForCallControl: Record = { + currentTask: 'json', + audioRef: 'json', + wrapupCodes: 'json', + toggleHold: 'function', + endCall: 'function', + isHeld: 'boolean', +}; + +const WebCallControl = r2wc(CallControlComponent, { + props: commonPropsForCallControl, +}); + +const WebCallControlCAD = r2wc(CallControlCADComponent, { + props: commonPropsForCallControl, +}); +``` + +### Example 4: Batch Registration +```typescript +const components = [ + {name: 'widget-cc-user-state', component: WebUserState}, + {name: 'widget-cc-station-login', component: WebStationLogin}, + {name: 'widget-cc-task-list', component: WebTaskList}, +]; + +components.forEach(({name, component}) => { + if (!customElements.get(name)) { + customElements.define(name, component); + } +}); +``` + +--- + +## Files Analyzed + +1. `/packages/contact-center/cc-widgets/src/wc.ts` (75 lines) +2. `/packages/contact-center/cc-widgets/src/index.ts` (8 lines) +3. `/packages/contact-center/cc-components/src/wc.ts` (109 lines) +4. `/packages/contact-center/cc-widgets/package.json` (100 lines) + +--- + +## Related Documentation + +- [React Patterns](./react-patterns.md) - React component patterns +- [TypeScript Patterns](./typescript-patterns.md) - Prop type definitions +- [Testing Patterns](./testing-patterns.md) - Web Component testing + From 8d5b2c07034b5d59bfcb18ffdb7a437be1f88213 Mon Sep 17 00:00:00 2001 From: Rankush Kumar Date: Fri, 12 Dec 2025 09:59:10 +0530 Subject: [PATCH 2/2] docs(patterns): rewrite pattern docs as prescriptive LLM guides - Remove 'Files Analyzed' sections - Remove 'Anti-Patterns Found' sections - Remove analysis/summary paragraphs - Make all guidance prescriptive (MUST, ALWAYS, NEVER) - Focus on actionable patterns for LLM code generation --- ai-docs/patterns/mobx-patterns.md | 802 +++++------------- ai-docs/patterns/react-patterns.md | 898 ++++----------------- ai-docs/patterns/testing-patterns.md | 879 +++++--------------- ai-docs/patterns/typescript-patterns.md | 503 +++--------- ai-docs/patterns/web-component-patterns.md | 649 +++------------ 5 files changed, 787 insertions(+), 2944 deletions(-) diff --git a/ai-docs/patterns/mobx-patterns.md b/ai-docs/patterns/mobx-patterns.md index dc16b266b..1999297ae 100644 --- a/ai-docs/patterns/mobx-patterns.md +++ b/ai-docs/patterns/mobx-patterns.md @@ -1,740 +1,308 @@ # MobX Patterns ---- -Technology: MobX -Configuration: See [package.json](../../packages/contact-center/store/package.json) for version -Dependencies: See individual [package.json](../../packages/contact-center/*/package.json) files -Scope: Repository-wide -Last Updated: 2025-11-23 ---- - -> **For LLM Agents**: Add this file to context when working on MobX store, observables, or state management. -> -> **For Developers**: Update this file when committing MobX pattern changes. +> Quick reference for LLMs working with MobX state management in this repository. --- -## Summary +## Rules -The codebase uses **MobX 6** with a **singleton store pattern** wrapped in a `StoreWrapper` class. The architecture separates core store state (`Store`) from business logic and event handling (`StoreWrapper`). Components consume the store using the `observer` HOC from `mobx-react-lite`, and state mutations are wrapped in `runInAction` for consistency. +- **MUST** use the singleton store pattern via `Store.getInstance()` +- **MUST** wrap widgets with `observer` HOC from `mobx-react-lite` +- **MUST** use `runInAction` for all state mutations +- **MUST** access store via `import store from '@webex/cc-store'` +- **MUST** mark state properties as `observable` +- **MUST** use `makeObservable` in store constructor +- **NEVER** mutate state outside of `runInAction` +- **NEVER** access store directly in presentational components +- **NEVER** create multiple store instances --- -## Store Architecture - -### 1. **Singleton Pattern** +## Store Singleton Pattern -**Core Store Class (`Store`):** ```typescript -class Store implements IStore { - private static instance: Store; +// store.ts +import { makeObservable, observable, action, runInAction } from 'mobx'; - constructor() { - makeAutoObservable(this, { - cc: observable.ref, - }); +class Store { + private static instance: Store; + + // Observable state + @observable agentId: string = ''; + @observable currentState: string = ''; + @observable idleCodes: IdleCode[] = []; + @observable isLoggedIn: boolean = false; + + private constructor() { + makeObservable(this); } - - public static getInstance(): Store { + + static getInstance(): Store { if (!Store.instance) { - console.log('Creating new store instance'); Store.instance = new Store(); } return Store.instance; } } -``` - -**Pattern:** Single instance of `Store` created and shared across the application. - ---- - -### 2. **StoreWrapper Pattern** - -**Wrapper Class:** -```typescript -class StoreWrapper implements IStoreWrapper { - store: IStore; - onIncomingTask: ({task}: {task: ITask}) => void; - onTaskRejected?: (task: ITask, reason: string) => void; - onErrorCallback?: (widgetName: string, error: Error) => void; - - constructor() { - this.store = Store.getInstance(); - } - - // Proxy all properties with getters - get cc() { return this.store.cc; } - get teams() { return this.store.teams; } - // ... 20+ more getters - - // Methods that modify state - setDeviceType = (option: string): void => { - this.store.deviceType = option; - }; - - setCurrentTask = (task: ITask | null): void => { - runInAction(() => { - this.store.currentTask = task; - }); - }; -} -const storeWrapper = new StoreWrapper(); -export default storeWrapper; +export default Store.getInstance(); ``` -**Purpose:** -- **Proxy pattern**: Wraps core `Store` with computed getters and business logic -- **Event handlers**: Manages SDK event listeners and callbacks -- **Filtered data**: Transforms store data (e.g., filtering idle codes) -- **Single export**: `@webex/cc-store` exports the wrapper instance, not the raw store - --- -## MobX Observable Patterns - -### 1. **makeAutoObservable** - -**Convention:** Use `makeAutoObservable` for automatic observability +## Observable Decorator Pattern ```typescript -constructor() { - makeAutoObservable(this, { - cc: observable.ref, - }); -} -``` +import { observable, makeObservable } from 'mobx'; -**Special handling:** -- `cc: observable.ref` - Contact center SDK instance treated as reference (not deep observable) -- All other properties automatically made observable -- All methods automatically made actions - ---- - -### 2. **Observable Properties** - -**Direct assignment for simple properties:** -```typescript class Store { - teams: Team[] = []; - loginOptions: string[] = []; - agentId: string = ''; - currentTheme: string = 'LIGHT'; - isAgentLoggedIn = false; - deviceType: string = ''; - dialNumber: string = ''; - currentState: string = ''; - customState: ICustomState = null; - taskList: Record = {}; - featureFlags: {[key: string]: boolean} = {}; - // ... 20+ more observables + @observable agentId: string = ''; + @observable teams: Team[] = []; + @observable currentState: string = 'Available'; + @observable tasks: ITask[] = []; + + constructor() { + makeObservable(this); + } } ``` -**Pattern:** All class properties are observable by default when using `makeAutoObservable`. - --- -### 3. **runInAction for Mutations** +## runInAction Pattern -**Pattern:** Wrap state mutations in `runInAction` for batched updates +**ALWAYS use runInAction for state mutations:** -**Simple setters:** ```typescript -setDeviceType = (option: string): void => { - this.store.deviceType = option; // Direct mutation (auto-action) -}; -``` +import { runInAction } from 'mobx'; -**Complex mutations:** -```typescript -setCurrentTask = (task: ITask | null, isClicked: boolean = false): void => { +// ✅ CORRECT +const handleLogin = async () => { + const result = await cc.login(); runInAction(() => { - let isSameTask = false; - if (task && this.currentTask) { - isSameTask = task.data.interactionId === this.currentTask.data.interactionId; - } - - this.store.currentTask = task ? - Object.assign(Object.create(Object.getPrototypeOf(task)), task) : null; - - if (this.onTaskSelected && !isSameTask && typeof isClicked !== 'undefined') { - this.onTaskSelected(task, isClicked); - } + store.agentId = result.agentId; + store.isLoggedIn = true; + store.teams = result.teams; }); }; -``` -**Guideline:** -- **Simple setters** (single property): Direct mutation is fine with `makeAutoObservable` -- **Complex logic** (multiple properties, conditionals): Use `runInAction` -- **Event handlers**: Always use `runInAction` for consistency - ---- - -### 4. **Computed Values (via Getters)** - -**Pattern:** Use getters in `StoreWrapper` to transform/filter store data - -```typescript -get idleCodes() { - return this.store.idleCodes.filter((code) => { - return Object.values(ERROR_TRIGGERING_IDLE_CODES).includes(code.name) || - !code.isSystem; - }); -} +// ❌ WRONG - Direct mutation +const handleLogin = async () => { + const result = await cc.login(); + store.agentId = result.agentId; // NOT ALLOWED +}; ``` -**Convention:** Getters in `StoreWrapper` act as computed values (automatically tracked by MobX). - --- -## Observer Pattern - -### 1. **observer HOC from mobx-react-lite** +## Observer HOC Pattern -**Pattern:** Wrap functional components with `observer` to track observables +**ALWAYS wrap widgets that access store with observer:** ```typescript -import {observer} from 'mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import store from '@webex/cc-store'; -const StationLoginInternal: React.FunctionComponent = observer( - ({onLogin, onLogout, profileMode}) => { - const { - cc, - teams, - loginOptions, - logger, - isAgentLoggedIn, - deviceType, - dialNumber, - setDeviceType, - setDialNumber, - } = store; - - return ; - } -); -``` - -**Convention:** -- Import store singleton at top of file -- Destructure needed properties inside observer component -- Component auto-rerenders when used observables change - ---- - -### 2. **Two-Layer Component Pattern** - -**Pattern:** Split components into Internal (observer) + Wrapper (ErrorBoundary) - -```typescript -// Internal component with observer -const StationLoginInternal: React.FunctionComponent = observer( - ({onLogin, onLogout, onCCSignOut, profileMode}) => { - const {cc, teams, loginOptions, logger, isAgentLoggedIn} = store; - // ... component logic - return ; - } -); - -// Wrapper component with ErrorBoundary -const StationLogin: React.FunctionComponent = (props) => { +const UserStateInternal: React.FC = observer((props) => { + // Access store - component will re-render when these change + const { currentState, idleCodes, agentId } = store; + return ( - <>} - onError={(error: Error) => { - if (store.onErrorCallback) store.onErrorCallback('StationLogin', error); - }} - > - - + ); -}; - -export {StationLogin}; +}); ``` -**Purpose:** -- **Internal**: Handles MobX reactivity -- **Wrapper**: Handles error boundaries -- **Benefit**: Error boundary doesn't need to be an observer - --- -## Action Patterns - -### 1. **Simple Setters** - -**Pattern:** Arrow functions for simple mutations +## Store Import Pattern ```typescript -setDeviceType = (option: string): void => { - this.store.deviceType = option; -}; +// ✅ CORRECT - Import singleton +import store from '@webex/cc-store'; -setDialNumber = (input: string): void => { - this.store.dialNumber = input; -}; +const MyWidget = observer(() => { + const { agentId, teams } = store; + // ... +}); -setShowMultipleLoginAlert = (value: boolean): void => { - this.store.showMultipleLoginAlert = value; -}; +// ❌ WRONG - Creating new instance +import { Store } from '@webex/cc-store'; +const store = new Store(); // NOT ALLOWED ``` --- -### 2. **Complex Actions with runInAction** - -**Pattern:** Group related mutations in `runInAction` +## Action Pattern ```typescript -refreshTaskList = (): void => { - runInAction(() => { - this.store.taskList = this.store.cc.taskManager.getAllTasks(); - const taskListKeys = Object.keys(this.store.taskList); - - if (taskListKeys.length === 0) { - if (this.currentTask) { - this.handleTaskRemove(this.currentTask); - } - this.setCurrentTask(null); - this.setState({reset: true}); - } else if (this.currentTask && this.store.taskList[this.currentTask.data.interactionId]) { - this.setCurrentTask(this.store.taskList[this.currentTask?.data?.interactionId]); - } else if (taskListKeys.length > 0) { - if (this.currentTask) { - this.handleTaskRemove(this.currentTask); - } - this.setCurrentTask(this.store.taskList[taskListKeys[0]]); - } - }); -}; -``` - ---- +import { action, makeObservable } from 'mobx'; -### 3. **Async Actions** - -**Pattern:** Promises with `runInAction` in `.then()` or use `runInAction` inside async functions - -```typescript -registerCC(webex?: WithWebex['webex']): Promise { - // ... validation +class Store { + @observable currentState: string = ''; - return this.cc - .register() - .then((response: Profile) => { - // Implicit action from makeAutoObservable - this.featureFlags = getFeatureFlags(response); - this.teams = response.teams; - this.loginOptions = response.webRtcEnabled - ? response.loginVoiceOptions - : response.loginVoiceOptions.filter((option) => option !== 'BROWSER'); - this.agentId = response.agentId; - this.isAgentLoggedIn = response.isAgentLoggedIn; - // ... more assignments - }) - .catch((error) => { - this.logger.error(`Registration failed - ${error}`); - return Promise.reject(error); - }); + constructor() { + makeObservable(this); + } + + @action + setCurrentState(state: string) { + this.currentState = state; + } + + @action + reset() { + this.currentState = ''; + this.agentId = ''; + this.isLoggedIn = false; + } } ``` -**Note:** With `makeAutoObservable`, mutations inside `then()` are automatically wrapped as actions. However, for clarity in event handlers, prefer explicit `runInAction`. - --- -## Event Handling Patterns - -### 1. **SDK Event Listeners** - -**Pattern:** Register event listeners in `setupIncomingTaskHandler` +## Computed Pattern ```typescript -setupIncomingTaskHandler = (ccSDK: IContactCenter) => { - const addEventListeners = () => { - ccSDK.on(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); - ccSDK.on(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); - ccSDK.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); - ccSDK.on(TASK_EVENTS.TASK_MERGED, this.handleTaskMerged); - ccSDK.on(CC_EVENTS.AGENT_MULTI_LOGIN, this.handleMultiLoginCloseSession); - ccSDK.on(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogOut); - }; - - const removeEventListeners = () => { - ccSDK.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); - ccSDK.off(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); - // ... more cleanup - }; -}; -``` +import { observable, computed, makeObservable } from 'mobx'; -**Pattern:** -- Define `addEventListeners` and `removeEventListeners` functions -- Register event handlers as class methods (arrow functions for `this` binding) -- Always provide cleanup (remove listeners) - ---- - -### 2. **Task Event Listeners** - -**Pattern:** Register task-specific events with `registerTaskEventListeners` - -```typescript -private registerTaskEventListeners = (task: ITask): void => { - task.on(TASK_EVENTS.TASK_END, this.handleTaskEnd); - task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); - task.on(TASK_EVENTS.TASK_REJECT, (reason) => this.handleTaskReject(task, reason)); - task.on(TASK_EVENTS.AGENT_WRAPPEDUP, this.refreshTaskList); - task.on(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); - // ... 20+ more task events +class Store { + @observable tasks: ITask[] = []; - if (this.deviceType === DEVICE_TYPE_BROWSER) { - task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); + constructor() { + makeObservable(this); } -}; -``` - -**Cleanup pattern:** -```typescript -handleTaskRemove = (taskToRemove: ITask) => { - if (taskToRemove) { - taskToRemove.off(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); - taskToRemove.off(TASK_EVENTS.TASK_END, this.handleTaskEnd); - // ... remove all listeners + + @computed + get activeTasks(): ITask[] { + return this.tasks.filter(task => task.status === 'active'); } - runInAction(() => { - this.setCurrentTask(null); - this.setState({reset: true}); - this.refreshTaskList(); - }); -}; + + @computed + get taskCount(): number { + return this.tasks.length; + } +} ``` --- -### 3. **Event Handlers Update Store** - -**Pattern:** Event handlers modify store state using `runInAction` +## Event Handling with Store Pattern ```typescript -handleTaskAssigned = (event) => { - const task = event; - if (this.onTaskAssigned) { - this.onTaskAssigned(task); - } - runInAction(() => { - this.setCurrentTask(task); - this.setState({ - developerName: ENGAGED_LABEL, - name: ENGAGED_USERNAME, +import { runInAction } from 'mobx'; +import store from '@webex/cc-store'; + +// In helper.ts or hook +useEffect(() => { + const handleTaskIncoming = (task: ITask) => { + runInAction(() => { + store.incomingTask = task; }); - }); -}; + }; -handleStateChange = (data) => { - if (data && typeof data === 'object' && data.type === 'AgentStateChangeSuccess') { - const DEFAULT_CODE = '0'; - this.setCurrentState(data.auxCodeId?.trim() !== '' ? data.auxCodeId : DEFAULT_CODE); - this.setLastStateChangeTimestamp(data.lastStateChangeTimestamp); - this.setLastIdleCodeChangeTimestamp(data.lastIdleCodeChangeTimestamp); - } -}; + store.cc.on(TASK_EVENTS.TASK_INCOMING, handleTaskIncoming); + + return () => { + store.cc.off(TASK_EVENTS.TASK_INCOMING, handleTaskIncoming); + }; +}, []); ``` --- -## Store Initialization Pattern - -### 1. **Two-Step Initialization** - -**Pattern:** `init()` → `registerCC()` +## Store Wrapper Pattern ```typescript -init(options: InitParams): Promise { - return this.store.init(options, this.setupIncomingTaskHandler); -} +// storeEventsWrapper.ts +import { runInAction } from 'mobx'; +import store from './store'; -// In Store class: -init(options: InitParams, setupEventListeners): Promise { - if ('webex' in options) { - setupEventListeners(options.webex.cc); - return this.registerCC(options.webex); - } - - return new Promise((resolve, reject) => { - const webex = Webex.init({ - config: options.webexConfig, - credentials: { access_token: options.access_token }, +export const initStoreEventListeners = () => { + store.cc.on(CC_EVENTS.AGENT_STATE_CHANGED, (data) => { + runInAction(() => { + store.currentState = data.state; + store.lastStateChangeTimestamp = Date.now(); }); + }); - webex.once('ready', () => { - setupEventListeners(webex.cc); - this.registerCC(webex) - .then(() => resolve()) - .catch((error) => reject(error)); + store.cc.on(CC_EVENTS.AGENT_LOGOUT_SUCCESS, () => { + runInAction(() => { + store.reset(); }); }); -} -``` - -**Flow:** -1. Consumer calls `store.init(options)` -2. Store initializes SDK (if needed) -3. `setupEventListeners` registers SDK event handlers -4. `registerCC()` fetches agent profile and populates store -5. Promise resolves when store is ready - ---- - -### 2. **Callback Registration Pattern** - -**Pattern:** Store exposes callback setters for widget events - -```typescript -setIncomingTaskCb = (callback: ({task}: {task: ITask}) => void): void => { - this.onIncomingTask = callback; -}; - -setTaskRejected = (callback: ((task: ITask, reason: string) => void) | undefined): void => { - this.onTaskRejected = callback; -}; - -setOnError = (callback: (widgetName: string, error: Error) => void) => { - this.onErrorCallback = callback; }; ``` -**Usage:** -- Widgets register callbacks to be notified of events -- Store invokes callbacks when events occur -- Pattern similar to event emitters - ---- - -## Store Usage in Components - -### 1. **Import and Destructure** - -```typescript -import store from '@webex/cc-store'; - -const UserStateInternal: React.FunctionComponent = observer( - ({onStateChange}) => { - const { - cc, - idleCodes, - agentId, - currentState, - lastStateChangeTimestamp, - customState, - logger, - } = store; - - // Component logic - } -); -``` - ---- - -### 2. **Pass to Custom Hooks** - -```typescript -const UserStateInternal: React.FunctionComponent = observer( - ({onStateChange}) => { - const { - cc, - idleCodes, - agentId, - currentState, - customState, - lastStateChangeTimestamp, - logger, - lastIdleCodeChangeTimestamp, - } = store; - - const props = { - ...useUserState({ - idleCodes, - agentId, - cc, - currentState, - customState, - lastStateChangeTimestamp, - logger, - onStateChange, - lastIdleCodeChangeTimestamp, - }), - customState, - logger, - }; - - return ; - } -); -``` - -**Pattern:** -- Observer component extracts store values -- Passes to custom hook for business logic -- Hook returns computed values/handlers -- Component renders with combined props - --- -### 3. **Store Mutations from Hooks** - -**Pattern:** Hooks can directly call store setters +## Store Access in Widgets ```typescript -// In helper.ts (custom hook) +// Widget file +import { observer } from 'mobx-react-lite'; import store from '@webex/cc-store'; -export const useUserState = ({currentState, logger, ...}) => { - const setAgentStatus = (selectedCode) => { - logger.info('Updating currentState'); - store.setCurrentState(selectedCode); // Direct store mutation - }; - - const updateAgentState = (selectedCode) => { - // ... business logic - return cc.setAgentState({...}) - .then((response) => { - store.setLastStateChangeTimestamp(response.data.lastStateChangeTimestamp); - store.setLastIdleCodeChangeTimestamp(response.data.lastIdleCodeChangeTimestamp); - }); - }; - - return { setAgentStatus, isSettingAgentStatus, elapsedTime }; -}; +const StationLoginInternal = observer(() => { + // Destructure what you need from store + const { + cc, + teams, + dialNumbers, + isAgentLoggedIn, + loginConfig, + } = store; + + // Use in component + return ( + + ); +}); ``` -**Guideline:** Hooks can call store setters, but should receive store values as props (not import store directly in hook for testability). - --- -## Key Conventions to Enforce - -### ✅ DO: -1. **Use `makeAutoObservable`** in Store constructor with minimal overrides -2. **Use `observable.ref`** for SDK instances and external objects -3. **Wrap complex mutations** in `runInAction` for batched updates -4. **Use `observer` HOC** for all components that read store state -5. **Destructure store** at the top of observer components -6. **Use arrow functions** for store methods to preserve `this` context -7. **Register and cleanup** SDK event listeners properly -8. **Use singleton pattern** for store (single instance) -9. **Export store wrapper** instance, not the class -10. **Separate Internal (observer) and Wrapper (ErrorBoundary)** components - -### ❌ DON'T: -1. **Don't mutate store outside of actions** when using `runInAction` -2. **Don't use makeObservable** - prefer `makeAutoObservable` -3. **Don't make SDK objects deeply observable** - use `observable.ref` -4. **Don't forget to remove event listeners** in cleanup -5. **Don't import store in non-observer components** (only in observer components) -6. **Don't use `@observable` decorators** - use `makeAutoObservable` instead -7. **Don't create multiple store instances** - singleton only +## Async Action Pattern ---- - -## Anti-Patterns Found - -### 1. **Inconsistent runInAction usage** -Some simple setters use direct mutation, others use `runInAction`. With `makeAutoObservable`, both work, but consistency would improve readability. - -**Recommendation:** Document when to use `runInAction` vs direct mutation. - ---- - -### 2. **Deep task cloning in setCurrentTask** ```typescript -this.store.currentTask = task ? - Object.assign(Object.create(Object.getPrototypeOf(task)), task) : null; -``` - -**Reason:** Preserving task prototype methods while creating observable copy. -**Recommendation:** Document this pattern for objects with methods. +import { runInAction } from 'mobx'; ---- - -## Examples to Reference - -### Example 1: Store Singleton with makeAutoObservable -```typescript -class Store implements IStore { - private static instance: Store; - teams: Team[] = []; - isAgentLoggedIn = false; +const fetchData = async () => { + // Set loading state + runInAction(() => { + store.isLoading = true; + store.error = null; + }); - constructor() { - makeAutoObservable(this, { - cc: observable.ref, + try { + const result = await store.cc.fetchTeams(); + + // Update with result + runInAction(() => { + store.teams = result.teams; + store.isLoading = false; }); - } - - public static getInstance(): Store { - if (!Store.instance) { - Store.instance = new Store(); - } - return Store.instance; - } -} -``` - -### Example 2: Observer Component with Store -```typescript -import {observer} from 'mobx-react-lite'; -import store from '@webex/cc-store'; - -const MyWidget = observer(({onEvent}) => { - const {cc, logger, currentState, setCurrentState} = store; - - return
setCurrentState('Available')}> - Current: {currentState} -
; -}); -``` - -### Example 3: Event Handler with runInAction -```typescript -handleTaskAssigned = (event) => { - const task = event; - runInAction(() => { - this.setCurrentTask(task); - this.setState({ - developerName: ENGAGED_LABEL, - name: ENGAGED_USERNAME, + } catch (error) { + // Handle error + runInAction(() => { + store.error = error; + store.isLoading = false; }); - }); + } }; ``` --- -## Files Analyzed - -1. `/packages/contact-center/store/src/store.ts` (167 lines) -2. `/packages/contact-center/store/src/storeEventsWrapper.ts` (819 lines) -3. `/packages/contact-center/store/src/index.ts` (5 lines) -4. `/packages/contact-center/station-login/src/station-login/index.tsx` (77 lines) -5. `/packages/contact-center/user-state/src/user-state/index.tsx` (52 lines) -6. `/packages/contact-center/user-state/src/helper.ts` (296 lines) -7. `/packages/contact-center/task/src/IncomingTask/index.tsx` -8. `/packages/contact-center/task/src/TaskList/index.tsx` -9. `/packages/contact-center/task/src/CallControl/index.tsx` - ---- - -## Related Documentation - -- [TypeScript Patterns](./typescript-patterns.md) - Store type definitions -- [React Patterns](./react-patterns.md) - Observer components -- [Testing Patterns](./testing-patterns.md) - Mocking MobX store +## Related +- [React Patterns](./react-patterns.md) +- [TypeScript Patterns](./typescript-patterns.md) +- [Testing Patterns](./testing-patterns.md) diff --git a/ai-docs/patterns/react-patterns.md b/ai-docs/patterns/react-patterns.md index e04d588a3..c55382d31 100644 --- a/ai-docs/patterns/react-patterns.md +++ b/ai-docs/patterns/react-patterns.md @@ -1,827 +1,283 @@ # React Patterns ---- -Technology: React -Configuration: See [package.json](../../packages/contact-center/*/package.json) for version -Dependencies: See individual [package.json](../../packages/contact-center/*/package.json) files -Scope: Repository-wide -Last Updated: 2025-11-23 ---- - -> **For LLM Agents**: Add this file to context when working on React components, hooks, or component composition. -> -> **For Developers**: Update this file when committing React pattern changes. +> Quick reference for LLMs working with React in this repository. --- -## Summary +## Rules -The codebase uses **React 18+ functional components** with **hooks** exclusively. The architecture follows a **three-layer pattern**: Widget components (MobX observers) → Custom hooks (business logic) → Presentational components (cc-components). Every widget is wrapped in `ErrorBoundary` from `react-error-boundary` with telemetry reporting. Custom hooks encapsulate SDK interactions, event listeners, and state management. +- **MUST** use functional components with hooks (no class components) +- **MUST** wrap every widget with `ErrorBoundary` from `react-error-boundary` +- **MUST** use the three-layer pattern: Widget → Hook → Component +- **MUST** use `observer` HOC from `mobx-react-lite` for widgets that access store +- **MUST** keep presentational components in `cc-components` package +- **MUST** encapsulate business logic in custom hooks (`helper.ts`) +- **NEVER** access store directly in presentational components +- **NEVER** call SDK methods directly in components (use hooks) +- **NEVER** use class components --- -## Component Architecture - -### 1. **Three-Layer Component Pattern** +## Three-Layer Architecture -**Layer 1: Widget Components (Observers)** -- Located in widget packages (`station-login`, `user-state`, `task/*`) -- Import and observe store state -- Wrapped with `ErrorBoundary` -- Minimal logic, delegate to custom hooks - -**Layer 2: Custom Hooks** -- Located in `helper.ts` files in each widget package -- Encapsulate business logic, SDK calls, event listeners -- Manage local state with `useState`, `useRef` -- Return handlers and computed values - -**Layer 3: Presentational Components** -- Located in `cc-components` package -- Pure UI rendering with props -- No store access, no SDK interactions -- Reusable across widgets +``` +┌─────────────────────────────────────┐ +│ Widget (observer) │ ← MobX observer, ErrorBoundary wrapper +│ packages/*/src/{widget}/index.tsx │ +├─────────────────────────────────────┤ +│ Custom Hook │ ← Business logic, SDK calls, events +│ packages/*/src/helper.ts │ +├─────────────────────────────────────┤ +│ Presentational Component │ ← Pure UI, props only +│ packages/cc-components/src/... │ +└─────────────────────────────────────┘ +``` --- ## Error Boundary Pattern -### **Standard Error Boundary Wrapper** - -**Every widget follows this exact pattern:** +**ALWAYS wrap widgets with this pattern:** ```typescript -import {ErrorBoundary} from 'react-error-boundary'; +import { ErrorBoundary } from 'react-error-boundary'; +import { observer } from 'mobx-react-lite'; import store from '@webex/cc-store'; // Internal observer component -const WidgetInternal: React.FunctionComponent = observer((props) => { - // Widget logic +const UserStateInternal: React.FC = observer((props) => { + // Widget logic here + return ; }); // External wrapper with ErrorBoundary -const Widget: React.FunctionComponent = (props) => { +const UserState: React.FC = (props) => { return ( <>} onError={(error: Error) => { - if (store.onErrorCallback) store.onErrorCallback('WidgetName', error); + if (store.onErrorCallback) { + store.onErrorCallback('UserState', error); + } }} > - + ); }; -export {Widget}; -``` - -**Key elements:** -1. **Two-component split**: `WidgetInternal` (observer) + `Widget` (wrapper) -2. **Empty fallback**: `fallbackRender={() => <>}` - fails gracefully with no UI -3. **Error telemetry**: `store.onErrorCallback('WidgetName', error)` - reports to metrics -4. **Conditional callback**: `if (store.onErrorCallback)` - only call if registered - -**Benefits:** -- Isolates errors to individual widgets -- Prevents entire app crashes -- Reports errors for debugging/analytics -- Clean separation between observer and error handling - ---- - -## Observer Pattern - -### **MobX Observer Usage** - -```typescript -import {observer} from 'mobx-react-lite'; -import store from '@webex/cc-store'; - -const StationLoginInternal: React.FunctionComponent = observer( - ({onLogin, onLogout, onCCSignOut, profileMode}) => { - // 1. Destructure store values - const { - cc, - teams, - loginOptions, - logger, - isAgentLoggedIn, - deviceType, - dialNumber, - setDeviceType, - setDialNumber, - teamId, - setTeamId, - } = store; - - // 2. Call custom hook with store values + props - const result = useStationLogin({ - cc, - onLogin, - onLogout, - logger, - deviceType, - dialNumber, - teamId, - isAgentLoggedIn, - onCCSignOut, - }); - - // 3. Compose props from store + hook + props - const props: StationLoginComponentProps = { - ...result, - setDeviceType, - setDialNumber, - teams, - loginOptions, - deviceType, - isAgentLoggedIn, - logger, - profileMode, - }; - - // 4. Render presentational component - return ; - } -); +export { UserState }; ``` -**Pattern breakdown:** -1. **Import store** - singleton instance from `@webex/cc-store` -2. **Wrap with observer** - automatically tracks store reads -3. **Destructure store** - only extract what's needed -4. **Pass to hook** - combine store values with props -5. **Compose final props** - merge store, hook results, and incoming props -6. **Render dumb component** - pass everything to presentational layer - --- -## Custom Hooks Patterns - -### **1. Event Listener Hook Pattern** - -```typescript -export const useIncomingTask = (props: UseTaskProps) => { - const {onAccepted, onRejected, deviceType, incomingTask, logger} = props; - - // Define callbacks - const taskAssignCallback = () => { - try { - if (onAccepted) onAccepted({task: incomingTask}); - } catch (error) { - logger?.error(`Error in taskAssignCallback - ${error.message}`); - } - }; - - const taskRejectCallback = () => { - try { - if (onRejected) onRejected({task: incomingTask}); - } catch (error) { - logger?.error(`Error in taskRejectCallback - ${error.message}`); - } - }; - - // Register event listeners on mount - useEffect(() => { - try { - if (!incomingTask) return; - - // Register listeners - store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask.data.interactionId); - - // Cleanup on unmount - return () => { - try { - store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask.data.interactionId); - } catch (error) { - logger?.error(`Error in cleanup - ${error.message}`); - } - }; - } catch (error) { - logger?.error(`Error in useIncomingTask useEffect - ${error.message}`); - } - }, [incomingTask]); - - // Return handlers - const accept = () => { - try { - if (!incomingTask?.data.interactionId) return; - incomingTask.accept().catch((error) => { - logger.error(`Error accepting task: ${error}`); - }); - } catch (error) { - logger?.error(`Error in accept - ${error.message}`); - } - }; - - return { incomingTask, accept, reject }; -}; -``` +## Custom Hook Pattern -**Key patterns:** -- **Event listener registration** in `useEffect` -- **Cleanup function** to remove listeners on unmount -- **Dependency array** `[incomingTask]` - re-register when task changes -- **Try-catch everywhere** - defensive error handling -- **Logger context** - every log includes module + method -- **Return handlers** - expose actions to component - ---- - -### **2. Web Worker Hook Pattern** +**ALWAYS encapsulate business logic in hooks:** ```typescript -export const useUserState = ({currentState, lastStateChangeTimestamp, logger, ...}) => { - const [elapsedTime, setElapsedTime] = useState(0); - const workerRef = useRef(null); - - // Define worker script inline - const workerScript = ` - let intervalId; - const startTimer = (startTime) => { - if (intervalId) clearInterval(intervalId); - intervalId = setInterval(() => { - const elapsedTime = Math.floor((Date.now() - startTime) / 1000); - self.postMessage({type: 'elapsedTime', elapsedTime}); - }, 1000); - }; - const stopTimer = () => { - if (intervalId) clearInterval(intervalId); - self.postMessage({type: 'stop'}); - }; - self.onmessage = (event) => { - if (event.data.type === 'start') { - startTimer(event.data.startTime); - } - if (event.data.type === 'stop') { - stopTimer(); - } - }; - `; +// helper.ts +export const useUserState = (props: UseUserStateProps) => { + const { cc, idleCodes, currentState, onStateChange } = props; + + const [selectedState, setSelectedState] = useState(null); + const [isLoading, setIsLoading] = useState(false); - // Initialize worker + // Event listener setup useEffect(() => { - try { - const blob = new Blob([workerScript], {type: 'application/javascript'}); - const workerUrl = URL.createObjectURL(blob); - workerRef.current = new Worker(workerUrl); - - workerRef.current.postMessage({type: 'start', startTime: Date.now()}); - - workerRef.current.onmessage = (event) => { - if (event.data.type === 'elapsedTime') { - setElapsedTime(event.data.elapsedTime > 0 ? event.data.elapsedTime : 0); - } - }; - } catch (error) { - logger?.error(`Error initializing worker - ${error.message}`); - } - - // Cleanup worker on unmount - return () => { - try { - if (workerRef.current) { - workerRef.current.postMessage({type: 'stop'}); - workerRef.current.terminate(); - workerRef.current = null; - } - } catch (error) { - logger?.error(`Error in cleanup - ${error.message}`); - } + const handleStateChange = (data: StateChangeEvent) => { + setSelectedState(data.state); + onStateChange?.(data.state); }; - }, []); - - // Reset timer when timestamp changes - useEffect(() => { - try { - if (workerRef.current && lastStateChangeTimestamp) { - workerRef.current.postMessage({type: 'reset', startTime: lastStateChangeTimestamp}); - } - } catch (error) { - logger?.error(`Error in timestamp useEffect - ${error.message}`); - } - }, [lastStateChangeTimestamp]); - - return { elapsedTime }; -}; -``` - -**Key patterns:** -- **Inline worker script** - defined as string template -- **Blob + Object URL** - create worker from script -- **useRef for worker** - persist across renders -- **Message-based communication** - `postMessage` / `onmessage` -- **Cleanup termination** - terminate worker on unmount -- **Multiple useEffects** - separate concerns (init vs. reset) - ---- -### **3. Callback Hook Pattern** - -```typescript -export const useCallControl = (props: useCallControlProps) => { - const {currentTask, onHoldResume, onEnd, logger, ...} = props; - - // Define callbacks that invoke prop callbacks - const holdCallback = () => { - try { - if (onHoldResume) { - onHoldResume({ - isHeld: true, - task: currentTask, - }); - } - } catch (error) { - logger?.error(`Error in holdCallback - ${error.message}`); - } - }; - - const endCallCallback = () => { - try { - if (onEnd) { - onEnd({ task: currentTask }); - } - } catch (error) { - logger?.error(`Error in endCallCallback - ${error.message}`); - } - }; - - // Register task event listeners - useEffect(() => { - if (!currentTask?.data?.interactionId) return; + cc.on(CC_EVENTS.AGENT_STATE_CHANGED, handleStateChange); - const interactionId = currentTask.data.interactionId; - - store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); - return () => { - store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); + cc.off(CC_EVENTS.AGENT_STATE_CHANGED, handleStateChange); }; - }, [currentTask]); + }, [cc, onStateChange]); - // Return action handlers - const toggleHold = (hold: boolean) => { + // Action handler + const handleSetState = useCallback(async (state: IdleCode) => { + setIsLoading(true); try { - if (hold) { - currentTask.hold().catch((e) => logger.error(`Hold failed: ${e}`)); - } else { - currentTask.resume().catch((e) => logger.error(`Resume failed: ${e}`)); - } + await cc.setAgentState(state); } catch (error) { - logger?.error(`Error in toggleHold - ${error.message}`); + console.error('Failed to set state:', error); + } finally { + setIsLoading(false); } - }; + }, [cc]); - return { toggleHold }; + return { + selectedState, + isLoading, + handleSetState, + }; }; ``` -**Pattern:** -- **Callback wrappers** - internal callbacks invoke props callbacks -- **Event-driven callbacks** - registered as task event listeners -- **Action handlers** - returned to component for user interactions -- **SDK call patterns** - always `.catch()` to handle errors - --- -### **4. useCallback and useMemo** +## Widget Pattern ```typescript -export const useCallControl = (props) => { - const {deviceType, featureFlags, currentTask, logger} = props; - const [buddyAgents, setBuddyAgents] = useState([]); - - // Memoized callback with dependencies - const loadBuddyAgents = useCallback(async () => { - try { - const agents = await store.getBuddyAgents(); - logger.info(`Loaded ${agents.length} buddy agents`); - setBuddyAgents(agents); - } catch (error) { - logger?.error(`Error loading buddy agents - ${error.message || error}`); - setBuddyAgents([]); - } - }, [logger]); - - const getEntryPoints = useCallback( - async ({page, pageSize, search}: PaginatedListParams) => { - try { - return await store.getEntryPoints({page, pageSize, search}); - } catch (error) { - logger?.error(`Error fetching entry points - ${error.message || error}`); - return {data: [], meta: {page: 0, totalPages: 0}}; - } - }, - [logger] - ); - - // Memoized computed value - const controlVisibility = useMemo( - () => getControlsVisibility(deviceType, featureFlags, currentTask, logger), - [deviceType, featureFlags, currentTask, logger] - ); - - return { loadBuddyAgents, getEntryPoints, controlVisibility }; -}; -``` - -**Pattern:** -- **useCallback** for async functions passed as props -- **useMemo** for expensive computations -- **Dependency arrays** carefully maintained -- **Error handling** in every async callback +// index.tsx +import { observer } from 'mobx-react-lite'; +import { ErrorBoundary } from 'react-error-boundary'; +import store from '@webex/cc-store'; +import { UserStateComponent } from '@webex/cc-components'; +import { useUserState } from '../helper'; +import { IUserStateProps } from './user-state.types'; ---- +const UserStateInternal: React.FC = observer((props) => { + const { onStateChange } = props; + + // Get data from store + const { cc, idleCodes, currentState, agentId } = store; -### **5. State Management with useRef** + // Use custom hook for logic + const { selectedState, isLoading, handleSetState } = useUserState({ + cc, + idleCodes, + currentState, + onStateChange, + }); -```typescript -export const useUserState = ({currentState, ...}) => { - const prevStateRef = useRef(currentState); + // Render presentational component + return ( + + ); +}); - useEffect(() => { - try { - if (prevStateRef.current !== currentState) { - // State changed, perform action - updateAgentState(currentState) - .then(() => { - prevStateRef.current = currentState; // Update ref after success - callOnStateChange(); - }) - .catch((error) => { - logger.error(`Failed to update state: ${error}`); - }); - } - } catch (error) { - logger?.error(`Error in currentState useEffect - ${error.message}`); - } - }, [currentState]); +const UserState: React.FC = (props) => ( + <>} + onError={(error) => store.onErrorCallback?.('UserState', error)} + > + + +); - return { ... }; -}; +export { UserState }; ``` -**Pattern:** -- **useRef for previous value** - detect changes -- **Update ref after success** - prevent re-triggering -- **Compare before action** - avoid unnecessary updates - --- -## Presentational Component Patterns - -### **1. Pure Functional Components** +## Presentational Component Pattern ```typescript -const UserStateComponent: React.FunctionComponent = (props) => { - const { - idleCodes, - setAgentStatus, - isSettingAgentStatus, - elapsedTime, - currentState, - customState, - logger, - } = props; - - // Local computed values with useMemo - const previousSelectableState = useMemo( - () => getPreviousSelectableState(idleCodes, logger), - [idleCodes, logger] - ); - - const selectedKey = getSelectedKey(customState, currentState, idleCodes, logger); - const items = buildDropdownItems(customState, idleCodes, currentState, logger); - +// cc-components/src/components/UserState/UserState.tsx +import React from 'react'; +import { IUserStateComponentProps } from './user-state.types'; + +export const UserStateComponent: React.FC = ({ + idleCodes, + currentState, + selectedState, + isLoading, + onStateSelect, +}) => { return ( -
- handleSelectionChange(key, currentState, setAgentStatus, logger)} - items={items} - > - {(item) => ( - - - {item.name} - - )} - - - {getTooltipText(customState, currentState, idleCodes, logger)} - - {formatTime(elapsedTime)} +
+ {idleCodes.map((code) => ( + + ))}
); }; - -export default withMetrics(UserStateComponent, 'UserState'); ``` -**Patterns:** -- **All props passed in** - no external dependencies -- **useMemo for computations** - optimized rendering -- **Utility functions** - extracted to separate utils file -- **Data test IDs** - every element has `data-testid` -- **withMetrics HOC** - wraps component for telemetry - --- -### **2. withMetrics HOC** - -```typescript -import {withMetrics} from '@webex/cc-ui-logging'; - -const MyComponent: React.FunctionComponent = (props) => { - // Component implementation -}; - -export default withMetrics(MyComponent, 'ComponentName'); -``` +## useEffect Cleanup Pattern -**Pattern:** -- Last line of every presentational component -- Wraps component for performance/usage metrics -- Component name string for identification +**ALWAYS clean up event listeners and subscriptions:** ---- - -## Component Composition - -### **Standard Widget Structure** +```typescript +useEffect(() => { + const handler = (data: EventData) => { + // Handle event + }; + cc.on(CC_EVENTS.SOME_EVENT, handler); + + // Cleanup function + return () => { + cc.off(CC_EVENTS.SOME_EVENT, handler); + }; +}, [cc]); ``` -packages/contact-center/station-login/ -├── src/ -│ ├── station-login/ -│ │ ├── index.tsx # Widget (observer + ErrorBoundary) -│ │ └── station-login.types.ts # Widget-specific types -│ ├── helper.ts # Custom hook (useStationLogin) -│ └── index.ts # Package entry (exports widget) -└── tests/ - └── station-login/ - └── index.tsx # Widget tests -``` - -**Flow:** -1. **index.tsx** - Widget component (observer wrapper) -2. **helper.ts** - Custom hook with business logic -3. **index.ts** - Re-exports widget for package consumers --- -## Hooks Usage Patterns - -### **Common React Hooks** - -| Hook | Usage | Pattern | -|------|-------|---------| -| `useState` | Local component state | `const [value, setValue] = useState(initialValue)` | -| `useEffect` | Side effects, event listeners | Always with cleanup function | -| `useRef` | Mutable refs, worker instances | `const ref = useRef(null)` | -| `useCallback` | Memoize functions | For expensive functions or props | -| `useMemo` | Memoize values | For expensive computations | +## useCallback Pattern -### **Custom Hook Naming** - -- **Pattern:** `use` (e.g., `useStationLogin`, `useUserState`, `useCallControl`) -- **Location:** `helper.ts` in widget package -- **Exports:** Named export, not default - ---- - -## Error Handling Patterns - -### **1. Try-Catch Everywhere** +**ALWAYS use useCallback for handlers passed to child components:** ```typescript -const setAgentStatus = (selectedCode) => { - try { - logger.info('Updating currentState'); - store.setCurrentState(selectedCode); - } catch (error) { - logger?.error(`Error in setAgentStatus - ${error.message}`, { - module: 'useUserState', - method: 'setAgentStatus', - }); - } -}; +const handleClick = useCallback((id: string) => { + // Handle click +}, [dependency1, dependency2]); ``` -**Convention:** -- Every function wrapped in try-catch -- Log errors with context (module, method) -- Use optional chaining for logger (`logger?.error`) - --- -### **2. Promise Error Handling** +## Conditional Rendering Pattern ```typescript -currentTask.accept() - .catch((error) => { - logger.error(`Error accepting task: ${error}`, { - module: 'useIncomingTask', - method: 'accept', - }); - }); -``` - -**Convention:** -- Always `.catch()` on promises -- Never rely on async/await without try-catch -- Log errors with context - ---- - -### **3. SDK Call Pattern** - -```typescript -const updateAgentState = (selectedCode) => { - setIsSettingAgentStatus(true); - - return cc.setAgentState({state: chosenState, auxCodeId}) - .then((response) => { - logger.log('Agent state set successfully'); - if ('data' in response) { - store.setLastStateChangeTimestamp(response.data.lastStateChangeTimestamp); - } - }) - .catch((error) => { - logger.error(`Error setting agent state: ${error}`); - store.setCurrentState(prevStateRef.current); // Rollback on error - throw error; - }) - .finally(() => { - setIsSettingAgentStatus(false); - }); -}; +// Loading state +if (isLoading) { + return ; +} + +// Error state +if (error) { + return ; +} + +// Empty state +if (!data || data.length === 0) { + return ; +} + +// Normal render +return ; ``` -**Pattern:** -- Set loading state before call -- Update store on success -- **Rollback on error** (restore previous state) -- Clear loading state in `finally` -- Re-throw error for upstream handling - ---- - -## Key Conventions to Enforce - -### ✅ DO: -1. **Use functional components only** - no class components -2. **Use `observer` from `mobx-react-lite`** for store-connected components -3. **Wrap every widget** with `ErrorBoundary` from `react-error-boundary` -4. **Split components** into Internal (observer) + Wrapper (ErrorBoundary) -5. **Extract business logic** to custom hooks in `helper.ts` -6. **Use try-catch** in every function -7. **Always cleanup** event listeners in `useEffect` return -8. **Add `data-testid`** to every interactive element -9. **Use `useCallback`** for functions passed as props -10. **Use `useMemo`** for expensive computations -11. **Log with context** (module, method) on every log -12. **Use `useRef`** for mutable values (workers, previous state) -13. **Destructure props** at top of component -14. **Return cleanup functions** from `useEffect` -15. **Use `withMetrics` HOC** on presentational components - -### ❌ DON'T: -1. **Don't use class components** - functional only -2. **Don't import store** in presentational components -3. **Don't forget ErrorBoundary** on widgets -4. **Don't skip cleanup** in useEffect -5. **Don't ignore promise errors** - always `.catch()` -6. **Don't mutate refs** during render -7. **Don't use empty dependency arrays** without justification -8. **Don't skip try-catch** in event handlers -9. **Don't use inline functions** in props without useCallback (if expensive) -10. **Don't mix business logic** into presentational components - ---- - -## Anti-Patterns Found - -### 1. **Inconsistent dependency arrays** -Some `useEffect` hooks have incomplete dependency arrays. - -**Recommendation:** Use ESLint `react-hooks/exhaustive-deps` rule. - ---- - -### 2. **Worker script as string literal** -Web Workers defined as inline strings make testing difficult. - -**Recommendation:** Extract to separate files when possible, or document pattern clearly. - --- -## Examples to Reference +## Props Destructuring Pattern -### Example 1: Complete Widget Structure ```typescript -// station-login/src/station-login/index.tsx -import React from 'react'; -import store from '@webex/cc-store'; -import {observer} from 'mobx-react-lite'; -import {ErrorBoundary} from 'react-error-boundary'; -import {StationLoginComponent} from '@webex/cc-components'; -import {useStationLogin} from '../helper'; - -const StationLoginInternal: React.FunctionComponent = observer( - ({onLogin, onLogout, profileMode}) => { - const {cc, teams, loginOptions, logger, isAgentLoggedIn} = store; - - const result = useStationLogin({ - cc, onLogin, onLogout, logger, isAgentLoggedIn - }); - - return ; - } -); - -const StationLogin: React.FunctionComponent = (props) => { - return ( - <>} - onError={(error: Error) => { - if (store.onErrorCallback) store.onErrorCallback('StationLogin', error); - }} - > - - - ); +const Component: React.FC = ({ + prop1, + prop2, + optionalProp = 'default', + onCallback, +}) => { + // Component logic }; - -export {StationLogin}; ``` -### Example 2: Custom Hook with Event Listeners -```typescript -export const useIncomingTask = (props: UseTaskProps) => { - const {incomingTask, onAccepted, logger} = props; - - const taskAssignCallback = () => { - try { - if (onAccepted) onAccepted({task: incomingTask}); - } catch (error) { - logger?.error(`Error - ${error.message}`); - } - }; - - useEffect(() => { - if (!incomingTask) return; - - store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); - - return () => { - store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); - }; - }, [incomingTask]); - - const accept = () => { - try { - incomingTask.accept().catch((error) => logger.error(`Error: ${error}`)); - } catch (error) { - logger?.error(`Error - ${error.message}`); - } - }; - - return { accept }; -}; -``` - ---- - -## Files Analyzed - -1. `/packages/contact-center/station-login/src/station-login/index.tsx` (77 lines) -2. `/packages/contact-center/user-state/src/user-state/index.tsx` (52 lines) -3. `/packages/contact-center/task/src/IncomingTask/index.tsx` -4. `/packages/contact-center/task/src/TaskList/index.tsx` -5. `/packages/contact-center/task/src/CallControl/index.tsx` -6. `/packages/contact-center/task/src/CallControlCAD/index.tsx` -7. `/packages/contact-center/station-login/src/helper.ts` (332 lines) -8. `/packages/contact-center/user-state/src/helper.ts` (296 lines) -9. `/packages/contact-center/task/src/helper.ts` (1002 lines) -10. `/packages/contact-center/cc-components/src/components/UserState/user-state.tsx` (100 lines) -11. `/packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx` (352 lines) - --- -## Related Documentation - -- [TypeScript Patterns](./typescript-patterns.md) - Component type definitions -- [MobX Patterns](./mobx-patterns.md) - Observer components -- [Web Component Patterns](./web-component-patterns.md) - React to WC conversion -- [Testing Patterns](./testing-patterns.md) - Component testing +## Related +- [TypeScript Patterns](./typescript-patterns.md) +- [MobX Patterns](./mobx-patterns.md) +- [Web Component Patterns](./web-component-patterns.md) +- [Testing Patterns](./testing-patterns.md) diff --git a/ai-docs/patterns/testing-patterns.md b/ai-docs/patterns/testing-patterns.md index 71ddaca0c..3b493d2df 100644 --- a/ai-docs/patterns/testing-patterns.md +++ b/ai-docs/patterns/testing-patterns.md @@ -1,806 +1,319 @@ # Testing Patterns ---- -Technology: Jest + Playwright -Configuration: See [jest.config.js](../../jest.config.js) and [playwright.config.ts](../../playwright.config.ts) -Dependencies: See [package.json](../../packages/contact-center/*/package.json) files for versions -Scope: Repository-wide -Last Updated: 2025-11-23 ---- - -> **For LLM Agents**: Add this file to context when working on tests, mocking, or test infrastructure. -> -> **For Developers**: Update this file when committing testing pattern changes. - ---- - -## Summary - -The codebase uses **Jest** for unit/integration tests and **Playwright** for E2E tests. Jest tests follow a consistent pattern: mock the store, spy on hooks, test component rendering and error boundaries. Playwright tests use a **TestManager** class for multi-agent/multi-session scenarios with real backend integration. All tests emphasize `data-testid` attributes for reliable selectors. +> Quick reference for LLMs working with tests in this repository. --- -## Testing Stack - -### **Unit/Integration Tests** -- **Framework:** Jest 29.7.0 -- **Testing Library:** @testing-library/react 16.0.1, @testing-library/jest-dom 6.6.2 -- **Environment:** jsdom -- **Coverage:** Jest built-in coverage +## Rules -### **E2E Tests** -- **Framework:** Playwright (@playwright/test) -- **Browser:** Chrome (Desktop) -- **Parallelization:** One worker per user set (multi-agent support) -- **Retry:** 1 retry for suite tests -- **Reporter:** HTML reporter +- **MUST** use Jest for unit tests +- **MUST** use React Testing Library for component tests +- **MUST** use Playwright for E2E tests +- **MUST** mock the store using `@webex/cc-test-fixtures` +- **MUST** use `data-testid` attributes for test selectors +- **MUST** place unit tests in `tests/` folder within each package +- **MUST** place E2E tests in `playwright/` folder at repo root +- **NEVER** test implementation details - test behavior +- **NEVER** use CSS selectors in tests - use `data-testid` --- -## Jest Configuration - -### **Root Configuration** +## Test File Structure -**File:** `jest.config.js` - -```javascript -module.exports = { - rootDir: '.', - setupFilesAfterEnv: ['/jest.setup.js'], - moduleNameMapper: { - '^.+\\.(css|less|scss)$': 'babel-jest', - }, - testEnvironment: 'jsdom', - testMatch: ['**/tooling/tests/**/*.js'], - transformIgnorePatterns: [ - '/node_modules/(?!(@momentum-design/components|@momentum-ui/react-collaboration|@lit|lit|cheerio|react-error-boundary))', - ], - transform: { - '\\.[jt]sx?$': 'babel-jest', - '\\.[jt]s?$': 'babel-jest', - }, - moduleDirectories: ['node_modules', 'src'], -}; ``` - -**Key points:** -- **jsdom environment** - simulates browser DOM -- **CSS mocking** - CSS files transformed with babel-jest -- **Transform ignore patterns** - includes specific node_modules packages -- **Babel transform** - for JSX and TS files - ---- - -### **Package-Level Configuration** - -**Pattern:** Each package extends root config - -```javascript -// station-login/jest.config.js -const jestConfig = require('../../../jest.config.js'); - -jestConfig.rootDir = '../../../'; -jestConfig.testMatch = ['**/station-login/tests/**/*.ts', '**/station-login/tests/**/*.tsx']; - -module.exports = jestConfig; +packages/contact-center/{package}/ +├── src/ +│ └── {widget}/ +│ └── index.tsx +└── tests/ + └── {widget}/ + └── index.test.tsx + +playwright/ +├── tests/ +│ ├── station-login-test.spec.ts +│ ├── user-state-test.spec.ts +│ └── tasklist-test.spec.ts +└── Utils/ + ├── stationLoginUtils.ts + └── userStateUtils.ts ``` -**Convention:** Override `rootDir` and `testMatch` for each package. - --- -## Jest Test Patterns - -### **1. Widget Component Test Pattern** +## Jest Unit Test Pattern -**File structure:** -``` -packages/contact-center/station-login/ -├── src/ -│ ├── station-login/index.tsx -│ └── helper.ts -└── tests/ - └── station-login/index.tsx -``` - -**Standard widget test:** ```typescript -import React from 'react'; -import {render} from '@testing-library/react'; -import {StationLogin} from '../../src'; -import * as helper from '../../src/helper'; -import '@testing-library/jest-dom'; -import store from '@webex/cc-store'; - -// 1. Mock store -jest.mock('@webex/cc-store', () => { - const originalStore = jest.requireActual('@webex/cc-store'); - - return { - ...originalStore, - cc: { - on: () => {}, - off: () => {}, - }, - teams: ['team123', 'team456'], - loginOptions: ['EXTENSION', 'AGENT_DN', 'BROWSER'], - deviceType: 'BROWSER', - dialNumber: '12345', - logger: { - log: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - }, - isAgentLoggedIn: false, - setCCCallback: jest.fn(), - onErrorCallback: jest.fn(), - }; -}); +// tests/{widget}/index.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { UserState } from '../../src/user-state'; +import { mockStore } from '@webex/cc-test-fixtures'; + +// Mock the store +jest.mock('@webex/cc-store', () => ({ + __esModule: true, + default: mockStore, +})); -describe('StationLogin Component', () => { +describe('UserState', () => { beforeEach(() => { jest.clearAllMocks(); - jest.spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { - jest.restoreAllMocks(); - }); - - // 2. Test component renders with correct props - it('renders StationLoginPresentational with correct props', () => { - const useStationLoginSpy = jest.spyOn(helper, 'useStationLogin'); - const loginCb = jest.fn(); + it('should render idle codes', () => { + mockStore.idleCodes = [ + { id: '1', name: 'Available' }, + { id: '2', name: 'Break' }, + ]; - render(); + render(); - expect(useStationLoginSpy).toHaveBeenCalledWith({ - cc: expect.any(Object), - onLogin: loginCb, - logger: expect.any(Object), - deviceType: 'BROWSER', - dialNumber: '12345', - isAgentLoggedIn: false, - }); + expect(screen.getByText('Available')).toBeInTheDocument(); + expect(screen.getByText('Break')).toBeInTheDocument(); }); - // 3. Test ErrorBoundary - describe('ErrorBoundary Tests', () => { - it('should render empty fragment when ErrorBoundary catches an error', () => { - const mockOnErrorCallback = jest.fn(); - store.onErrorCallback = mockOnErrorCallback; + it('should call onStateChange when state is selected', async () => { + const onStateChange = jest.fn(); + mockStore.idleCodes = [{ id: '1', name: 'Available' }]; - jest.spyOn(helper, 'useStationLogin').mockImplementation(() => { - throw new Error('Test error in useStationLogin'); - }); + render(); - const {container} = render(); + fireEvent.click(screen.getByText('Available')); - expect(container.firstChild).toBeNull(); - expect(store.onErrorCallback).toHaveBeenCalledWith( - 'StationLogin', - Error('Test error in useStationLogin') - ); + await waitFor(() => { + expect(onStateChange).toHaveBeenCalled(); }); }); }); ``` -**Pattern breakdown:** -1. **Mock store** - Use `jest.mock()` to mock `@webex/cc-store` -2. **Spy on hooks** - Use `jest.spyOn(helper, 'useHook')` to verify calls -3. **Suppress console.error** - Prevent ErrorBoundary errors from cluttering output -4. **Test render** - Verify component renders and hook called with correct props -5. **Test ErrorBoundary** - Mock hook to throw, verify fallback and callback - --- -### **2. Store Mock Pattern** +## Mock Store Pattern -**Full mock with spread:** ```typescript -jest.mock('@webex/cc-store', () => { - const originalStore = jest.requireActual('@webex/cc-store'); - - return { - ...originalStore, // Spread original for types/constants - cc: { - on: jest.fn(), - off: jest.fn(), - }, - idleCodes: [], - agentId: 'testAgentId', - logger: { - log: jest.fn(), - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - }, - currentState: '0', - customState: null, - onErrorCallback: jest.fn(), - }; -}); -``` - -**Benefits:** -- Preserves original types/enums -- Overrides runtime values -- Consistent across tests - ---- - -### **3. Web Worker Mock Pattern** - -```typescript -describe('UserState Component', () => { - let workerMock; - - beforeEach(() => { - workerMock = { - postMessage: jest.fn(), - terminate: jest.fn(), - onmessage: null, - }; - - global.Worker = jest.fn(() => workerMock); - global.URL.createObjectURL = jest.fn(() => 'blob:http://localhost:3000/12345'); - - if (typeof window.HTMLElement.prototype.attachInternals !== 'function') { - window.HTMLElement.prototype.attachInternals = jest.fn(); - } - }); +// test-fixtures/src/mockStore.ts +export const mockStore = { + cc: { + on: jest.fn(), + off: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + setAgentState: jest.fn(), + }, + agentId: 'test-agent-123', + isAgentLoggedIn: false, + teams: [], + idleCodes: [], + currentState: 'Available', + onErrorCallback: jest.fn(), +}; - it('renders UserStateComponent with correct props', () => { - const useUserStateSpy = jest.spyOn(helper, 'useUserState'); - render(); - expect(useUserStateSpy).toHaveBeenCalledTimes(1); - }); -}); +// Usage in test +jest.mock('@webex/cc-store', () => ({ + __esModule: true, + default: mockStore, +})); ``` -**Pattern:** -- Mock `Worker` constructor -- Mock `URL.createObjectURL` -- Mock `HTMLElement.prototype.attachInternals` (for Web Components) - --- -### **4. Store Unit Test Pattern** +## Hook Testing Pattern -**Testing the store itself:** ```typescript -import {makeAutoObservable} from 'mobx'; -import Webex from '@webex/contact-center'; -import store from '../src/store'; -import {mockProfile} from '@webex/test-fixtures'; - -jest.mock('mobx', () => ({ - makeAutoObservable: jest.fn(), - observable: {ref: jest.fn()}, -})); - -jest.mock('@webex/contact-center', () => ({ - init: jest.fn(() => ({ - once: jest.fn((event, callback) => { - if (event === 'ready') { - callback(); - } - }), - cc: { - register: jest.fn().mockResolvedValue(mockProfile), - LoggerProxy: { - error: jest.fn(), - log: jest.fn(), - }, - }, - })), -})); - -describe('Store', () => { - let storeInstance; - let mockWebex; - - beforeEach(() => { - storeInstance = store.getInstance(); - mockWebex = Webex.init({ - config: {anyConfig: true}, - credentials: {access_token: 'fake_token'}, - }); - jest.useFakeTimers(); - }); +import { renderHook, act } from '@testing-library/react'; +import { useUserState } from '../../src/helper'; +import { mockStore } from '@webex/cc-test-fixtures'; - afterEach(() => { - jest.useRealTimers(); - }); +describe('useUserState', () => { + it('should handle state change', async () => { + const onStateChange = jest.fn(); + + const { result } = renderHook(() => + useUserState({ + cc: mockStore.cc, + idleCodes: mockStore.idleCodes, + currentState: 'Available', + onStateChange, + }) + ); - it('should initialize with default values', () => { - expect(storeInstance.teams).toEqual([]); - expect(storeInstance.isAgentLoggedIn).toBe(false); - expect(makeAutoObservable).toHaveBeenCalledWith(storeInstance, { - cc: expect.any(Function), + await act(async () => { + await result.current.handleSetState({ id: '1', name: 'Break' }); }); - }); - describe('registerCC', () => { - it('should initialise store values on successful register', async () => { - const mockResponse = { - teams: [{id: 'team1', name: 'Team 1'}], - agentId: 'agent1', - isAgentLoggedIn: true, - }; - mockWebex.cc.register.mockResolvedValue(mockResponse); - - await storeInstance.registerCC(mockWebex); - - expect(storeInstance.teams).toEqual(mockResponse.teams); - expect(storeInstance.agentId).toEqual(mockResponse.agentId); - }); + expect(mockStore.cc.setAgentState).toHaveBeenCalled(); }); }); ``` -**Pattern:** -- Mock MobX -- Mock Webex SDK -- Use fake timers for async tests -- Test initial state and mutations - --- -## Playwright Configuration +## data-testid Pattern -### **Configuration File** +```typescript +// In component + -**File:** `playwright.config.ts` +
+ {/* content */} +
-```typescript -import {defineConfig, devices} from '@playwright/test'; -import dotenv from 'dotenv'; -import {USER_SETS} from './playwright/test-data'; - -dotenv.config({path: path.resolve(__dirname, '.env')}); - -export default defineConfig({ - testDir: './playwright', - timeout: 180000, - webServer: { - command: 'yarn workspace samples-cc-react-app serve', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - }, - retries: 0, - fullyParallel: true, - workers: Object.keys(USER_SETS).length, // Dynamic worker count - reporter: 'html', - use: { - baseURL: 'http://localhost:3000', - trace: 'retain-on-failure', - }, - projects: [ - { - name: 'OAuth: Get Access Token', - testMatch: /global\.setup\.ts/, - }, - // Dynamic test projects from USER_SETS - ...Object.entries(USER_SETS).map(([setName, setData], index) => { - return { - name: setName, - dependencies: ['OAuth: Get Access Token'], - fullyParallel: false, - retries: 1, - testMatch: [`**/suites/${setData.TEST_SUITE}`], - use: { - ...devices['Desktop Chrome'], - channel: 'chrome', - launchOptions: { - args: [ - `--use-fake-ui-for-media-stream`, - `--use-fake-device-for-media-stream`, - `--use-file-for-fake-audio-capture=${dummyAudioPath}`, - `--remote-debugging-port=${9221 + index}`, - `--window-position=${index * 1300},0`, - ], - }, - }, - }; - }), - ], -}); +// In test +const loginButton = screen.getByTestId('login-button'); +const dropdown = screen.getByTestId('user-state-dropdown'); ``` -**Key features:** -- **Dynamic projects** - One project per user set (multi-agent) -- **OAuth setup** - Global setup for token -- **Fake media** - Fake audio/video for WebRTC -- **Parallel workers** - One per user set -- **Remote debugging** - Different port per worker - --- -## Playwright Test Patterns - -### **1. TestManager Pattern** +## Playwright E2E Test Pattern ```typescript -import {TestManager} from '../test-manager'; +// playwright/tests/station-login-test.spec.ts +import { test, expect } from '@playwright/test'; +import { StationLoginUtils } from '../Utils/stationLoginUtils'; -export default function createUserStateTests() { - let testManager: TestManager; +test.describe('Station Login', () => { + let utils: StationLoginUtils; - test.beforeAll(async ({browser}, testInfo) => { - const projectName = testInfo.project.name; - testManager = new TestManager(projectName); - await testManager.basicSetup(browser); - - // Login agent - await telephonyLogin( - testManager.agent1Page, - LOGIN_MODE.EXTENSION, - process.env[`${testManager.projectName}_AGENT1_EXTENSION_NUMBER`] - ); - - await expect(testManager.agent1Page.getByTestId('state-select')).toBeVisible(); + test.beforeEach(async ({ page }) => { + utils = new StationLoginUtils(page); + await utils.navigateToApp(); }); - test.afterAll(async () => { - if (testManager) { - await testManager.cleanup(); - } - }); + test('should login successfully', async ({ page }) => { + await utils.selectTeam('Team A'); + await utils.selectDialNumber('+1234567890'); + await utils.clickLogin(); - test('should verify initial state is Meeting', async () => { - const state = await getCurrentState(testManager.agent1Page); - if (state !== USER_STATES.MEETING) - throw new Error('Initial state is not Meeting'); + await expect(page.getByTestId('login-success')).toBeVisible(); }); -} -``` - -**TestManager responsibilities:** -- Browser/page management -- Multi-agent support -- Multi-session support -- Console log capture -- Environment variable access - ---- - -### **2. Utility Function Pattern** - -```typescript -// Utils/userStateUtils.ts -export async function getCurrentState(page: Page): Promise { - const stateElement = page.getByTestId('state-select'); - return await stateElement.innerText(); -} - -export async function changeUserState(page: Page, state: string) { - await page.getByTestId('state-select').click(); - await page.getByTestId(`state-item-${state}`).click(); - await page.waitForTimeout(2000); -} -export async function verifyCurrentState(page: Page, expectedState: string) { - const currentState = await getCurrentState(page); - expect(currentState).toBe(expectedState); -} + test('should show error on invalid credentials', async ({ page }) => { + await utils.clickLogin(); -export async function getStateElapsedTime(page: Page): Promise { - return await page.getByTestId('elapsed-time').innerText(); -} + await expect(page.getByTestId('error-message')).toBeVisible(); + }); +}); ``` -**Pattern:** -- Extract common actions to utilities -- Use `data-testid` for selectors -- Return values for assertions -- Encapsulate waits - --- -### **3. Multi-Session Test Pattern** +## Playwright Utils Pattern ```typescript -test('should test multi-session synchronization', async () => { - // Create multi-session page - if (!testManager.multiSessionAgent1Page) { - if (!testManager.multiSessionContext) { - testManager.multiSessionContext = await testManager.agent1Context.browser()!.newContext(); - } - testManager.multiSessionAgent1Page = await testManager.multiSessionContext.newPage(); - } +// playwright/Utils/stationLoginUtils.ts +import { Page, Locator } from '@playwright/test'; - await testManager.setupMultiSessionPage(); - const multiSessionPage = testManager.multiSessionAgent1Page!; - - // Change state in first session - await changeUserState(testManager.agent1Page, USER_STATES.MEETING); - await verifyCurrentState(testManager.agent1Page, USER_STATES.MEETING); - - // Verify state synchronized in second session - await multiSessionPage.waitForTimeout(3000); - await verifyCurrentState(multiSessionPage, USER_STATES.MEETING); - - // Compare timers - const [timer1, timer2] = await Promise.all([ - getStateElapsedTime(testManager.agent1Page), - getStateElapsedTime(multiSessionPage), - ]); - - const parseTimer = (timer: string) => { - const parts = timer.split(':'); - return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); - }; +export class StationLoginUtils { + private page: Page; - expect(Math.abs(parseTimer(timer1) - parseTimer(timer2))).toBeLessThanOrEqual(2); -}); -``` - -**Pattern:** -- Create second context/page for multi-session -- Perform action in first session -- Verify synchronization in second session -- Use `Promise.all` for parallel checks + constructor(page: Page) { + this.page = page; + } ---- + async navigateToApp(): Promise { + await this.page.goto('/'); + } -### **4. Console Validation Pattern** + async selectTeam(teamName: string): Promise { + await this.page.getByTestId('team-dropdown').click(); + await this.page.getByText(teamName).click(); + } -```typescript -export async function validateConsoleStateChange( - page: Page, - expectedState: string, - consoleMessages: string[] -): Promise { - const found = consoleMessages.some(msg => - msg.includes('onStateChange called') && - msg.includes(expectedState) - ); - return found; -} + async selectDialNumber(number: string): Promise { + await this.page.getByTestId('dial-number-input').fill(number); + } -export async function checkCallbackSequence( - page: Page, - state: string, - consoleMessages: string[] -): Promise { - const callbackIndex = consoleMessages.findIndex(msg => - msg.includes('onStateChange called') - ); - const apiSuccessIndex = consoleMessages.findIndex(msg => - msg.includes('Agent state set successfully') - ); - - return callbackIndex > -1 && - apiSuccessIndex > -1 && - callbackIndex > apiSuccessIndex; -} + async clickLogin(): Promise { + await this.page.getByTestId('login-button').click(); + } -// In TestManager -constructor(projectName: string) { - this.consoleMessages = []; - // Capture console logs in beforeAll - this.agent1Page.on('console', msg => { - this.consoleMessages.push(msg.text()); - }); + async waitForLoginSuccess(): Promise { + await this.page.waitForSelector('[data-testid="login-success"]'); + } } ``` -**Pattern:** -- Capture console logs in TestManager -- Validate callback invocation -- Check event sequence -- Use for debugging and verification - --- -## Test Data Patterns - -### **data-testid Convention** +## Async Testing Pattern -**Every interactive element has a `data-testid`:** ```typescript -// Component -
- - ... - - {formatTime(elapsedTime)} -
+// Using waitFor +await waitFor(() => { + expect(screen.getByText('Success')).toBeInTheDocument(); +}); -// Test -await page.getByTestId('state-select').click(); -await page.getByTestId('state-item-Available').click(); -const time = await page.getByTestId('elapsed-time').innerText(); -``` +// Using findBy (auto-waits) +const successMessage = await screen.findByText('Success'); +expect(successMessage).toBeInTheDocument(); -**Naming convention:** -- Use kebab-case -- Descriptive, hierarchical names -- Include dynamic parts (e.g., `state-item-${name}`) +// Using act for state updates +await act(async () => { + fireEvent.click(button); +}); +``` --- -## Test Fixtures - -### **Fixture Pattern** - -**Location:** `packages/contact-center/test-fixtures/src/` +## Mock Event Pattern ```typescript -// incomingTaskFixtures.ts -export const mockIncomingTask = { - data: { - interactionId: 'interaction123', - interaction: { - mediaType: 'telephony', - state: 'connected', - }, - }, - accept: jest.fn().mockResolvedValue({}), - decline: jest.fn().mockResolvedValue({}), - hold: jest.fn().mockResolvedValue({}), - resume: jest.fn().mockResolvedValue({}), - end: jest.fn().mockResolvedValue({}), - on: jest.fn(), - off: jest.fn(), -}; +// Mock event listener +const mockOn = jest.fn(); +const mockOff = jest.fn(); -// taskListFixtures.ts -export const mockTaskList = { - 'interaction123': mockIncomingTask, - 'interaction456': {...}, +mockStore.cc = { + on: mockOn, + off: mockOff, }; -``` - -**Usage:** -```typescript -import {mockIncomingTask} from '@webex/test-fixtures'; -test('should accept task', () => { - render(); - // ... +// Simulate event +const eventHandler = mockOn.mock.calls[0][1]; +act(() => { + eventHandler({ state: 'Break' }); }); ``` --- -## Key Conventions to Enforce - -### ✅ DO: -1. **Mock store** in every widget test -2. **Spy on hooks** to verify calls -3. **Test ErrorBoundary** for every widget -4. **Suppress console.error** in beforeEach -5. **Restore mocks** in afterEach -6. **Use data-testid** for selectors -7. **Extract common actions** to utility functions -8. **Use TestManager** for Playwright tests -9. **Capture console logs** for validation -10. **Use fake timers** for async tests -11. **Clear mocks** before each test -12. **Test multi-session** scenarios where relevant -13. **Validate callback sequence** with console logs -14. **Use fixtures** for complex mock data - -### ❌ DON'T: -1. **Don't use CSS selectors** - use `data-testid` -2. **Don't skip ErrorBoundary tests** - required for every widget -3. **Don't forget to cleanup** in afterEach/afterAll -4. **Don't use real timers** for time-dependent tests -5. **Don't skip console.error suppression** - clutters output -6. **Don't hardcode test data** - use fixtures or constants -7. **Don't test implementation details** - test behavior -8. **Don't skip multi-session tests** for shared state widgets - ---- - -## Anti-Patterns Found - -### 1. **Hardcoded waits** -```typescript -await page.waitForTimeout(3000); -``` - -**Issue:** Brittle, slows tests -**Recommendation:** Use `waitFor` with conditions when possible +## Snapshot Testing Pattern ---- - -### 2. **Manual timer parsing** ```typescript -const parseTimer = (timer: string) => { - const parts = timer.split(':'); - return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); -}; +it('should match snapshot', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); +}); ``` -**Recommendation:** Extract to utility function, use in all tests - --- -## Examples to Reference - -### Example 1: Widget Unit Test -```typescript -import {render} from '@testing-library/react'; -import {UserState} from '../../src'; -import * as helper from '../../src/helper'; -import store from '@webex/cc-store'; - -jest.mock('@webex/cc-store', () => ({ - cc: {on: jest.fn(), off: jest.fn()}, - idleCodes: [], - agentId: 'testAgentId', - logger: {log: jest.fn(), error: jest.fn()}, - onErrorCallback: jest.fn(), -})); - -describe('UserState Component', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - it('renders with correct props', () => { - const spy = jest.spyOn(helper, 'useUserState'); - render(); - expect(spy).toHaveBeenCalledWith({ - cc: expect.any(Object), - idleCodes: [], - agentId: 'testAgentId', - logger: expect.any(Object), - }); - }); -}); -``` +## Test Commands -### Example 2: Playwright E2E Test -```typescript -import {test, expect} from '@playwright/test'; -import {TestManager} from '../test-manager'; +```bash +# Run all unit tests +yarn test -export default function createTests() { - let testManager: TestManager; +# Run specific package tests +yarn workspace @webex/cc-station-login test - test.beforeAll(async ({browser}, testInfo) => { - testManager = new TestManager(testInfo.project.name); - await testManager.basicSetup(browser); - }); +# Run with coverage +yarn test --coverage - test.afterAll(async () => { - await testManager.cleanup(); - }); +# Run E2E tests +yarn test:e2e - test('should change state', async () => { - await testManager.agent1Page.getByTestId('state-select').click(); - await testManager.agent1Page.getByTestId('state-item-Available').click(); - - const state = await testManager.agent1Page.getByTestId('state-select').innerText(); - expect(state).toBe('Available'); - }); -} +# Run specific E2E test +npx playwright test tests/station-login-test.spec.ts ``` --- -## Files Analyzed - -1. `/jest.config.js` (21 lines) -2. `/packages/contact-center/station-login/jest.config.js` (7 lines) -3. `/packages/contact-center/station-login/tests/station-login/index.tsx` (113 lines) -4. `/packages/contact-center/user-state/tests/user-state/index.tsx` (102 lines) -5. `/packages/contact-center/store/tests/store.ts` (100+ lines) -6. `/playwright.config.ts` (67 lines) -7. `/playwright/tests/user-state-test.spec.ts` (150+ lines) - ---- - -## Related Documentation - -- [React Patterns](./react-patterns.md) - Component testing strategies -- [MobX Patterns](./mobx-patterns.md) - Store mocking techniques -- [TypeScript Patterns](./typescript-patterns.md) - Type mocking +## Related +- [React Patterns](./react-patterns.md) +- [TypeScript Patterns](./typescript-patterns.md) +- [MobX Patterns](./mobx-patterns.md) diff --git a/ai-docs/patterns/typescript-patterns.md b/ai-docs/patterns/typescript-patterns.md index 603a7b8db..c8dfd1890 100644 --- a/ai-docs/patterns/typescript-patterns.md +++ b/ai-docs/patterns/typescript-patterns.md @@ -1,278 +1,149 @@ # TypeScript Patterns ---- -Technology: TypeScript -Configuration: [root tsconfig.json](../../tsconfig.json) -Dependencies: See individual [package.json](../../packages/contact-center/*/package.json) files -Scope: Repository-wide -Last Updated: 2025-11-23 ---- - -> **For LLM Agents**: Add this file to context when working on TypeScript code, interfaces, or type definitions. -> -> **For Developers**: Update this file when committing TypeScript pattern changes. +> Quick reference for LLMs working with TypeScript in this repository. --- -## Naming Conventions - -**Components:** -- PascalCase: `UserState.tsx`, `StationLogin.tsx` -- Component files use `.tsx` extension - -**Hooks:** -- camelCase with `use` prefix: `useUserState.ts`, `useStationLogin.ts` -- Hook files use `.ts` extension +## Rules -**Types/Interfaces:** -- PascalCase with `I` prefix: `IUserState`, `IStationLoginProps` -- Located in `{component}.types.ts` files +- **MUST** prefix all interfaces with `I` (e.g., `IUserState`, `IStationLoginProps`) +- **MUST** use PascalCase for components and interfaces +- **MUST** use camelCase for hooks with `use` prefix (e.g., `useUserState`) +- **MUST** use `.tsx` extension for components, `.ts` for hooks and utilities +- **MUST** co-locate types in `{component}.types.ts` files +- **MUST** use `Pick` and `Partial` to derive types from parent interfaces +- **MUST** document every interface property with JSDoc comments +- **MUST** use enums for event names and constants +- **NEVER** use `any` without ESLint disable comment and explanation +- **NEVER** duplicate type definitions - derive from source with `Pick` -**Constants:** -- SCREAMING_SNAKE_CASE: `MAX_RETRY_COUNT`, `DEFAULT_TIMEOUT` -- Grouped in constants files or at top of modules - -**Files:** -- Widget entry: `packages/*/src/{widget}/index.tsx` -- Helpers/Hooks: `packages/*/src/helper.ts` -- Types: `packages/*/src/{widget}/{widget}.types.ts` +--- -## Import Patterns +## Naming Conventions -**Store:** +### Components ```typescript -import store from '@webex/cc-store'; +// PascalCase, .tsx extension +UserState.tsx +StationLogin.tsx +CallControl.tsx ``` -**Components:** +### Hooks ```typescript -import {Component} from '@webex/cc-components'; +// camelCase with 'use' prefix, .ts extension +useUserState.ts +useStationLogin.ts +useCallControl.ts ``` -**Hooks:** +### Interfaces & Types ```typescript -import {useUserState} from '../helper'; +// PascalCase with 'I' prefix +interface IUserState { ... } +interface IStationLoginProps { ... } +interface IContactCenter { ... } ``` -**Types:** +### Constants ```typescript -import {IUserState} from './user-state.types'; +// SCREAMING_SNAKE_CASE +const MAX_RETRY_COUNT = 3; +const DEFAULT_TIMEOUT = 5000; ``` -**MobX:** -```typescript -import {observer} from 'mobx-react-lite'; -import {runInAction} from 'mobx'; +### File Structure ``` - ---- - -## Summary - -The codebase uses TypeScript with a centralized configuration and consistent patterns across all packages. TypeScript strict mode is **partially enabled** (`alwaysStrict: true` but not full `strict: true`). The project emphasizes type safety through interfaces, type aliases, and utility types, with a clear separation between widget-level and component-level type definitions. - ---- - -## TypeScript Configuration - -### Root Configuration (`tsconfig.json`) - -**Location:** `/packages/contact-center/../../../tsconfig.json` - -**Key Settings:** -- `alwaysStrict: true` - Enforces strict mode in emitted JavaScript -- `strict: false` - Full strict mode **NOT enabled** -- `allowJs: true` - Allows JavaScript files -- `allowSyntheticDefaultImports: true` - Enables synthetic default imports -- `experimentalDecorators: true` - Required for MobX decorators -- `isolatedModules: true` - Required for Babel transpilation -- `module: "commonjs"` - CommonJS module system -- `target: "ES6"` - ES6 compilation target -- `jsx: "react"` - React JSX support -- `skipLibCheck: true` - Skip type checking of declaration files -- `types: ["jest"]` - Global Jest types - -### Package-Level Configurations - -All packages (`station-login`, `user-state`, `store`, `cc-components`, `cc-widgets`, `task`, `ui-logging`, `test-fixtures`) extend the root configuration: - -```json -{ - "extends": "../../../tsconfig.json", - "include": ["./src"], - "compilerOptions": { - "outDir": "./dist", - "declaration": true, - "declarationDir": "./dist/types" - } -} +packages/*/src/{widget}/index.tsx # Widget entry +packages/*/src/helper.ts # Hooks/helpers +packages/*/src/{widget}/{widget}.types.ts # Types ``` -**Notable Exception:** `store` package uses `moduleResolution: "NodeNext"` and `module: "NodeNext"` for modern resolution. - ---- - -## Interface Patterns - -### 1. **Interface Naming Convention** - -**Pattern:** Prefix interfaces with `I` - -**Examples:** -```typescript -interface IContactCenter { ... } -interface IStore { ... } -interface IStoreWrapper extends IStore { ... } -interface ILogger { ... } -interface IWrapupCode { ... } -interface IUserState { ... } -interface IStationLoginProps { ... } -``` - -**Enforcement:** Consistent across all packages, especially in `store.types.ts` and component type files. - --- -### 2. **Type Aliases vs Interfaces** - -**When to use `type`:** -- Union types: `type ICustomState = ICustomStateSet | ICustomStateReset` -- Intersection types: `type WithWebex = { webex: {...} }` -- Utility type compositions: `type UseTaskProps = Pick & Partial<...>` -- Simple object shapes without extension needs - -**When to use `interface`:** -- Component props: `interface IStationLoginProps { ... }` -- Store contracts: `interface IStore { ... }` -- Extensible structures: `interface IStoreWrapper extends IStore { ... }` -- API contracts: `interface IContactCenter { ... }` +## Import Patterns -**Examples:** +### Store Import ```typescript -// Type for union -type ICustomState = ICustomStateSet | ICustomStateReset; - -// Interface for props -interface IUserState { - idleCodes: IdleCode[]; - logger: ILogger; - onStateChange?: (arg: IdleCode | ICustomState) => void; -} +import store from '@webex/cc-store'; ``` ---- - -### 3. **Pick and Partial Utility Types** - -**Heavy use of `Pick` and `Partial` to derive types** - This is a core pattern throughout the codebase. - -**Pattern 1: Pick specific props from parent interface** +### Components Import ```typescript -export type IUserStateProps = Pick; - -export type UseUserStateProps = Pick< - IUserState, - | 'idleCodes' - | 'agentId' - | 'cc' - | 'currentState' - | 'customState' - | 'lastStateChangeTimestamp' - | 'logger' - | 'onStateChange' - | 'lastIdleCodeChangeTimestamp' ->; +import { Component } from '@webex/cc-components'; ``` -**Pattern 2: Combine Pick with Partial for optional props** +### Types Import ```typescript -export type IncomingTaskProps = Pick & - Partial>; - -export type StationLoginProps = Pick & - Partial>; +import { IUserState } from './user-state.types'; ``` -**Pattern 3: Pick from multiple interfaces with intersection** +### MobX Import ```typescript -export type UseTaskProps = Pick & - Partial>; +import { observer } from 'mobx-react-lite'; +import { runInAction } from 'mobx'; ``` -**Benefit:** Ensures type consistency between component layers (widget → component) without duplication. - --- -### 4. **Optional Properties** - -**Convention:** Use `?` for optional properties +## Interface Patterns +### Pattern 1: Interface with I Prefix ```typescript interface IUserState { + idleCodes: IdleCode[]; + agentId: string; + cc: IContactCenter; + currentState: string; onStateChange?: (arg: IdleCode | ICustomState) => void; - lastStateChangeTimestamp?: number; - lastIdleCodeChangeTimestamp?: number; } ``` -**Alternative:** Use `Partial>` to make specific props optional when deriving types. - ---- - -### 5. **Function Type Signatures** - -**Callback Props:** +### Pattern 2: Use Pick to Derive Types ```typescript -onStateChange?: (arg: IdleCode | ICustomState) => void; -onLogin?: () => void; -onSaveEnd?: (isComplete: boolean) => void; +// Widget picks only what it needs from component interface +export type IUserStateProps = Pick; + +// Hook picks different subset +export type UseUserStateProps = Pick< + IUserState, + 'idleCodes' | 'agentId' | 'cc' | 'currentState' | 'logger' +>; ``` -**Generic Functions:** +### Pattern 3: Combine Pick with Partial ```typescript -type FetchPaginatedList = ( - params: PaginatedListParams -) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; - -type TransformPaginatedData = (item: T, page: number, index: number) => U; +// Required props + optional callback props +export type StationLoginProps = + Pick & + Partial>; ``` -**Event Handlers:** +### Pattern 4: Union Types ```typescript -// eslint-disable-next-line @typescript-eslint/no-explicit-any -on: (event: string, callback: (data: any) => void) => void; +type ICustomState = ICustomStateSet | ICustomStateReset; ``` --- -## Enums - -### Pattern: Named enums for constants +## Enum Patterns -**Examples:** +### Event Enums ```typescript export enum TASK_EVENTS { TASK_INCOMING = 'task:incoming', TASK_ASSIGNED = 'task:assigned', TASK_HOLD = 'task:hold', - // ... 40+ task events } export enum CC_EVENTS { AGENT_DN_REGISTERED = 'agent:dnRegistered', AGENT_LOGOUT_SUCCESS = 'agent:logoutSuccess', - AGENT_STATION_LOGIN_SUCCESS = 'agent:stationLoginSuccess', - // ... -} - -export enum ConsultStatus { - NO_CONSULTATION_IN_PROGRESS = 'No consultation in progress', - BEING_CONSULTED = 'beingConsulted', - CONSULT_INITIATED = 'consultInitiated', - // ... } +``` +### State Enums +```typescript export enum AgentUserState { Available = 'Available', RONA = 'RONA', @@ -280,88 +151,9 @@ export enum AgentUserState { } ``` -**Convention:** UPPERCASE for enum names representing events/constants, PascalCase for state enums. - ---- - -## Type Exports - -### Central Export Pattern - -**Each package has a `*.types.ts` file that exports all types:** - -```typescript -export type { - IContactCenter, - ITask, - Profile, - Team, - // ... all interfaces and types -}; - -export { - CC_EVENTS, - TASK_EVENTS, - ENGAGED_LABEL, - // ... all enums and constants -}; -``` - -**Widget packages export minimal types:** -```typescript -// user-state.types.ts -export type IUserStateProps = Pick; -export type UseUserStateProps = Pick; -``` - ---- - -## Import Patterns - -### 1. **SDK Type Imports** - -**Direct imports from `@webex/contact-center`:** -```typescript -import { - AgentLogin, - Profile, - ITask, - // ... -} from '@webex/contact-center'; -``` - -**Deep imports for types not exported (workaround):** -```typescript -import { - OutdialAniEntriesResponse, - OutdialAniParams, -} from 'node_modules/@webex/contact-center/dist/types/services/config/types'; -``` - -**Comment pattern for SDK issues:** -```typescript -// To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 -interface IContactCenter { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (event: string, callback: (data: any) => void) => void; -} -``` - -### 2. **Internal Package Imports** - -```typescript -import store, {CC_EVENTS} from '@webex/cc-store'; -import {StationLoginComponent, StationLoginComponentProps} from '@webex/cc-components'; -import {IUserState} from '@webex/cc-components'; -``` - --- -## Documentation Patterns - -### JSDoc for Interfaces - -**Comprehensive JSDoc comments on interface properties:** +## JSDoc Pattern ```typescript /** @@ -369,151 +161,56 @@ import {IUserState} from '@webex/cc-components'; */ export interface IStationLoginProps { /** - * Webex instance. + * Webex Contact Center instance. */ cc: IContactCenter; /** - * Array of the team IDs that agent belongs to + * Array of teams the agent belongs to. */ teams: Team[]; /** - * Handler to initiate the agent login - */ - login: () => void; - - /** - * Flag to indicate if the agent is logged in + * Handler called when login completes. */ - isAgentLoggedIn: boolean; - - // ... + onLogin?: () => void; } ``` -**Convention:** Every property should have a JSDoc comment describing its purpose. - ---- - -## Key Conventions to Enforce - -### ✅ DO: -1. **Prefix interfaces with `I`**: `IStore`, `ILogger`, `IUserState` -2. **Use `Pick` and `Partial`** to derive widget types from component types -3. **Export all types** from a central `*.types.ts` file in each package -4. **Document every interface property** with JSDoc comments -5. **Use enums for event names and constants** instead of string literals -6. **Use `type` for unions, intersections, and utility compositions** -7. **Use `interface` for component props and extensible contracts** -8. **Mark optional props with `?`** or wrap in `Partial<>` -9. **Use explicit `void` return type** for callbacks -10. **Add TODO comments with JIRA links** for SDK workarounds - -### ❌ DON'T: -1. **Don't use `any`** without ESLint disable comment and explanation -2. **Don't duplicate type definitions** - use `Pick` to derive from source -3. **Don't mix `type` and `interface`** for the same use case -4. **Don't skip JSDoc** on public interfaces -5. **Don't use deep imports** from `node_modules` unless SDK types are unavailable - --- -## Anti-Patterns Found +## Type Export Pattern -### 1. **Deep imports from node_modules** ```typescript -// ❌ ANTI-PATTERN -import {OutdialAniEntriesResponse} from 'node_modules/@webex/contact-center/dist/types/services/config/types'; -``` -**Reason:** These should be exported from SDK. Tracked as technical debt with JIRA link. +// Central export from *.types.ts +export type { + IContactCenter, + ITask, + Profile, + Team, +}; -### 2. **Use of `any` in SDK interface workaround** -```typescript -// ❌ NECESSARY EVIL (documented) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -on: (event: string, callback: (data: any) => void) => void; +export { + CC_EVENTS, + TASK_EVENTS, +}; ``` -**Reason:** SDK doesn't properly type event callbacks. Disable comment required. - -### 3. **Partial strict mode** -- `alwaysStrict: true` but `strict: false` in root config -- **Impact:** Missing stricter null checks, implicit any, etc. -- **Recommendation:** Consider enabling full `strict: true` in future --- -## Examples to Reference +## Callback Type Pattern -### Example 1: Widget Type Derivation ```typescript -// Component defines full interface -interface IUserState { - idleCodes: IdleCode[]; - agentId: string; - cc: IContactCenter; - currentState: string; - onStateChange?: (arg: IdleCode | ICustomState) => void; - // ... 10+ more properties -} - -// Widget picks only what it needs -export type IUserStateProps = Pick; - -// Helper hook picks different subset -export type UseUserStateProps = Pick< - IUserState, - | 'idleCodes' - | 'agentId' - | 'cc' - | 'currentState' - | 'customState' - | 'lastStateChangeTimestamp' - | 'logger' - | 'onStateChange' - | 'lastIdleCodeChangeTimestamp' ->; -``` - -### Example 2: Combining Picked and Partial Props -```typescript -export type StationLoginProps = - Pick & - Partial>; -``` - -### Example 3: Generic Type Definitions -```typescript -type FetchPaginatedList = ( - params: PaginatedListParams -) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; +// Optional callback with specific signature +onStateChange?: (arg: IdleCode | ICustomState) => void; +onLogin?: () => void; +onSaveEnd?: (isComplete: boolean) => void; ``` --- -## Files Analyzed - -1. `/packages/contact-center/tsconfig.json` (root) -2. `/packages/contact-center/station-login/tsconfig.json` -3. `/packages/contact-center/user-state/tsconfig.json` -4. `/packages/contact-center/store/tsconfig.json` -5. `/packages/contact-center/cc-components/tsconfig.json` -6. `/packages/contact-center/store/src/store.types.ts` (346 lines) -7. `/packages/contact-center/user-state/src/user-state.types.ts` -8. `/packages/contact-center/task/src/task.types.ts` -9. `/packages/contact-center/station-login/src/station-login/station-login.types.ts` -10. `/packages/contact-center/cc-components/src/components/StationLogin/station-login.types.ts` (247 lines) -11. `/packages/contact-center/cc-components/src/components/UserState/user-state.types.ts` -12. `/packages/contact-center/station-login/src/helper.ts` (332 lines) -13. `/packages/contact-center/station-login/src/station-login/index.tsx` (77 lines) -14. `/packages/contact-center/user-state/src/user-state/index.tsx` (52 lines) - ---- - -## Related Documentation - -- [MobX Patterns](./mobx-patterns.md) - MobX store with TypeScript types -- [React Patterns](./react-patterns.md) - React components with TypeScript -- [Testing Patterns](./testing-patterns.md) - TypeScript in tests +## Related +- [React Patterns](./react-patterns.md) +- [MobX Patterns](./mobx-patterns.md) +- [Testing Patterns](./testing-patterns.md) diff --git a/ai-docs/patterns/web-component-patterns.md b/ai-docs/patterns/web-component-patterns.md index 0162de134..6c8b84257 100644 --- a/ai-docs/patterns/web-component-patterns.md +++ b/ai-docs/patterns/web-component-patterns.md @@ -1,618 +1,227 @@ # Web Component Patterns ---- -Technology: Web Components (Custom Elements v1) -Configuration: See [cc-widgets/package.json](../../packages/contact-center/cc-widgets/package.json) -Dependencies: See [@r2wc package.json](../../packages/contact-center/cc-widgets/package.json) for version -Scope: Repository-wide -Last Updated: 2025-11-23 ---- - -> **For LLM Agents**: Add this file to context when working on Web Components, r2wc wrappers, or custom element registration. -> -> **For Developers**: Update this file when committing Web Component pattern changes. +> Quick reference for LLMs working with Web Components in this repository. --- -## Summary - -The codebase uses **`@r2wc/react-to-web-component`** (version 2.0.3) to wrap React components as Web Components. There are **two levels** of Web Component exports: -1. **Widget-level** (`cc-widgets/wc.ts`) - Wraps widget components with minimal props (callbacks only) -2. **Component-level** (`cc-components/wc.ts`) - Wraps presentational components with full props - -All Web Components are registered using `customElements.define()` with duplicate registration checks. The package exports both React components (`index.ts`) and Web Components (`wc.ts`) through package.json exports field. - ---- - -## Package Structure - -### **Dual Export Pattern** - -**package.json exports:** -```json -{ - "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/index.js", - "types": "./dist/types/index.d.ts" - }, - "./wc": { - "import": "./dist/wc.js", - "require": "./dist/wc.js", - "types": "./dist/types/wc.d.ts" - } - } -} -``` - -**Usage:** -```typescript -// Import React components -import {StationLogin, UserState} from '@webex/cc-widgets'; +## Rules -// Import Web Components (auto-registered) -import '@webex/cc-widgets/wc'; -// Now can use in HTML -``` +- **MUST** use `r2wc` (React to Web Component) to wrap React widgets +- **MUST** register custom elements with `customElements.define()` +- **MUST** use kebab-case for custom element names (e.g., `cc-station-login`) +- **MUST** prefix all custom elements with `cc-` +- **MUST** define prop types in r2wc options +- **MUST** export Web Components from `cc-widgets` package only +- **NEVER** create Web Components directly - always wrap React components +- **NEVER** use camelCase for custom element names --- ## r2wc Wrapper Pattern -### **1. Widget-Level Wrappers (cc-widgets)** - -**Location:** `packages/contact-center/cc-widgets/src/wc.ts` - -**Pattern:** ```typescript +// wc.ts in widget package import r2wc from '@r2wc/react-to-web-component'; -import {StationLogin} from '@webex/cc-station-login'; -import {UserState} from '@webex/cc-user-state'; -import store from '@webex/cc-store'; - -// Wrap widget with minimal props (only callbacks) -const WebUserState = r2wc(UserState, { - props: { - onStateChange: 'function', - }, -}); +import { StationLogin } from './station-login'; -const WebStationLogin = r2wc(StationLogin, { +export const StationLoginWC = r2wc(StationLogin, { props: { + profileMode: 'string', onLogin: 'function', onLogout: 'function', + onCCSignOut: 'function', }, }); - -const WebIncomingTask = r2wc(IncomingTask, { - props: { - incomingTask: 'json', - onAccepted: 'function', - onRejected: 'function', - }, -}); - -const WebTaskList = r2wc(TaskList, { - props: { - onTaskAccepted: 'function', - onTaskDeclined: 'function', - onTaskSelected: 'function', - }, -}); - -const WebCallControl = r2wc(CallControl, { - props: { - onHoldResume: 'function', - onEnd: 'function', - onWrapUp: 'function', - onRecordingToggle: 'function', - }, -}); - -const WebOutdialCall = r2wc(OutdialCall, {}); - -// Register all components -const components = [ - {name: 'widget-cc-user-state', component: WebUserState}, - {name: 'widget-cc-station-login', component: WebStationLogin}, - {name: 'widget-cc-incoming-task', component: WebIncomingTask}, - {name: 'widget-cc-task-list', component: WebTaskList}, - {name: 'widget-cc-call-control', component: WebCallControl}, - {name: 'widget-cc-outdial-call', component: WebOutdialCall}, - {name: 'widget-cc-call-control-cad', component: WebCallControlCAD}, -]; - -components.forEach(({name, component}) => { - if (!customElements.get(name)) { - customElements.define(name, component); - } -}); - -// Export store for external access -export {store}; ``` -**Key characteristics:** -- **Minimal props** - only user-facing callbacks and input data -- **Store access hidden** - widgets access store internally -- **Convention**: `widget-cc-` for custom element names -- **Batch registration** - loop through components array -- **Store export** - also exports store for external initialization - --- -### **2. Component-Level Wrappers (cc-components)** - -**Location:** `packages/contact-center/cc-components/src/wc.ts` +## Custom Element Registration Pattern -**Pattern:** ```typescript -import r2wc from '@r2wc/react-to-web-component'; -import UserStateComponent from './components/UserState/user-state'; -import StationLoginComponent from './components/StationLogin/station-login'; - -// Wrap presentational component with full props -const WebUserState = r2wc(UserStateComponent, { - props: { - idleCodes: 'json', - setAgentStatus: 'function', - isSettingAgentStatus: 'boolean', - elapsedTime: 'number', - lastIdleStateChangeElapsedTime: 'number', - currentState: 'string', - customState: 'json', - logger: 'function', - }, -}); - -if (!customElements.get('component-cc-user-state')) { - customElements.define('component-cc-user-state', WebUserState); -} - -const WebStationLogin = r2wc(StationLoginComponent, { - props: { - teams: 'json', - loginOptions: 'json', - login: 'function', - logout: 'function', - loginSuccess: 'json', - loginFailure: 'json', - logoutSuccess: 'json', - setDeviceType: 'function', - setDialNumber: 'function', - setTeam: 'function', - isAgentLoggedIn: 'boolean', - handleContinue: 'function', - deviceType: 'string', - showMultipleLoginAlert: 'boolean', - logger: 'function', - }, -}); - -if (!customElements.get('component-cc-station-login')) { - customElements.define('component-cc-station-login', WebStationLogin); -} - -// Shared props pattern -const commonPropsForCallControl: Record = { - currentTask: 'json', - audioRef: 'json', - wrapupCodes: 'json', - wrapupRequired: 'boolean', - toggleHold: 'function', - toggleRecording: 'function', - endCall: 'function', - wrapupCall: 'function', - isHeld: 'boolean', - setIsHeld: 'function', - consultTransferOptions: 'json', -}; - -const WebCallControlCADComponent = r2wc(CallControlCADComponent, { - props: commonPropsForCallControl, -}); - -const WebCallControl = r2wc(CallControlComponent, { - props: commonPropsForCallControl, -}); - -if (!customElements.get('component-cc-call-control-cad')) { - customElements.define('component-cc-call-control-cad', WebCallControlCADComponent); -} +// cc-widgets/src/index.ts +import { StationLoginWC } from '@webex/cc-station-login/wc'; +import { UserStateWC } from '@webex/cc-user-state/wc'; -if (!customElements.get('component-cc-call-control')) { - customElements.define('component-cc-call-control', WebCallControl); -} +// Register custom elements +customElements.define('cc-station-login', StationLoginWC); +customElements.define('cc-user-state', UserStateWC); +customElements.define('cc-incoming-task', IncomingTaskWC); +customElements.define('cc-task-list', TaskListWC); +customElements.define('cc-call-control', CallControlWC); ``` -**Key characteristics:** -- **Full props** - all component props exposed -- **Shared props** - common props extracted to constants -- **Convention**: `component-cc-` for custom element names -- **Individual registration** - each component registered separately -- **Duplicate check** - `if (!customElements.get(name))` before defining - --- -## r2wc Type Mapping - -### **Supported Prop Types** - -| r2wc Type | TypeScript Type | Usage | -|-----------|-----------------|-------| -| `'string'` | `string` | Simple strings, IDs, names | -| `'number'` | `number` | Counts, timestamps, durations | -| `'boolean'` | `boolean` | Flags, states | -| `'function'` | `(...args: any[]) => any` | Callbacks, handlers, loggers | -| `'json'` | `object`, `array`, complex types | Objects, arrays, structured data | +## Prop Type Mapping -**Examples:** ```typescript -const WebComponent = r2wc(ReactComponent, { +// Map React prop types to r2wc types +const WidgetWC = r2wc(Widget, { props: { - // Primitives - deviceType: 'string', - elapsedTime: 'number', - isAgentLoggedIn: 'boolean', + // String props + profileMode: 'string', + agentId: 'string', - // Functions - onStateChange: 'function', - setAgentStatus: 'function', - logger: 'function', + // Boolean props + isEnabled: 'boolean', + showHeader: 'boolean', + + // Number props + timeout: 'number', + maxRetries: 'number', + + // Function props (callbacks) + onLogin: 'function', + onLogout: 'function', + onError: 'function', - // Complex types - teams: 'json', // Team[] - idleCodes: 'json', // IdleCode[] - currentTask: 'json', // ITask - loginFailure: 'json', // Error - customState: 'json', // ICustomState + // Object/Array props (passed as JSON string) + config: 'json', + teams: 'json', }, }); ``` --- -## Custom Element Registration - -### **Pattern 1: Batch Registration (cc-widgets)** +## Widget Package wc.ts Structure ```typescript -const components = [ - {name: 'widget-cc-user-state', component: WebUserState}, - {name: 'widget-cc-station-login', component: WebStationLogin}, - {name: 'widget-cc-incoming-task', component: WebIncomingTask}, -]; - -components.forEach(({name, component}) => { - if (!customElements.get(name)) { - customElements.define(name, component); - } -}); -``` - -**Benefits:** -- Easy to add new components -- Consistent registration logic -- Prevents duplicates automatically - ---- - -### **Pattern 2: Individual Registration (cc-components)** +// packages/contact-center/{widget}/src/wc.ts +import r2wc from '@r2wc/react-to-web-component'; +import { WidgetName } from './{widget}'; -```typescript -if (!customElements.get('component-cc-user-state')) { - customElements.define('component-cc-user-state', WebUserState); -} +export const WidgetNameWC = r2wc(WidgetName, { + props: { + // Define all props that should be exposed to Web Component + prop1: 'string', + prop2: 'boolean', + onCallback: 'function', + }, +}); ``` -**Benefits:** -- Explicit control per component -- Clear which components are registered -- Easy to debug registration issues - ---- - -## Naming Conventions - -### **Custom Element Names** - -**Widget level:** -- **Pattern:** `widget-cc-` -- **Examples:** - - `widget-cc-user-state` - - `widget-cc-station-login` - - `widget-cc-incoming-task` - - `widget-cc-task-list` - - `widget-cc-call-control` - - `widget-cc-call-control-cad` - - `widget-cc-outdial-call` - -**Component level:** -- **Pattern:** `component-cc-` -- **Examples:** - - `component-cc-user-state` - - `component-cc-station-login` - - `component-cc-incoming-task` - - `component-cc-task-list` - - `component-cc-call-control` - - `component-cc-call-control-cad` - - `component-cc-out-dial-call` - -**Naming rules:** -- All lowercase -- Words separated by hyphens -- Must contain a hyphen (Web Component standard) -- Prefix indicates layer (`widget-cc-` vs `component-cc-`) - --- -## Usage Patterns - -### **HTML Usage** +## HTML Usage Pattern ```html - - - - - - - - - - - - + + + + + - - ``` --- -### **React Component Import** +## JavaScript Usage Pattern -```typescript -// Import React components (not Web Components) -import {StationLogin, UserState, store} from '@webex/cc-widgets'; - -function App() { - useEffect(() => { - store.init({ - access_token: 'YOUR_TOKEN', - webexConfig: {...} - }); - }, []); - - return ( -
- console.log(state)} /> - console.log('Logged in')} /> -
- ); -} -``` - ---- +```javascript +// Create element programmatically +const stationLogin = document.createElement('cc-station-login'); +stationLogin.setAttribute('profile-mode', 'desktop'); -## Store Initialization Pattern - -### **Store Export** - -```typescript -// cc-widgets/src/wc.ts -import store from '@webex/cc-store'; - -// ... component registrations ... - -export {store}; // Export store for external init -``` - -**Usage:** -```typescript -import {store} from '@webex/cc-widgets/wc'; +// Set callback props +stationLogin.onLogin = () => { + console.log('Login successful'); +}; -// Initialize store before using widgets -await store.init({ - access_token: 'token', - webexConfig: {...} -}); +stationLogin.onLogout = () => { + console.log('Logged out'); +}; -// Or with existing webex instance -await store.init({ - webex: { - cc: ccSDK, - logger: logger - } -}); +// Append to DOM +document.getElementById('container').appendChild(stationLogin); ``` --- -## React Component Export - -### **Component-Only Export** +## Attribute Naming Convention ```typescript -// cc-widgets/src/index.ts -import {StationLogin} from '@webex/cc-station-login'; -import {UserState} from '@webex/cc-user-state'; -import {IncomingTask, TaskList, CallControl, CallControlCAD, OutdialCall} from '@webex/cc-task'; -import store from '@webex/cc-store'; -import '@momentum-ui/core/css/momentum-ui.min.css'; - -export {StationLogin, UserState, IncomingTask, CallControl, CallControlCAD, TaskList, OutdialCall, store}; +// React prop → HTML attribute +profileMode → profile-mode +onStateChange → on-state-change (or handled via JS property) +isEnabled → is-enabled +maxRetries → max-retries ``` -**Purpose:** -- React consumers import from `@webex/cc-widgets` (not `/wc`) -- Gets React components, not Web Components -- Also exports store for initialization -- Includes momentum UI styles - --- -## Key Conventions to Enforce - -### ✅ DO: -1. **Use r2wc version 2.0.3** for consistent behavior -2. **Check for duplicate registrations** with `customElements.get(name)` -3. **Use descriptive names** with `widget-cc-` or `component-cc-` prefix -4. **Map all component props** in r2wc config -5. **Use `'json'` type** for complex objects/arrays -6. **Use `'function'` type** for all callbacks -7. **Export store** from `wc.ts` for external initialization -8. **Batch register** widgets in cc-widgets (loop pattern) -9. **Individual register** components in cc-components (explicit pattern) -10. **Include both exports** in package.json (`.` and `./wc`) - -### ❌ DON'T: -1. **Don't skip duplicate checks** - may cause runtime errors -2. **Don't use uppercase** in custom element names -3. **Don't omit hyphens** in custom element names (required by spec) -4. **Don't forget prop mappings** - unmapped props won't work -5. **Don't use wrong type** - `'json'` for objects, `'function'` for callbacks -6. **Don't mix React and WC imports** - choose one per consumer -7. **Don't forget store initialization** - widgets won't work without it -8. **Don't register twice** - causes "already defined" errors +## cc-widgets Package Structure ---- - -## Anti-Patterns Found - -### 1. **Empty props object** -```typescript -const WebOutdialCall = r2wc(OutdialCall, {}); ``` - -**Issue:** Component has no props to configure. -**Recommendation:** If component truly has no props, document why. Otherwise, expose necessary props. - ---- - -### 2. **Type assertion for commonProps** -```typescript -const commonPropsForCallControl: Record = { - currentTask: 'json', - // ... -}; +packages/contact-center/cc-widgets/ +├── src/ +│ ├── index.ts # Custom element registration +│ └── wc.ts # Aggregated exports +├── package.json +└── tsconfig.json ``` -**Recommendation:** This is actually a good pattern for shared props. Could extract to a type helper. - --- -## Examples to Reference +## Full Widget to Web Component Flow -### Example 1: Widget-Level Web Component -```typescript -import r2wc from '@r2wc/react-to-web-component'; -import {UserState} from '@webex/cc-user-state'; +``` +React Widget (packages/{widget}/src/{widget}/index.tsx) + ↓ +r2wc Wrapper (packages/{widget}/src/wc.ts) + ↓ +Custom Element Registration (packages/cc-widgets/src/index.ts) + ↓ +HTML Usage () +``` -const WebUserState = r2wc(UserState, { - props: { - onStateChange: 'function', - }, -}); +--- -if (!customElements.get('widget-cc-user-state')) { - customElements.define('widget-cc-user-state', WebUserState); -} -``` +## Example: Complete Web Component Setup -### Example 2: Component-Level Web Component +### 1. Widget Package (station-login/src/wc.ts) ```typescript import r2wc from '@r2wc/react-to-web-component'; -import UserStateComponent from './components/UserState/user-state'; +import { StationLogin } from './station-login'; -const WebUserState = r2wc(UserStateComponent, { +export const StationLoginWC = r2wc(StationLogin, { props: { - idleCodes: 'json', - setAgentStatus: 'function', - isSettingAgentStatus: 'boolean', - elapsedTime: 'number', - currentState: 'string', - customState: 'json', - logger: 'function', + profileMode: 'string', + teamId: 'string', + onLogin: 'function', + onLogout: 'function', + onCCSignOut: 'function', + onSaveStart: 'function', + onSaveEnd: 'function', }, }); - -if (!customElements.get('component-cc-user-state')) { - customElements.define('component-cc-user-state', WebUserState); -} ``` -### Example 3: Shared Props Pattern +### 2. CC-Widgets Registration (cc-widgets/src/index.ts) ```typescript -const commonPropsForCallControl: Record = { - currentTask: 'json', - audioRef: 'json', - wrapupCodes: 'json', - toggleHold: 'function', - endCall: 'function', - isHeld: 'boolean', -}; - -const WebCallControl = r2wc(CallControlComponent, { - props: commonPropsForCallControl, -}); +import { StationLoginWC } from '@webex/cc-station-login/wc'; -const WebCallControlCAD = r2wc(CallControlCADComponent, { - props: commonPropsForCallControl, -}); +customElements.define('cc-station-login', StationLoginWC); ``` -### Example 4: Batch Registration -```typescript -const components = [ - {name: 'widget-cc-user-state', component: WebUserState}, - {name: 'widget-cc-station-login', component: WebStationLogin}, - {name: 'widget-cc-task-list', component: WebTaskList}, -]; - -components.forEach(({name, component}) => { - if (!customElements.get(name)) { - customElements.define(name, component); - } -}); +### 3. HTML Usage +```html + + ``` --- -## Files Analyzed - -1. `/packages/contact-center/cc-widgets/src/wc.ts` (75 lines) -2. `/packages/contact-center/cc-widgets/src/index.ts` (8 lines) -3. `/packages/contact-center/cc-components/src/wc.ts` (109 lines) -4. `/packages/contact-center/cc-widgets/package.json` (100 lines) - ---- - -## Related Documentation - -- [React Patterns](./react-patterns.md) - React component patterns -- [TypeScript Patterns](./typescript-patterns.md) - Prop type definitions -- [Testing Patterns](./testing-patterns.md) - Web Component testing +## Related +- [React Patterns](./react-patterns.md) +- [TypeScript Patterns](./typescript-patterns.md)