Skip to content

Commit cdb3858

Browse files
akulakumrsarika
andauthored
feat(cc-digital-channel): add digital channel package (#586)
Co-authored-by: Ravi Chandra Sekhar Sarika <rsarika@cisco.com>
1 parent 0a43537 commit cdb3858

34 files changed

Lines changed: 1879 additions & 476 deletions

jest.setup.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// This is a workaround for the fact that JSDOM does not support canvas methods like getContext.
33
import 'jest-canvas-mock';
44

5+
// Set up the global that cc-digital-interactions expects
6+
global.AGENTX_SERVICE = {};
7+
58
// Web components used in @momentum-design imports rely on browser-only APIs like attachInternals.
69
// Jest (via JSDOM) doesn't support these, causing runtime errors in tests.
710
// We mock these methods on HTMLElement to prevent test failures.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import globals from 'globals';
2+
import pluginJs from '@eslint/js';
3+
import tseslint from 'typescript-eslint';
4+
import pluginReact from 'eslint-plugin-react';
5+
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
6+
import eslintConfigPrettier from 'eslint-config-prettier';
7+
8+
export default [
9+
{files: ['**/src/**/*.{js,mjs,cjs,ts,jsx,tsx}']},
10+
{ignores: ['.babelrc.js', '*config.{js,ts}', 'dist', 'node_modules', 'coverage']},
11+
{languageOptions: {globals: globals.browser}},
12+
pluginJs.configs.recommended,
13+
...tseslint.configs.recommended,
14+
{
15+
...pluginReact.configs.flat.recommended,
16+
settings: {react: {version: 'detect'}},
17+
},
18+
eslintPluginPrettierRecommended,
19+
eslintConfigPrettier,
20+
];
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const jestConfig = require('../../../jest.config.js');
2+
3+
jestConfig.rootDir = '../../../';
4+
jestConfig.testMatch = ['**/cc-digital-channels/tests/**/*.ts', '**/cc-digital-channels/tests/**/*.tsx'];
5+
jestConfig.transformIgnorePatterns = [
6+
'/node_modules/(?!(@momentum-design/components|@momentum-ui/web-components|@momentum-ui/react-collaboration|@lit|lit|cheerio|@popperjs|@webex-engage|@interactjs|react-error-boundary))',
7+
];
8+
9+
module.exports = jestConfig;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"name": "@webex/cc-digital-channels",
3+
"description": "Webex Contact Center Widgets: Digital Channels",
4+
"license": "Cisco's General Terms (https://www.cisco.com/site/us/en/about/legal/contract-experience/index.html)",
5+
"version": "1.28.0-ccwidgets.126",
6+
"main": "dist/index.js",
7+
"types": "dist/types/index.d.ts",
8+
"publishConfig": {
9+
"access": "public"
10+
},
11+
"files": [
12+
"dist/",
13+
"package.json"
14+
],
15+
"scripts": {
16+
"clean": "rm -rf dist && rm -rf node_modules",
17+
"clean:dist": "rm -rf dist",
18+
"build": "yarn run -T tsc",
19+
"build:src": "yarn run clean:dist && yarn run build && webpack",
20+
"build:watch": "webpack --watch",
21+
"test:unit": "jest --coverage",
22+
"test:styles": "eslint",
23+
"deploy:npm": "yarn npm publish"
24+
},
25+
"dependencies": {
26+
"@webex/cc-store": "workspace:*",
27+
"cc-digital-interactions": "3.0.6-beta.1",
28+
"mobx-react-lite": "^4.1.0"
29+
},
30+
"devDependencies": {
31+
"@babel/core": "7.25.2",
32+
"@babel/preset-env": "7.25.4",
33+
"@babel/preset-react": "7.24.7",
34+
"@babel/preset-typescript": "7.25.9",
35+
"@eslint/js": "^9.20.0",
36+
"@testing-library/dom": "10.4.0",
37+
"@testing-library/jest-dom": "6.6.2",
38+
"@testing-library/react": "16.0.1",
39+
"@types/jest": "29.5.14",
40+
"@webex/test-fixtures": "workspace:*",
41+
"babel-jest": "29.7.0",
42+
"babel-loader": "9.2.1",
43+
"css-loader": "7.1.2",
44+
"eslint": "^9.20.1",
45+
"eslint-config-prettier": "^10.0.1",
46+
"eslint-config-standard": "^17.1.0",
47+
"eslint-plugin-import": "^2.25.2",
48+
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
49+
"eslint-plugin-prettier": "^5.2.3",
50+
"eslint-plugin-promise": "^6.0.0",
51+
"eslint-plugin-react": "^7.37.4",
52+
"globals": "^16.0.0",
53+
"jest": "29.7.0",
54+
"jest-environment-jsdom": "29.7.0",
55+
"prettier": "^3.5.1",
56+
"sass": "1.79.5",
57+
"sass-loader": "16.0.2",
58+
"style-loader": "4.0.0",
59+
"ts-jest": "^29.1.1",
60+
"ts-loader": "9.5.1",
61+
"typescript": "5.6.3",
62+
"typescript-eslint": "^8.24.1",
63+
"webpack": "5.94.0",
64+
"webpack-cli": "5.1.4",
65+
"webpack-merge": "6.0.1"
66+
},
67+
"peerDependencies": {
68+
"@momentum-ui/web-components": "^2.23.35",
69+
"react": ">=18.3.1",
70+
"react-dom": ">=18.3.1"
71+
}
72+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React, {useMemo} from 'react';
2+
import Engage from 'cc-digital-interactions';
3+
4+
import '@momentum-ui/web-components';
5+
import {DigitalChannelsComponentProps} from './digital-channels.types';
6+
7+
/**
8+
* Presentation component for Digital Channels.
9+
* Renders the Engage widget with proper theming.
10+
*/
11+
const DigitalChannelsComponent: React.FunctionComponent<DigitalChannelsComponentProps> = ({
12+
conversationId,
13+
jwtToken,
14+
dataCenter,
15+
currentTheme = 'LIGHT',
16+
}) => {
17+
// Create a stable key based on critical props to force remount when they change
18+
// This prevents issues with the Froala editor trying to cleanup/reinitialize improperly
19+
const componentKey = useMemo(() => {
20+
return `${conversationId}-${jwtToken.slice(-8)}-${dataCenter}`;
21+
}, [conversationId, jwtToken, dataCenter]);
22+
23+
const isDarkTheme = currentTheme === 'DARK';
24+
25+
return (
26+
<div>
27+
<md-theme id="app-theme" theme="momentumV2" {...(isDarkTheme ? {darktheme: true} : {lighttheme: true})}>
28+
<Engage
29+
key={componentKey}
30+
conversationId={conversationId}
31+
jwtToken={jwtToken}
32+
dataCenter={dataCenter}
33+
interactionId=""
34+
readonly={false}
35+
theme={isDarkTheme ? 'dark' : 'light'}
36+
isVisualRebrand={true}
37+
/>
38+
</md-theme>
39+
</div>
40+
);
41+
};
42+
43+
export {DigitalChannelsComponent};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {ITask} from '@webex/cc-store';
2+
3+
export interface DigitalChannelsInitHookProps {
4+
currentTask: ITask;
5+
jwtToken: string;
6+
dataCenter: string;
7+
logger: {
8+
log: (message: string, meta?: Record<string, unknown>) => void;
9+
error: (message: string, error?: unknown, meta?: Record<string, unknown>) => void;
10+
};
11+
isDigitalChannelsInitialized: boolean;
12+
setDigitalChannelsInitialized: (value: boolean) => void;
13+
skipInit?: boolean;
14+
}
15+
16+
export interface DigitalChannelsDataHookProps {
17+
getAccessToken: () => Promise<string>;
18+
currentTask: ITask | null;
19+
logger?: {
20+
log: (message: string, meta?: Record<string, unknown>) => void;
21+
error: (message: string, meta?: Record<string, unknown>) => void;
22+
};
23+
}
24+
25+
export interface DigitalChannelsProps {
26+
currentTheme?: string;
27+
}
28+
29+
export interface DigitalChannelsComponentProps {
30+
conversationId: string;
31+
jwtToken: string;
32+
dataCenter: string;
33+
currentTheme?: string;
34+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import {observer} from 'mobx-react-lite';
3+
import {ErrorBoundary} from 'react-error-boundary';
4+
5+
import store from '@webex/cc-store';
6+
import {useDigitalChannelsInit, useDigitalChannelsData} from '../helper';
7+
import {DigitalChannelsComponent} from './DigitalChannelsComponent';
8+
import {DigitalChannelsProps} from './digital-channels.types';
9+
10+
const DigitalChannelsInternal: React.FunctionComponent<DigitalChannelsProps> = observer(({currentTheme}) => {
11+
const {logger, currentTask, isDigitalChannelsInitialized, setDigitalChannelsInitialized, getAccessToken, dataCenter} =
12+
store;
13+
14+
// Fetch JWT token and conversation ID
15+
const {jwtToken, conversationId, hasError} = useDigitalChannelsData({
16+
getAccessToken,
17+
currentTask,
18+
logger,
19+
});
20+
21+
// Initialize Digital Channels app once we have all required data
22+
const {initialized} = useDigitalChannelsInit({
23+
currentTask: currentTask || ({} as typeof currentTask),
24+
jwtToken: jwtToken || '',
25+
dataCenter: dataCenter || '',
26+
logger,
27+
isDigitalChannelsInitialized,
28+
setDigitalChannelsInitialized,
29+
// Skip initialization if we don't have required data
30+
skipInit: !currentTask || !jwtToken || !dataCenter,
31+
});
32+
33+
// Early return after all hooks are called
34+
if (!currentTask || !jwtToken || !dataCenter || hasError || !initialized || !conversationId) {
35+
return null;
36+
}
37+
38+
return (
39+
<DigitalChannelsComponent
40+
conversationId={conversationId}
41+
jwtToken={jwtToken}
42+
dataCenter={dataCenter}
43+
currentTheme={currentTheme}
44+
/>
45+
);
46+
});
47+
48+
const DigitalChannels: React.FunctionComponent<DigitalChannelsProps> = (props) => {
49+
return (
50+
<ErrorBoundary
51+
fallbackRender={() => <></>}
52+
onError={(error: Error) => {
53+
if (store.onErrorCallback) store.onErrorCallback('DigitalChannels', error);
54+
}}
55+
>
56+
<DigitalChannelsInternal {...props} />
57+
</ErrorBoundary>
58+
);
59+
};
60+
61+
export {DigitalChannels};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {useEffect, useState, useMemo} from 'react';
2+
import {initializeApp} from 'cc-digital-interactions';
3+
4+
import {DigitalChannelsInitHookProps, DigitalChannelsDataHookProps} from './digital-channels/digital-channels.types';
5+
6+
/**
7+
* Hook to handle Digital Channels initialization.
8+
* Ensures initialization happens only once per session using store flag.
9+
*/
10+
export const useDigitalChannelsInit = (props: DigitalChannelsInitHookProps) => {
11+
const {
12+
currentTask,
13+
jwtToken,
14+
dataCenter,
15+
logger,
16+
isDigitalChannelsInitialized,
17+
setDigitalChannelsInitialized,
18+
skipInit = false,
19+
} = props;
20+
21+
const [initialized, setInitialized] = useState(isDigitalChannelsInitialized);
22+
23+
useEffect(() => {
24+
// Skip initialization if required data is not available
25+
if (skipInit) {
26+
return;
27+
}
28+
29+
const initialize = async () => {
30+
// Initialize the digital channels app only once per session
31+
if (!isDigitalChannelsInitialized) {
32+
logger.log(
33+
`[DIGITAL_CHANNELS_INIT] Starting Digital Channels initialization for the FIRST TIME (dataCenter: ${dataCenter})...`,
34+
{
35+
module: 'cc-digital-channels',
36+
method: 'useDigitalChannelsInit',
37+
}
38+
);
39+
40+
try {
41+
await initializeApp(dataCenter, jwtToken);
42+
setDigitalChannelsInitialized(true);
43+
setInitialized(true);
44+
logger.log('[DIGITAL_CHANNELS_INIT] ✅ Digital Channels app initialized SUCCESSFULLY', {
45+
module: 'cc-digital-channels',
46+
method: 'useDigitalChannelsInit',
47+
});
48+
} catch (error) {
49+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
50+
logger.error(`[DIGITAL_CHANNELS_INIT] ❌ Failed to initialize Digital Channels app: ${errorMessage}`, {
51+
module: 'cc-digital-channels',
52+
method: 'useDigitalChannelsInit',
53+
error,
54+
});
55+
}
56+
} else {
57+
logger.log('[DIGITAL_CHANNELS_INIT] ✅ App already initialized. Skipping re-initialization.', {
58+
module: 'cc-digital-channels',
59+
method: 'useDigitalChannelsInit',
60+
});
61+
setInitialized(true);
62+
}
63+
};
64+
65+
initialize();
66+
}, [currentTask, skipInit, jwtToken]);
67+
68+
return {initialized};
69+
};
70+
71+
/**
72+
* Hook to handle fetching Digital Channels data (token and conversationId).
73+
* Centralizes token fetching logic to keep the component clean.
74+
*/
75+
export const useDigitalChannelsData = (props: DigitalChannelsDataHookProps) => {
76+
const {getAccessToken, currentTask, logger} = props;
77+
78+
const [jwtToken, setJwtToken] = useState<string>('');
79+
const [tokenError, setTokenError] = useState<boolean>(false);
80+
81+
// Fetch access token from the store
82+
useEffect(() => {
83+
const fetchToken = async () => {
84+
try {
85+
const token = await getAccessToken();
86+
setJwtToken(token);
87+
} catch (error) {
88+
logger?.error('[DIGITAL_CHANNELS] ❌ Failed to get access token', {
89+
module: 'cc-digital-channels',
90+
method: 'useDigitalChannelsData.fetchToken',
91+
error,
92+
});
93+
setTokenError(true);
94+
}
95+
};
96+
fetchToken();
97+
}, [getAccessToken, logger]);
98+
99+
// Extract conversationId from currentTask (always call this, return empty string if no task)
100+
const conversationId = useMemo(() => {
101+
if (!currentTask) return '';
102+
return (
103+
(currentTask.data.interaction as {callAssociatedDetails?: {mediaResourceId?: string}}).callAssociatedDetails
104+
?.mediaResourceId || ''
105+
);
106+
}, [currentTask]);
107+
108+
const hasError = tokenError;
109+
110+
return {
111+
jwtToken,
112+
conversationId,
113+
tokenError,
114+
hasError,
115+
};
116+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import {DigitalChannels} from './digital-channels';
2+
3+
export {DigitalChannels};
4+
export default DigitalChannels;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
declare global {
2+
namespace JSX {
3+
interface IntrinsicElements {
4+
'md-theme': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
5+
theme?: string;
6+
class?: string;
7+
darktheme?: boolean;
8+
lighttheme?: boolean;
9+
};
10+
}
11+
}
12+
}
13+
14+
export {};

0 commit comments

Comments
 (0)