From a7fd38152737c60fe52f9658fd2942cd9206a170 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Wed, 9 Jul 2025 17:34:58 +0530 Subject: [PATCH 1/2] feat(metrics): create metrics provider --- .../contact-center/WidgetsProvider/README.md | 231 ++++ .../WidgetsProvider/bable.config.js | 3 + .../WidgetsProvider/eslint.config.mjs | 22 + .../WidgetsProvider/jest.config.js | 6 + .../WidgetsProvider/package.json | 54 + .../WidgetsProvider/src/MetricsContext.tsx | 46 + .../WidgetsProvider/src/WidgetsProvider.tsx | 99 ++ .../WidgetsProvider/src/index.ts | 5 + .../WidgetsProvider/src/metricsLogger.ts | 191 ++++ .../WidgetsProvider/src/withMetrics.tsx | 47 + .../WidgetsProvider/tsconfig.json | 11 + .../WidgetsProvider/webpack.config.js | 62 + .../cc/samples-cc-react-app/package.json | 1 + .../cc/samples-cc-react-app/src/App.tsx | 1000 +++++++++-------- .../cc/samples-cc-react-app/webpack.config.js | 1 + yarn.lock | 47 + 16 files changed, 1330 insertions(+), 496 deletions(-) create mode 100644 packages/contact-center/WidgetsProvider/README.md create mode 100644 packages/contact-center/WidgetsProvider/bable.config.js create mode 100644 packages/contact-center/WidgetsProvider/eslint.config.mjs create mode 100644 packages/contact-center/WidgetsProvider/jest.config.js create mode 100644 packages/contact-center/WidgetsProvider/package.json create mode 100644 packages/contact-center/WidgetsProvider/src/MetricsContext.tsx create mode 100644 packages/contact-center/WidgetsProvider/src/WidgetsProvider.tsx create mode 100644 packages/contact-center/WidgetsProvider/src/index.ts create mode 100644 packages/contact-center/WidgetsProvider/src/metricsLogger.ts create mode 100644 packages/contact-center/WidgetsProvider/src/withMetrics.tsx create mode 100644 packages/contact-center/WidgetsProvider/tsconfig.json create mode 100644 packages/contact-center/WidgetsProvider/webpack.config.js diff --git a/packages/contact-center/WidgetsProvider/README.md b/packages/contact-center/WidgetsProvider/README.md new file mode 100644 index 000000000..8e937f87e --- /dev/null +++ b/packages/contact-center/WidgetsProvider/README.md @@ -0,0 +1,231 @@ +# Webex Contact Center Widgets Provider + +This package provides a React provider component that automatically detects and tracks metrics for Contact Center widgets in your application. + +## Features + +- **Automatic Widget Detection**: The provider automatically scans for widgets in the DOM and tracks them without requiring manual HOC wrapping +- **Metrics Collection**: Collects performance and usage metrics for all detected widgets +- **Theme and Icon Support**: Integrates with Momentum Design System for consistent theming and iconography +- **Context-based Metrics**: Provides React context for accessing metrics data + +## Installation + +```bash +yarn add @webex/cc-widgets-provider +``` + +## Usage + +### Basic Setup + +Simply wrap your application with the `WidgetsProvider`: + +```tsx +import React from 'react'; +import WidgetsProvider from '@webex/cc-widgets-provider'; +import {MyWidget} from './widgets'; + +function App() { + return ( + +
+ +
+
+ ); +} + +export default App; +``` + +### Widget Detection + +For a widget to be automatically detected and tracked, it must have a `data-widget-id` attribute: + +```tsx +// This widget will be automatically detected +
+ +
+ +// This widget will also be detected +
+ +
+``` + +### Accessing Metrics + +Use the `useMetrics` hook to access metrics data in your components: + +```tsx +import React from 'react'; +import {useMetrics} from '@webex/cc-widgets-provider'; + +function MetricsDashboard() { + const {metrics, sessionSummary, refreshMetrics} = useMetrics(); + + return ( +
+

Widget Metrics

+

Total Widgets: {sessionSummary.totalWidgetsLoaded}

+

Active Widgets: {sessionSummary.activeWidgets}

+ + + + +
+ ); +} +``` + +## Props + +### WidgetsProvider Props + +| Prop | Type | Default | Description | +| --------------- | ----------- | ------------------------------- | --------------------------------- | +| `children` | `ReactNode` | Required | Child components to render | +| `enableMetrics` | `boolean` | `true` | Enable/disable metrics collection | +| `themeclass` | `string` | `'mds-theme-stable-lightWebex'` | Momentum Design theme class | +| `iconSet` | `string` | `'momentum-icons'` | Icon set to use | + +## Metrics Events + +The provider automatically tracks the following events: + +- `WIDGET_DETECTED`: When a widget with `data-widget-id` is found in the DOM +- `WIDGET_INITIALIZED`: When a widget is initialized (if using withMetrics HOC) +- `WIDGET_PROP_UPDATED`: When widget props change (if using withMetrics HOC) +- `WIDGET_UNMOUNTED`: When a widget is unmounted (if using withMetrics HOC) + +## Manual Widget Tracking (Optional) + +If you need more granular control, you can still use the `withMetrics` HOC: + +```tsx +import React from 'react'; +import {withMetrics} from '@webex/cc-widgets-provider'; + +const MyWidget = ({title, data}) => { + return ( +
+

{title}

+
{JSON.stringify(data, null, 2)}
+
+ ); +}; + +// Wrap with HOC for detailed tracking +export default withMetrics(MyWidget, 'my-custom-widget'); +``` + +## API Reference + +### useMetrics Hook + +Returns an object with: + +- `metrics`: `Map` - All widget metrics +- `sessionSummary`: Session summary with total and active widget counts +- `refreshMetrics`: Function to manually refresh metrics + +### MetricEvent Interface + +```typescript +interface MetricEvent { + widgetName: string; + event: 'WIDGET_INITIALIZED' | 'WIDGET_UNMOUNTED' | 'WIDGET_PROP_UPDATED' | 'WIDGET_DETECTED'; + props?: any; + timestamp: number; + sessionId?: string; +} +``` + +### WidgetMetrics Interface + +```typescript +interface WidgetMetrics { + widgetName: string; + initializationTime: number; + lastPropUpdate?: number; + unmountTime?: number; + propUpdateCount: number; + currentProps?: any; +} +``` + +## Example + +Here's a complete example of how to use the provider: + +```tsx +import React from 'react'; +import WidgetsProvider, {useMetrics} from '@webex/cc-widgets-provider'; + +// A simple widget component +const StatusWidget = ({status}) =>
Status: {status}
; + +// Component that displays metrics +const MetricsDisplay = () => { + const {metrics, sessionSummary} = useMetrics(); + + return ( +
+

Detected Widgets: {sessionSummary.totalWidgetsLoaded}

+ {Array.from(metrics.keys()).map((widgetName) => ( +

✓ {widgetName}

+ ))} +
+ ); +}; + +// Main application +function App() { + return ( + +

My Contact Center App

+ +
+

Task Panel

+

Current tasks...

+
+ +
+ ); +} + +export default App; +``` + +## Migration from Manual HOC Approach + +If you were previously wrapping each widget with `withMetrics`, you can now simply: + +1. Remove the HOC wrapping from your widgets +2. Add `data-widget-id` attributes to the widget containers +3. The provider will automatically detect and track them + +Before: + +```tsx +export default withMetrics(MyWidget, 'my-widget'); +``` + +After: + +```tsx +// In your JSX +
+ +
+``` + +The automatic detection approach is simpler and requires less code changes while providing the same tracking capabilities. diff --git a/packages/contact-center/WidgetsProvider/bable.config.js b/packages/contact-center/WidgetsProvider/bable.config.js new file mode 100644 index 000000000..0eaef236c --- /dev/null +++ b/packages/contact-center/WidgetsProvider/bable.config.js @@ -0,0 +1,3 @@ +const baseConfig = require('../../../babel.config.js'); + +module.exports = baseConfig; diff --git a/packages/contact-center/WidgetsProvider/eslint.config.mjs b/packages/contact-center/WidgetsProvider/eslint.config.mjs new file mode 100644 index 000000000..b0a9c15ce --- /dev/null +++ b/packages/contact-center/WidgetsProvider/eslint.config.mjs @@ -0,0 +1,22 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import pluginReact from 'eslint-plugin-react'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default [ + {files: ['**/src/**/*.{js,mjs,cjs,ts,jsx,tsx}']}, + { + ignores: ['.babelrc.js', '*config.{js,ts}', 'dist', 'node_modules', 'coverage'], + }, + {languageOptions: {globals: globals.browser}}, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ...pluginReact.configs.flat.recommended, + settings: {react: {version: 'detect'}}, + }, + eslintPluginPrettierRecommended, + eslintConfigPrettier, +]; diff --git a/packages/contact-center/WidgetsProvider/jest.config.js b/packages/contact-center/WidgetsProvider/jest.config.js new file mode 100644 index 000000000..7a32a9a7a --- /dev/null +++ b/packages/contact-center/WidgetsProvider/jest.config.js @@ -0,0 +1,6 @@ +const jestConfig = require('../../../jest.config.js'); + +jestConfig.rootDir = '../../../'; +jestConfig.testMatch = ['**/station-login/tests/**/*.ts', '**/station-login/tests/**/*.tsx']; + +module.exports = jestConfig; diff --git a/packages/contact-center/WidgetsProvider/package.json b/packages/contact-center/WidgetsProvider/package.json new file mode 100644 index 000000000..64ae45728 --- /dev/null +++ b/packages/contact-center/WidgetsProvider/package.json @@ -0,0 +1,54 @@ +{ + "name": "@webex/cc-widgets-provider", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@momentum-design/components": "latest", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "peerDependencies": { + "@momentum-design/components": ">=1.0.0", + "react": ">=18.3.1", + "react-dom": ">=18.3.1" + }, + "devDependencies": { + "@babel/core": "7.25.2", + "@babel/preset-env": "7.25.4", + "@babel/preset-react": "7.24.7", + "@babel/preset-typescript": "7.25.9", + "@eslint/js": "^9.20.0", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.2", + "@testing-library/react": "16.0.1", + "@types/jest": "29.5.14", + "@types/node": "^22.13.13", + "@types/react-test-renderer": "18", + "babel-jest": "29.7.0", + "babel-loader": "9.2.1", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "eslint-config-standard": "^17.1.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-prettier": "^5.2.3", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.37.4", + "file-loader": "6.2.0", + "globals": "^16.0.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "prettier": "^3.5.1", + "ts-loader": "9.5.1", + "typescript": "5.6.3", + "typescript-eslint": "^8.24.1", + "webpack": "5.94.0", + "webpack-cli": "5.1.4", + "webpack-merge": "6.0.1" + }, + "scripts": { + "build:src": "webpack", + "dev": "webpack --mode development --watch" + } +} diff --git a/packages/contact-center/WidgetsProvider/src/MetricsContext.tsx b/packages/contact-center/WidgetsProvider/src/MetricsContext.tsx new file mode 100644 index 000000000..1023cdf5a --- /dev/null +++ b/packages/contact-center/WidgetsProvider/src/MetricsContext.tsx @@ -0,0 +1,46 @@ +import React, {createContext, useContext, useEffect, useState} from 'react'; +import {MetricEvent, WidgetMetrics, addMetricsListener, getMetrics, getSessionSummary} from './metricsLogger'; + +interface MetricsContextType { + metrics: Map; + sessionSummary: ReturnType; + refreshMetrics: () => void; +} + +const MetricsContext = createContext(undefined); + +export const useMetrics = () => { + const context = useContext(MetricsContext); + if (!context) { + throw new Error('useMetrics must be used within a MetricsProvider'); + } + return context; +}; + +export const MetricsProvider: React.FC<{children: React.ReactNode}> = ({children}) => { + const [metrics, setMetrics] = useState>(new Map()); + const [sessionSummary, setSessionSummary] = useState(getSessionSummary()); + + const refreshMetrics = React.useCallback(() => { + setMetrics(getMetrics()); + setSessionSummary(getSessionSummary()); + }, []); + + useEffect(() => { + // Listen for metric events and update state + const unsubscribe = addMetricsListener(() => { + setMetrics(getMetrics()); + setSessionSummary(getSessionSummary()); + }); + + // Initial load + setMetrics(getMetrics()); + setSessionSummary(getSessionSummary()); + + return unsubscribe; + }, []); + + return ( + {children} + ); +}; diff --git a/packages/contact-center/WidgetsProvider/src/WidgetsProvider.tsx b/packages/contact-center/WidgetsProvider/src/WidgetsProvider.tsx new file mode 100644 index 000000000..e6fd96bf1 --- /dev/null +++ b/packages/contact-center/WidgetsProvider/src/WidgetsProvider.tsx @@ -0,0 +1,99 @@ +import React, {ReactNode, useEffect} from 'react'; +import {ThemeProvider, IconProvider} from '@momentum-design/components/dist/react'; +import {MetricsProvider, useMetrics} from './MetricsContext'; +import {sendWidgetEvent} from './metricsLogger'; + +export interface WidgetsProviderProps { + children: ReactNode; + themeclass?: string; + iconSet?: string; + enableMetrics?: boolean; +} + +// Create a wrapper component that has access to the metrics context +const WidgetDetector: React.FC<{enableMetrics: boolean}> = ({enableMetrics}) => { + const {refreshMetrics} = useMetrics(); + + useEffect(() => { + if (enableMetrics) { + const detectedWidgets = new Set(); + + const detectWidgets = () => { + console.log('[WidgetsProvider] Detecting widgets...'); + const widgets = document.querySelectorAll('[data-widget-id]'); + widgets.forEach((widget) => { + if (!detectedWidgets.has(widget as HTMLElement)) { + detectedWidgets.add(widget as HTMLElement); + const widgetId = (widget as HTMLElement).getAttribute('data-widget-id'); + if (widgetId) { + sendWidgetEvent(widgetId, 'WIDGET_DETECTED'); + } + } else { + const widgetId = (widget as HTMLElement).getAttribute('data-widget-id'); + if (widgetId) { + const props = Array.from(widget.attributes).reduce((acc, attr) => { + acc[attr.name] = attr.value; + return acc; + }, {}); + sendWidgetEvent(widgetId, 'WIDGET_PROP_UPDATED', props); + } + } + }); + }; + + const observer = new MutationObserver((mutations) => { + console.log('[WidgetsProvider] Mutation detected, checking for widgets...', mutations); + mutations.forEach((mutation) => { + mutation.removedNodes.forEach((node) => { + if (node instanceof HTMLElement) { + const traverseAndRemoveWidgets = (element: HTMLElement) => { + const widgetId = element.getAttribute('data-widget-id'); + if (widgetId && detectedWidgets.has(element)) { + console.log('[WidgetsProvider] Widget removed:', element); + detectedWidgets.delete(element); + sendWidgetEvent(widgetId, 'WIDGET_UNMOUNTED'); + } + element.querySelectorAll('[data-widget-id]').forEach((child) => { + traverseAndRemoveWidgets(child as HTMLElement); + }); + }; + traverseAndRemoveWidgets(node); + } + }); + }); + detectWidgets(); + }); + + observer.observe(document.body, {childList: true, subtree: true}); + refreshMetrics(); + } + }, [enableMetrics]); + + return null; // This component doesn't render anything +}; + +const WidgetsProvider: React.FC = ({ + children, + themeclass = 'mds-theme-stable-lightWebex', + iconSet = 'momentum-icons', + enableMetrics = true, +}) => { + const content = ( + + {children} + + ); + + if (enableMetrics) { + return ( + + + {content} + + ); + } + + return content; +}; + +export default WidgetsProvider; diff --git a/packages/contact-center/WidgetsProvider/src/index.ts b/packages/contact-center/WidgetsProvider/src/index.ts new file mode 100644 index 000000000..14e5aae29 --- /dev/null +++ b/packages/contact-center/WidgetsProvider/src/index.ts @@ -0,0 +1,5 @@ +export {default as WidgetsProvider} from './WidgetsProvider'; +export * from './WidgetsProvider'; +export {default as withMetrics} from './withMetrics'; +export * from './metricsLogger'; +export * from './MetricsContext'; diff --git a/packages/contact-center/WidgetsProvider/src/metricsLogger.ts b/packages/contact-center/WidgetsProvider/src/metricsLogger.ts new file mode 100644 index 000000000..0d6abadea --- /dev/null +++ b/packages/contact-center/WidgetsProvider/src/metricsLogger.ts @@ -0,0 +1,191 @@ +export interface MetricEvent { + widgetName: string; + event: 'WIDGET_INITIALIZED' | 'WIDGET_UNMOUNTED' | 'WIDGET_PROP_UPDATED' | 'WIDGET_DETECTED'; + props?: any; + timestamp: number; + sessionId?: string; +} + +export interface WidgetMetrics { + widgetName: string; + initializationTime: number; + lastPropUpdate?: number; + unmountTime?: number; + propUpdateCount: number; + currentProps?: any; +} + +class MetricsCollector { + private metrics: Map = new Map(); + private sessionId: string; + private listeners: ((event: MetricEvent) => void)[] = []; + + constructor() { + this.sessionId = this.generateSessionId(); + } + + private generateSessionId(): string { + return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + public logMetrics(event: MetricEvent): void { + console.log('[WidgetsProvider Metrics] Logging event:', event); + const eventWithSession = {...event, sessionId: this.sessionId}; + + // Update internal metrics tracking + this.updateMetrics(eventWithSession); + + // Notify all listeners + this.listeners.forEach((listener) => listener(eventWithSession)); + + // Log to console in development + } + + private updateMetrics(event: MetricEvent): void { + const {widgetName, event: eventType, props, timestamp} = event; + + let widgetMetric = this.metrics.get(widgetName); + + switch (eventType) { + case 'WIDGET_INITIALIZED': + if (!widgetMetric) { + widgetMetric = { + widgetName, + initializationTime: timestamp, + propUpdateCount: 0, + currentProps: props, + }; + this.metrics.set(widgetName, widgetMetric); + } + break; + + case 'WIDGET_PROP_UPDATED': + if (widgetMetric) { + widgetMetric.lastPropUpdate = timestamp; + widgetMetric.propUpdateCount += 1; + widgetMetric.currentProps = props; + } + break; + + case 'WIDGET_UNMOUNTED': + if (widgetMetric) { + widgetMetric.unmountTime = timestamp; + } + break; + + case 'WIDGET_DETECTED': + if (!widgetMetric) { + widgetMetric = { + widgetName, + initializationTime: timestamp, + propUpdateCount: 0, + }; + this.metrics.set(widgetName, widgetMetric); + } + break; + } + } + + public getMetrics(): Map { + return new Map(this.metrics); + } + + public getWidgetMetrics(widgetName: string): WidgetMetrics | undefined { + return this.metrics.get(widgetName); + } + + public getAllLoadedWidgets(): string[] { + return Array.from(this.metrics.keys()); + } + + public addListener(listener: (event: MetricEvent) => void): () => void { + this.listeners.push(listener); + return () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }; + } + + public clearMetrics(): void { + this.metrics.clear(); + } + + public getSessionSummary() { + const loadedWidgets = this.getAllLoadedWidgets(); + const totalWidgets = loadedWidgets.length; + const activeWidgets = Array.from(this.metrics.values()).filter((metric) => !metric.unmountTime).length; + + return { + sessionId: this.sessionId, + totalWidgetsLoaded: totalWidgets, + activeWidgets, + loadedWidgets, + sessionStartTime: Math.min(...Array.from(this.metrics.values()).map((m) => m.initializationTime)), + }; + } +} + +// Global metrics collector instance +const metricsCollector = new MetricsCollector(); + +export const logMetrics = (event: MetricEvent): void => { + metricsCollector.logMetrics(event); +}; + +export const getMetrics = (): Map => { + return metricsCollector.getMetrics(); +}; + +export const getWidgetMetrics = (widgetName: string): WidgetMetrics | undefined => { + return metricsCollector.getWidgetMetrics(widgetName); +}; + +export const getAllLoadedWidgets = (): string[] => { + return metricsCollector.getAllLoadedWidgets(); +}; + +export const addMetricsListener = (listener: (event: MetricEvent) => void): (() => void) => { + return metricsCollector.addListener(listener); +}; + +export const clearMetrics = (): void => { + metricsCollector.clearMetrics(); +}; + +export const getSessionSummary = () => { + return metricsCollector.getSessionSummary(); +}; + +export const havePropsChanged = (prevProps: T, nextProps: T): boolean => { + const prevKeys = Object.keys(prevProps); + const nextKeys = Object.keys(nextProps); + + if (prevKeys.length !== nextKeys.length) { + return true; + } + + for (const key of prevKeys) { + if (prevProps[key as keyof T] !== nextProps[key as keyof T]) { + return true; + } + } + + return false; +}; + +export const sendWidgetEvent = ( + widgetName: string, + event: 'WIDGET_INITIALIZED' | 'WIDGET_UNMOUNTED' | 'WIDGET_PROP_UPDATED' | 'WIDGET_DETECTED', + props?: any +): void => { + const timestamp = Date.now(); + const metricEvent: MetricEvent = { + widgetName, + event, + props, + timestamp, + }; + metricsCollector.logMetrics(metricEvent); +}; diff --git a/packages/contact-center/WidgetsProvider/src/withMetrics.tsx b/packages/contact-center/WidgetsProvider/src/withMetrics.tsx new file mode 100644 index 000000000..320c624d2 --- /dev/null +++ b/packages/contact-center/WidgetsProvider/src/withMetrics.tsx @@ -0,0 +1,47 @@ +import React, {useEffect, useRef} from 'react'; +import {havePropsChanged, logMetrics} from './metricsLogger'; + +export default function withMetrics

(Component: any, widgetName: string) { + return React.memo( + (props: P) => { + const previousProps = useRef

(); + + // Handle mount and unmount events + useEffect(() => { + logMetrics({ + widgetName, + event: 'WIDGET_INITIALIZED', + props, + timestamp: Date.now(), + }); + + return () => { + logMetrics({ + widgetName, + event: 'WIDGET_UNMOUNTED', + timestamp: Date.now(), + }); + }; + }, []); + + // Handle prop updates + useEffect(() => { + if (previousProps.current && havePropsChanged(previousProps.current, props)) { + logMetrics({ + widgetName, + event: 'WIDGET_PROP_UPDATED', + props, + timestamp: Date.now(), + }); + } + previousProps.current = props; + }); + + return ; + }, + (prevProps, nextProps) => { + const hasChanged = havePropsChanged(prevProps, nextProps); + return !hasChanged; + } + ); +} diff --git a/packages/contact-center/WidgetsProvider/tsconfig.json b/packages/contact-center/WidgetsProvider/tsconfig.json new file mode 100644 index 000000000..50b68ce6a --- /dev/null +++ b/packages/contact-center/WidgetsProvider/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "./src" + ], + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types" + }, +} \ No newline at end of file diff --git a/packages/contact-center/WidgetsProvider/webpack.config.js b/packages/contact-center/WidgetsProvider/webpack.config.js new file mode 100644 index 000000000..2e36143d3 --- /dev/null +++ b/packages/contact-center/WidgetsProvider/webpack.config.js @@ -0,0 +1,62 @@ +const {merge} = require('webpack-merge'); +const path = require('path'); + +const baseConfig = require('../../../webpack.config'); + +// Helper function to resolve paths relative to the monorepo root +const resolveMonorepoRoot = (...segments) => path.resolve(__dirname, '../../../', ...segments); + +module.exports = merge(baseConfig, { + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'index.js', // Set the output filename to index.js + libraryTarget: 'commonjs2', + }, + externals: { + react: 'react', + 'react-dom': 'react-dom', + '@webex/cc-store': '@webex/cc-store', + '@momentum-ui/react-collaboration': '@momentum-ui/react-collaboration', + }, + module: { + rules: [ + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + include: [ + resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration'), // Include specific node module + path.resolve(__dirname, 'packages'), // Include all CSS from the local package + ], + }, + { + test: /\.scss$/, + use: [ + 'style-loader', // Injects styles into DOM + 'css-loader', // Turns CSS into CommonJS + 'sass-loader', // Compiles Sass to CSS + ], + include: [ + resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration'), // Include specific node module + path.resolve(__dirname, 'packages'), // Include all CSS from the local package + ], + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + include: [resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration')], + type: 'asset/resource', + generator: { + filename: 'fonts/[name][ext][query]', + }, + }, + { + test: /\.(png|jpg|gif|svg)$/, + include: [resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration')], + + type: 'asset/resource', + generator: { + filename: 'images/[name][ext][query]', + }, + }, + ], + }, +}); diff --git a/widgets-samples/cc/samples-cc-react-app/package.json b/widgets-samples/cc/samples-cc-react-app/package.json index 0d9d49b55..ea1d60904 100644 --- a/widgets-samples/cc/samples-cc-react-app/package.json +++ b/widgets-samples/cc/samples-cc-react-app/package.json @@ -18,6 +18,7 @@ "@babel/preset-typescript": "^7.25.9", "@momentum-ui/react-collaboration": "26.197.0", "@webex/cc-widgets": "workspace:*", + "@webex/cc-widgets-provider": "workspace:*", "babel-loader": "^9.2.1", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.6.3", diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 73e19e036..26f569432 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -11,7 +11,8 @@ import { } from '@webex/cc-widgets'; import {StationLogoutSuccess} from '@webex/plugin-cc'; import Webex from 'webex'; -import {ThemeProvider, IconProvider, Icon, Button, Checkbox, Text, Select, Option} from '@momentum-design/components/dist/react'; +import {Icon, Button, Checkbox, Text, Select, Option} from '@momentum-design/components/dist/react'; +import {WidgetsProvider} from '@webex/cc-widgets-provider'; import {PopoverNext} from '@momentum-ui/react-collaboration'; import './App.scss'; import {observer} from 'mobx-react-lite'; @@ -66,18 +67,18 @@ function App() { }); const handleSaveStart = () => { - setShowLoader(true); - setToast(null); -}; + setShowLoader(true); + setToast(null); + }; -const handleSaveEnd = (isComplete: boolean) => { - setShowLoader(false); - if (isComplete) { - setToast({type: 'success'}); - } else { - setToast({type: 'error'}); - } -}; + const handleSaveEnd = (isComplete: boolean) => { + setShowLoader(false); + if (isComplete) { + setToast({type: 'success'}); + } else { + setToast({type: 'error'}); + } + }; const onIncomingTaskCB = ({task}) => { console.log('Incoming task:', task); @@ -88,28 +89,22 @@ const handleSaveEnd = (isComplete: boolean) => { useEffect(() => { if (window.location.hash) { const urlParams = new URLSearchParams(window.location.hash.replace('#', '?')); - + const accessToken = urlParams.get('access_token'); - + if (accessToken) { window.localStorage.setItem('accessToken', accessToken); setAccessToken(accessToken); // Clear the hash from the URL to remove the token from browser history - window.history.replaceState( - {}, - document.title, - window.location.pathname + window.location.search - ); + window.history.replaceState({}, document.title, window.location.pathname + window.location.search); } - } - else { + } else { const storedAccessToken = window.localStorage.getItem('accessToken'); if (storedAccessToken) { setAccessToken(storedAccessToken); } } - } - , []); + }, []); const webexConfig = { fedramp: false, @@ -157,7 +152,7 @@ const handleSaveEnd = (isComplete: boolean) => { console.log('onTaskAccepted invoked for task:', task); }; -const onTaskDeclined = (task,reason) => { + const onTaskDeclined = (task, reason) => { console.log('onTaskDeclined invoked for task:', task); setRejectedReason(reason); setShowRejectedPopup(true); @@ -265,30 +260,17 @@ const onTaskDeclined = (task,reason) => { } // Reference: https://developer.webex-cx.com/documentation/integrations - const ccMandatoryScopes = [ - "cjp:config_read", - "cjp:config_write", - "cjp:config", - "cjp:user", - ]; + const ccMandatoryScopes = ['cjp:config_read', 'cjp:config_write', 'cjp:config', 'cjp:user']; - const webRTCCallingScopes = [ - "spark:webrtc_calling", - "spark:calls_read", - "spark:calls_write", - "spark:xsi" - ]; + const webRTCCallingScopes = ['spark:webrtc_calling', 'spark:calls_read', 'spark:calls_write', 'spark:xsi']; const additionalScopes = [ - "spark:kms", // to avoid token downscope to only spark:kms error on SDK init + 'spark:kms', // to avoid token downscope to only spark:kms error on SDK init ]; const requestedScopes = Array.from( - new Set( - ccMandatoryScopes - .concat(webRTCCallingScopes) - .concat(additionalScopes)) - ).join(' '); + new Set(ccMandatoryScopes.concat(webRTCCallingScopes).concat(additionalScopes)) + ).join(' '); const webexConfig = { config: { @@ -318,7 +300,7 @@ const onTaskDeclined = (task,reason) => { // Store accessToken changes in local storage useEffect(() => { - if(accessToken.trim() !== '') { + if (accessToken.trim() !== '') { window.localStorage.setItem('accessToken', accessToken); } }, [accessToken]); @@ -347,19 +329,20 @@ const onTaskDeclined = (task,reason) => { }; }, []); - const onStateChange = (status) => { - console.log('onStateChange invoked', status); - //adding a log to be used for automation - console.log('onStateChange invoked with state name:', status?.name); - if (!status || !status.name) return; - if (status.name !== 'RONA') { - setShowRejectedPopup(false); - setRejectedReason(''); - } -}; + const onStateChange = (status) => { + console.log('onStateChange invoked', status); + //adding a log to be used for automation + console.log('onStateChange invoked with state name:', status?.name); + if (!status || !status.name) return; + if (status.name !== 'RONA') { + setShowRejectedPopup(false); + setRejectedReason(''); + } + }; - const stationLogout = () => { - store.cc.stationLogout({logoutReason: 'User requested logout'}) + const stationLogout = () => { + store.cc + .stationLogout({logoutReason: 'User requested logout'}) .then((res: StationLogoutSuccess) => { console.log('Agent logged out successfully', res.data.type); }) @@ -379,374 +362,386 @@ const onTaskDeclined = (task,reason) => { return (

- - -
-

Contact Center Widgets in a React app

- {showLoader && ( -
-
-
- )} +
+

Contact Center Widgets in a React app

+ {showLoader && ( +
+
+
+ )} - {toast && toast.type === 'success' && ( -
- -
-
- Interaction preferences changes -
+ {toast && toast.type === 'success' && ( +
+ +
+
Interaction preferences changes
+
Your interaction preference is updated
+
+
+ )} + +
+
+
+  Authentication  + + +
+ {loginType === 'token' && (
- Your interaction preference is updated + Your access token: + setAccessToken(e.target.value)} />
-
- + )}
- )} - -
+ + +
+
+
+
-  Authentication  - - -
- {loginType === 'token' && ( -
- Your access token: - setAccessToken(e.target.value)} - /> -
- )} - {loginType === 'oauth' && ( - - )} +  Select Widgets to Show  +
+ {Object.keys(defaultWidgets).map((widget) => ( + <> + + + ))}
-
-
-
-
-
-  Select Widgets to Show  -
- {Object.keys(defaultWidgets).map((widget) => ( - <> - - - ))} -
-
-
-
- -
-
-
-  Sample App Toggles and Operations  - { - setCurrentTheme(currentTheme === 'DARK' ? 'LIGHT' : 'DARK'); - store.setCurrentTheme(currentTheme === 'DARK' ? 'LIGHT' : 'DARK'); - }} - /> - { - setShowAgentProfile(!showAgentProfile); - }} - /> - { - setDoStationLogout(!doStationLogout); - }} - /> - { - setintegrationEnv(!integrationEnv); - }} - /> - {store.isAgentLoggedIn && ( - - )} -
-
-
-
-
-  SDK Toggles  - -
-
-
-
-
-
- -
- {isSdkReady && ( - <> - {showAgentProfile && store.agentProfile && ( - <> -
-
-  Agent Profile  - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Agent Name: - - {store.agentProfile.agentName}
- - Profile Type: - - {store.agentProfile.profileType}
- - Org ID: - - {store.agentProfile.orgId}
- - Handle call using: - - {store.agentProfile.deviceType}
- - Roles: - - {store.agentProfile.roles}
- - MM Profile: - - - - - {store.agentProfile.mmProfile && - Object.entries(store.agentProfile.mmProfile).map(([channel, count]) => ( - - - - - ))} - -
- {channel.charAt(0).toUpperCase() + channel.slice(1)} - {count}
-
-
-
- - )} - {selectedWidgets.stationLogin && ( -
-
-
- Station Login -
- -
-
-
-
- )} - {selectedWidgets.stationLoginProfile && store.isAgentLoggedIn && ( -
-
-
- Station Login (Profile Mode) -
- + +
+
+
+  Sample App Toggles and Operations  + { + setCurrentTheme(currentTheme === 'DARK' ? 'LIGHT' : 'DARK'); + store.setCurrentTheme(currentTheme === 'DARK' ? 'LIGHT' : 'DARK'); + }} + /> + { + setShowAgentProfile(!showAgentProfile); + }} + /> + { + setDoStationLogout(!doStationLogout); + }} + /> + { + setintegrationEnv(!integrationEnv); + }} + /> + {store.isAgentLoggedIn && ( + + )} +
+
+
+
+
+  SDK Toggles  +
-
-
- )} - {(store.isAgentLoggedIn || isLoggedIn) && ( - <> - {selectedWidgets.userState && ( -
-
-
- User State - -
-
+ + + +
+
+
+
+
+
+ +
+ {isSdkReady && ( + <> + {showAgentProfile && store.agentProfile && ( + <> +
+
+  Agent Profile  + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Agent Name: + + {store.agentProfile.agentName}
+ + Profile Type: + + {store.agentProfile.profileType}
+ + Org ID: + + {store.agentProfile.orgId}
+ + Handle call using: + + {store.agentProfile.deviceType}
+ + Roles: + + {store.agentProfile.roles}
+ + MM Profile: + + + + + {store.agentProfile.mmProfile && + Object.entries(store.agentProfile.mmProfile).map(([channel, count]) => ( + + + + + ))} + +
+ {channel.charAt(0).toUpperCase() + channel.slice(1)} + {count}
+
+
+
+ + )} + {selectedWidgets.stationLogin && ( +
+
+
+ Station Login +
+
- )} - {selectedWidgets.callControl && store.currentTask && ( -
-
-
- Call Control - -
-
+
+
+
+ )} + {selectedWidgets.stationLoginProfile && store.isAgentLoggedIn && ( +
+
+
+ Station Login (Profile Mode) +
+
- )} - {selectedWidgets.callControlCAD && store.currentTask && ( -
-
-
- Call Control with Call Associated Data (CAD) +
+
+
+ )} + {(store.isAgentLoggedIn || isLoggedIn) && ( + <> + {selectedWidgets.userState && ( +
+
+
+ User State +
+ +
+
+
+
+ )} + {selectedWidgets.callControl && store.currentTask && ( +
+
+
+ Call Control +
+ +
+
+
+
+ )} + {selectedWidgets.callControlCAD && store.currentTask && ( +
+
+
+ Call Control with Call Associated Data (CAD) +
{ callControlClassName={'call-control-outer'} callControlConsultClassName={'call-control-consult-outer'} /> -
-
-
- )} - - {selectedWidgets.incomingTask && ( - <> -
-
- {incomingTasks.map((task) => ( -
{ - if (collapsedTasks.includes(task.data.interactionId)) { - setCollapsedTasks((prev) => prev.filter((id) => id !== task.data.interactionId)); - } - }} - > - <> - - - -
- ))} -
-
- - )} +
+ + +
+ )} - {selectedWidgets.taskList && ( -
+ {selectedWidgets.incomingTask && ( + <> +
-
- Task List - -
+ {incomingTasks.map((task) => ( +
{ + if (collapsedTasks.includes(task.data.interactionId)) { + setCollapsedTasks((prev) => prev.filter((id) => id !== task.data.interactionId)); + } + }} + data-widget-id={`incoming-task-${task.data.interactionId}`} + > + <> + + + +
+ ))}
- )} - {selectedWidgets.outdialCall && } - - )} - - )} - {showRejectedPopup && ( -
- - -
Task Rejected
-
- -
- Reason: {rejectedReason} -
-
- - -
- )} + + )} + + {selectedWidgets.taskList && ( +
+
+
+ Task List +
+ +
+
+
+
+ )} + {selectedWidgets.outdialCall && ( +
+ +
+ )} + + )} + + )} + {showRejectedPopup && ( +
+ + +
Task Rejected
+
+ +
+ Reason: {rejectedReason} +
+
+ + +
+ )} - {isSdkReady && (store.isAgentLoggedIn || isLoggedIn) && ( + {isSdkReady && (store.isAgentLoggedIn || isLoggedIn) && ( +
- )} -
- - +
+ )} +
+
); } diff --git a/widgets-samples/cc/samples-cc-react-app/webpack.config.js b/widgets-samples/cc/samples-cc-react-app/webpack.config.js index cfd17aaf2..54fb6dd75 100644 --- a/widgets-samples/cc/samples-cc-react-app/webpack.config.js +++ b/widgets-samples/cc/samples-cc-react-app/webpack.config.js @@ -17,6 +17,7 @@ module.exports = merge(baseConfig, { '@webex/cc-user-state': path.resolve(__dirname, '../../../packages/contact-center/user-state/src'), '@webex/cc-task': path.resolve(__dirname, '../../../packages/contact-center/task/src'), '@webex/cc-components': path.resolve(__dirname, '../../../packages/contact-center/cc-components/src'), + '@webex/cc-widgets-provider': path.resolve(__dirname, '../../../packages/contact-center/WidgetsProvider/src'), }, }, module: { diff --git a/yarn.lock b/yarn.lock index f84429f86..bf84ae33f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9604,6 +9604,52 @@ __metadata: languageName: unknown linkType: soft +"@webex/cc-widgets-provider@workspace:*, @webex/cc-widgets-provider@workspace:packages/contact-center/WidgetsProvider": + version: 0.0.0-use.local + resolution: "@webex/cc-widgets-provider@workspace:packages/contact-center/WidgetsProvider" + dependencies: + "@babel/core": "npm:7.25.2" + "@babel/preset-env": "npm:7.25.4" + "@babel/preset-react": "npm:7.24.7" + "@babel/preset-typescript": "npm:7.25.9" + "@eslint/js": "npm:^9.20.0" + "@momentum-design/components": "npm:latest" + "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.6.2" + "@testing-library/react": "npm:16.0.1" + "@types/jest": "npm:29.5.14" + "@types/node": "npm:^22.13.13" + "@types/react-test-renderer": "npm:18" + babel-jest: "npm:29.7.0" + babel-loader: "npm:9.2.1" + eslint: "npm:^9.20.1" + eslint-config-prettier: "npm:^10.0.1" + eslint-config-standard: "npm:^17.1.0" + eslint-plugin-import: "npm:^2.25.2" + eslint-plugin-n: "npm:^15.0.0 || ^16.0.0 " + eslint-plugin-prettier: "npm:^5.2.3" + eslint-plugin-promise: "npm:^6.0.0" + eslint-plugin-react: "npm:^7.37.4" + file-loader: "npm:6.2.0" + globals: "npm:^16.0.0" + jest: "npm:29.7.0" + jest-environment-jsdom: "npm:29.7.0" + prettier: "npm:^3.5.1" + react: "npm:18.3.1" + react-dom: "npm:18.3.1" + ts-loader: "npm:9.5.1" + typescript: "npm:5.6.3" + typescript-eslint: "npm:^8.24.1" + webpack: "npm:5.94.0" + webpack-cli: "npm:5.1.4" + webpack-merge: "npm:6.0.1" + peerDependencies: + "@momentum-design/components": ">=1.0.0" + react: ">=18.3.1" + react-dom: ">=18.3.1" + languageName: unknown + linkType: soft + "@webex/cc-widgets@workspace:*, @webex/cc-widgets@workspace:packages/contact-center/cc-widgets": version: 0.0.0-use.local resolution: "@webex/cc-widgets@workspace:packages/contact-center/cc-widgets" @@ -31032,6 +31078,7 @@ __metadata: "@eslint/js": "npm:^9.20.0" "@momentum-ui/react-collaboration": "npm:26.197.0" "@webex/cc-widgets": "workspace:*" + "@webex/cc-widgets-provider": "workspace:*" babel-loader: "npm:^9.2.1" eslint: "npm:^9.20.1" eslint-config-prettier: "npm:^10.0.1" From c7bc8db964976b43232649c2baabcb971a7c4ad4 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Wed, 9 Jul 2025 18:11:10 +0530 Subject: [PATCH 2/2] fix(metrics): no extra change in app --- .../station-login/src/station-login/index.tsx | 6 +++++- .../task/src/CallControl/index.tsx | 6 +++++- .../task/src/CallControlCAD/index.tsx | 6 +++++- .../task/src/IncomingTask/index.tsx | 6 +++++- .../task/src/OutdialCall/index.tsx | 6 +++++- .../contact-center/task/src/TaskList/index.tsx | 6 +++++- .../user-state/src/user-state/index.tsx | 6 +++++- .../cc/samples-cc-react-app/src/App.tsx | 17 ++++++++--------- 8 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/contact-center/station-login/src/station-login/index.tsx b/packages/contact-center/station-login/src/station-login/index.tsx index 98293bef4..65fd13173 100644 --- a/packages/contact-center/station-login/src/station-login/index.tsx +++ b/packages/contact-center/station-login/src/station-login/index.tsx @@ -54,7 +54,11 @@ const StationLogin: React.FunctionComponent = observer( logger, profileMode, }; - return ; + return ( +
+ +
+ ); } ); diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 82929a599..16964fbe6 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -45,7 +45,11 @@ const CallControl: React.FunctionComponent = observer( allowConsultToQueue, logger, }; - return ; + return ( +
+ +
+ ); } ); diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index 29d395a1f..c0f22a2ea 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -47,7 +47,11 @@ const CallControlCAD: React.FunctionComponent = observer( logger, }; - return ; + return ( +
+ +
+ ); } ); diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index 6dfe5d4db..b612631bb 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -15,7 +15,11 @@ const IncomingTask: React.FunctionComponent = observer(({inco logger, }; - return ; + return ( +
+ +
+ ); }); export {IncomingTask}; diff --git a/packages/contact-center/task/src/OutdialCall/index.tsx b/packages/contact-center/task/src/OutdialCall/index.tsx index aa6eff781..63735c56b 100644 --- a/packages/contact-center/task/src/OutdialCall/index.tsx +++ b/packages/contact-center/task/src/OutdialCall/index.tsx @@ -12,7 +12,11 @@ const OutdialCall: React.FunctionComponent = observer(() => { ...result, }; - return ; + return ( +
+ +
+ ); }); export {OutdialCall}; diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx index 6d3ed878d..d54f89227 100644 --- a/packages/contact-center/task/src/TaskList/index.tsx +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -17,7 +17,11 @@ const TaskList: React.FunctionComponent = observer( logger, }; - return ; + return ( +
+ +
+ ); } ); diff --git a/packages/contact-center/user-state/src/user-state/index.tsx b/packages/contact-center/user-state/src/user-state/index.tsx index 6e03ad675..48000d5cd 100644 --- a/packages/contact-center/user-state/src/user-state/index.tsx +++ b/packages/contact-center/user-state/src/user-state/index.tsx @@ -35,7 +35,11 @@ const UserState: React.FunctionComponent = observer(({onStateCh logger, }; - return ; + return ( +
+ +
+ ); }); export {UserState}; diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 26f569432..a43730d6f 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -680,7 +680,7 @@ function App() {
Station Login -
+
Station Login (Profile Mode) -
+
@@ -712,7 +712,7 @@ function App() {
User State -
+
@@ -724,7 +724,7 @@ function App() {
Call Control -
+
Call Control with Call Associated Data (CAD) -
+
prev.filter((id) => id !== task.data.interactionId)); } }} - data-widget-id={`incoming-task-${task.data.interactionId}`} > <>