diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index e93cfb8a9..abfe8e114 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -22,22 +22,8 @@ } } -.global-variables { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - width: 100%; - max-height: 11.25rem; - overflow-y: auto; - margin-top: 0.75rem; - - .global-variable-item { - flex-basis: 50%; - min-width: 0; - box-sizing: border-box; - padding: 0.125rem 0; - line-height: 1.75rem; - } +.cad-global-variables { + margin-top: 0.5rem; } /* On-hold chip styling */ @@ -192,6 +178,11 @@ .media-icon.chat { color: var(--mds-color-theme-indicator-secure); } + + .campaign-call-avatar { + --mdc-avatar-default-background-color: var(--mds-color-theme-avatar-campaign); + --mdc-avatar-default-foreground-color: var(--mds-color-theme-indicator-stable); + } } .call-control-task-tooltip::part(popover-content) { white-space: normal; diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index 34eea5920..13b491ece 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, {useRef} from 'react'; import CallControlComponent from '../CallControl/call-control'; import {Text, PopoverNext} from '@momentum-ui/react-collaboration'; -import {Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; +import {Avatar, Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; import './call-control-cad.styles.scss'; import TaskTimer from '../TaskTimer/index'; import CallControlConsultComponent from '../CallControl/CallControlCustom/call-control-consult'; @@ -12,6 +12,7 @@ import { CallAssociatedDataMap, } from '../task.types'; import {getAgentViewableGlobalVariables} from '../Task/task.utils'; +import GlobalVariablesPanel from '../GlobalVariablesPanel/global-variables-panel'; import {getMediaTypeInfo} from '../../../utils'; import { @@ -23,6 +24,7 @@ import { QUEUE, PHONE_NUMBER, CUSTOMER_NAME, + CAMPAIGN_CALL, } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; @@ -48,6 +50,7 @@ const CallControlCADComponent: React.FC = (props) => isMuted, toggleMute, conferenceParticipants, + isCampaignCall = false, } = props; const formatTime = (time: number): string => { @@ -77,7 +80,22 @@ const CallControlCADComponent: React.FC = (props) => //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 const callAssociatedData = currentTask?.data?.interaction?.callAssociatedData as CallAssociatedDataMap | undefined; - const globalVariables = getAgentViewableGlobalVariables(callAssociatedData); + const latestGlobalVariables = getAgentViewableGlobalVariables(callAssociatedData); + + // Persist global variables across task updates — some store refreshes + // replace currentTask with a snapshot that omits callAssociatedData. + // Reset when the interaction changes so stale CAD from a previous task + // is never shown on a new call. + const interactionId = currentTask.data.interaction.interactionId; + const globalVariablesRef = useRef(latestGlobalVariables); + const prevInteractionIdRef = useRef(interactionId); + if (prevInteractionIdRef.current !== interactionId) { + prevInteractionIdRef.current = interactionId; + globalVariablesRef.current = latestGlobalVariables; + } else if (latestGlobalVariables.length > 0) { + globalVariablesRef.current = latestGlobalVariables; + } + const globalVariables = globalVariablesRef.current; // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; @@ -178,7 +196,9 @@ const CallControlCADComponent: React.FC = (props) => {/* Caller Information */}
- {currentMediaType.isBrandVisual ? ( + {isCampaignCall ? ( + + ) : currentMediaType.isBrandVisual ? ( ) : ( @@ -190,7 +210,8 @@ const CallControlCADComponent: React.FC = (props) =>
- {currentMediaType.labelName} - + {isCampaignCall ? CAMPAIGN_CALL : currentMediaType.labelName} -{' '} + {stateTimerLabel && stateTimerTimestamp && ( <> {' '} @@ -285,24 +306,7 @@ const CallControlCADComponent: React.FC = (props) => {renderPhoneNumber()}
- {globalVariables.length > 0 && ( -
- {globalVariables.map((variable) => ( -
- - {variable.displayName || variable.name} - - - {variable.value || ''} - -
- ))} -
- )} +
{controlVisibility.isConsultInitiatedOrAccepted && !controlVisibility.wrapup.isVisible && (
diff --git a/packages/contact-center/cc-components/src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts b/packages/contact-center/cc-components/src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts index 78f35bbbd..29ebf53e8 100644 --- a/packages/contact-center/cc-components/src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts +++ b/packages/contact-center/cc-components/src/components/task/CampaignErrorDialog/campaign-error-dialog.types.ts @@ -1,4 +1,4 @@ -export type CampaignErrorType = 'ACCEPT_FAILED' | 'SKIP_FAILED' | 'REMOVE_FAILED'; +export type CampaignErrorType = 'ACCEPT_FAILED' | 'SKIP_FAILED' | 'REMOVE_FAILED' | 'CANCEL_FAILED'; export interface CampaignErrorDialogProps { errorType: CampaignErrorType; @@ -10,6 +10,7 @@ export const ERROR_TITLES: Record = { ACCEPT_FAILED: "Can't accept contact", SKIP_FAILED: "Can't skip contact", REMOVE_FAILED: "Can't remove contact", + CANCEL_FAILED: "Can't cancel contact", }; export const ERROR_MESSAGE = diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.tsx b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.tsx new file mode 100644 index 000000000..a586060e0 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import {Avatar, Button, ListItem, Text, Tooltip} from '@momentum-design/components/dist/react'; +import CampaignCountdown from '../../CampaignCountdown/campaign-countdown'; +import TaskTimer from '../../TaskTimer/index'; +import {CampaignTaskListItemProps} from './campaign-task-list-item.types'; +import { + CAMPAIGN_ACCEPT, + CAMPAIGN_CONNECTING, + CAMPAIGN_SKIP, + CAMPAIGN_SKIP_TOOLTIP, + CAMPAIGN_SKIP_DISABLED_TOOLTIP, + CAMPAIGN_REMOVE, + CAMPAIGN_REMOVE_TOOLTIP, + CAMPAIGN_REMOVE_DISABLED_TOOLTIP, + CAMPAIGN_ACTIONS_LABEL, + HANDLE_TIME, +} from '../../constants'; + +/** + * CampaignTaskListItem renders the ListItem row shared between the + * CampaignTask inline card and the CampaignTaskPopover. + * + * Layout: Avatar | Title / Phone / Countdown | Accept + Skip/Remove buttons + */ +const CampaignTaskListItem: React.FC = ({ + title, + phoneNumber, + customerName, + timeoutTimestamp, + isAcceptClicked, + isAccepted, + isAcceptDisabled, + isSkipDisabled, + isRemoveDisabled, + onAccept, + onSkip, + onRemove, + onTimeout, + handleTimestamp, + logger, + className, + testIdPrefix = 'campaign-task', +}) => { + const skipTooltipText = isSkipDisabled ? CAMPAIGN_SKIP_DISABLED_TOOLTIP : CAMPAIGN_SKIP_TOOLTIP; + const removeTooltipText = isRemoveDisabled ? CAMPAIGN_REMOVE_DISABLED_TOOLTIP : CAMPAIGN_REMOVE_TOOLTIP; + const skipButtonId = `${testIdPrefix}-skip-btn`; + const removeButtonId = `${testIdPrefix}-remove-btn`; + + return ( + + + + + {title} + + {customerName && phoneNumber && phoneNumber !== customerName && ( + + {phoneNumber} + + )} + {!isAccepted && timeoutTimestamp && ( +
+ +
+ )} + {isAccepted && handleTimestamp && ( + + {HANDLE_TIME} + + )} + + {!isAccepted && ( +
+ {!isAcceptClicked ? ( + + ) : ( + + )} + +
+
+
+ )} +
+ ); +}; + +export default CampaignTaskListItem; diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.types.ts b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.types.ts new file mode 100644 index 000000000..5d05642da --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.types.ts @@ -0,0 +1,61 @@ +import {ILogger} from '@webex/cc-store'; + +/** + * Properties for the CampaignTaskListItem component. + * + * Renders the ListItem row shared between the CampaignTask card + * and CampaignTaskPopover: avatar, title, phone, countdown, and + * Accept / Skip / Remove action buttons. + */ +export interface CampaignTaskListItemProps { + /** Display title (customer name or caller identifier). */ + title: string; + + /** Phone number to show as secondary label. */ + phoneNumber?: string; + + /** Customer name — used to decide whether to show phone as secondary label. */ + customerName?: string; + + /** Campaign preview offer timeout timestamp (ms string). */ + timeoutTimestamp?: string; + + /** Whether the Accept button has been clicked (shows "Connecting..." state). */ + isAcceptClicked: boolean; + + /** Whether the campaign preview has been accepted by the backend (call controls visible). */ + isAccepted: boolean; + + /** Whether the Accept button is disabled. */ + isAcceptDisabled: boolean; + + /** Whether the Skip button is disabled. */ + isSkipDisabled: boolean; + + /** Whether the Remove button is disabled. */ + isRemoveDisabled: boolean; + + /** Handler for Accept button click. */ + onAccept: () => void; + + /** Handler for Skip button click. */ + onSkip: () => void; + + /** Handler for Remove button click. */ + onRemove: () => void; + + /** Handler for countdown timeout. */ + onTimeout: () => void; + + /** Timestamp (ms) when the campaign call was accepted — used for the handle time timer. */ + handleTimestamp?: number; + + /** Logger instance. */ + logger?: ILogger; + + /** Optional CSS class name applied to the ListItem. */ + className?: string; + + /** Optional test ID prefix for data-testid attributes. */ + testIdPrefix?: string; +} diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.style.scss b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.style.scss new file mode 100644 index 000000000..a52cc8a12 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.style.scss @@ -0,0 +1,56 @@ +.campaign-task-popover { + &__content { + display: flex; + flex-direction: column; + padding: 0.5rem; + gap: 0.5rem; + min-height: 0; + overflow: hidden; + + // Cap the variables panel height inside the popover so it scrolls + // instead of overflowing past the popover boundary. + // Agent Desktop uses max-height: 120px (~7.5rem) on the details container. + .global-variables-panel--two-column { + max-height: 7.5rem; + overflow-y: auto; + } + } + + &__list-item { + --mdc-listitem-padding-left-right: 0.75rem; + --mdc-listitem-padding-top-bottom: 0.5rem; + --mdc-listitem-background-color-hover: transparent; + --mdc-listitem-background-color-active: transparent; + --mdc-listitem-cursor: default; + width: 100%; + + .campaign-avatar { + --mdc-avatar-default-background-color: var(--mds-color-theme-avatar-campaign); + --mdc-avatar-default-foreground-color: var(--mds-color-theme-indicator-stable); + } + } + + // Action buttons — vertical column on the right (Accept on top, Skip + Remove below) + .campaign-task-actions { + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: flex-end; + gap: 0.25rem; + height: 100%; + } + + .campaign-task-skip-remove { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; + } + + // Icon buttons (skip / remove) — match call-control circular icon button style + .campaign-task-icon-button { + width: 1.75rem !important; + height: 1.75rem !important; + } + +} diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.tsx b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.tsx new file mode 100644 index 000000000..52efff662 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.tsx @@ -0,0 +1,101 @@ +import React, {useRef} from 'react'; +import {Popover} from '@momentum-design/components/dist/react'; +import CampaignTaskListItem from '../CampaignTaskListItem/campaign-task-list-item'; +import GlobalVariablesPanel from '../../GlobalVariablesPanel/global-variables-panel'; +import {CampaignTaskPopoverProps} from './campaign-task-popover.types'; +import {CallAssociatedDataMap, getCallerIdentifier} from '../../task.types'; +import {getAgentViewableGlobalVariables} from '../../Task/task.utils'; +import {CampaignCallProcessingDetails} from '../campaign-task.types'; +import './campaign-task-popover.style.scss'; + +const POPOVER_WIDTH = '440px'; +const POPOVER_DELAY = '200,100'; + +const getCampaignCpd = (cpd: Record | undefined): CampaignCallProcessingDetails => { + if (!cpd) return {}; + return cpd as CampaignCallProcessingDetails; +}; + +const CampaignTaskPopover: React.FC = ({ + task, + logger, + triggerId, + isAcceptClicked, + isAccepted, + isAcceptDisabled, + isSkipDisabled, + isRemoveDisabled, + onAccept, + onSkip, + onRemove, + onTimeout, + handleTimestamp, +}) => { + const cpd = task.data.interaction.callProcessingDetails; + const campaignCpd = getCampaignCpd(cpd as unknown as Record); + const timeoutTimestamp = campaignCpd.campaignPreviewOfferTimeout; + + // @ts-expect-error callAssociatedDetails not yet typed in SDK + const callAssociatedDetails = task.data.interaction.callAssociatedDetails; + const ani = callAssociatedDetails?.ani ?? ''; + const dn = callAssociatedDetails?.dn ?? ''; + const customerName = callAssociatedDetails?.customerName; + const outboundType = task.data.interaction.outboundType; + const title = customerName || getCallerIdentifier(ani, dn, outboundType); + const phoneNumber = getCallerIdentifier(ani, dn, outboundType); + + const callAssociatedData = (task.data.interaction as unknown as {callAssociatedData?: CallAssociatedDataMap}) + .callAssociatedData; + const latestGlobalVariables = getAgentViewableGlobalVariables(callAssociatedData); + + // Persist global variables across task updates — some store refreshes + // replace the task with a snapshot that omits callAssociatedData. + const globalVariablesRef = useRef(latestGlobalVariables); + if (latestGlobalVariables.length > 0) { + globalVariablesRef.current = latestGlobalVariables; + } + const globalVariables = globalVariablesRef.current; + + return ( + +
+ + + +
+
+ ); +}; + +export default CampaignTaskPopover; diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.types.ts b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.types.ts new file mode 100644 index 000000000..5b0a3711c --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.types.ts @@ -0,0 +1,49 @@ +import {ILogger, ITask} from '@webex/cc-store'; + +/** + * Properties for the CampaignTaskPopover component. + * + * Displays a hover popover over the campaign preview task with the + * ListItem row (avatar, title, phone, countdown, action buttons) + * and a two-column scrollable data panel of global variables. + */ +export interface CampaignTaskPopoverProps { + /** The campaign preview task. */ + task: ITask; + + /** Logger instance for logging purposes. */ + logger?: ILogger; + + /** ID of the trigger element that opens the popover on hover. */ + triggerId: string; + + /** Whether the Accept button has been clicked (shows "Connecting..." state). */ + isAcceptClicked: boolean; + + /** Whether the campaign preview has been accepted by the backend (call controls visible). */ + isAccepted: boolean; + + /** Whether the Accept button is disabled. */ + isAcceptDisabled: boolean; + + /** Whether the Skip button is disabled. */ + isSkipDisabled: boolean; + + /** Whether the Remove button is disabled. */ + isRemoveDisabled: boolean; + + /** Handler for Accept button click. */ + onAccept: () => void; + + /** Handler for Skip button click. */ + onSkip: () => void; + + /** Handler for Remove button click. */ + onRemove: () => void; + + /** Handler for countdown timeout. */ + onTimeout: () => void; + + /** Timestamp (ms) when the campaign call was accepted — used for the handle time timer. */ + handleTimestamp?: number; +} diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.style.scss b/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.style.scss new file mode 100644 index 000000000..e0a44d3d6 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.style.scss @@ -0,0 +1,81 @@ +:root { + --mds-color-theme-avatar-campaign: rgba(0, 0, 0, 0.07); +} + +@media (prefers-color-scheme: dark) { + :root { + --mds-color-theme-avatar-campaign: rgba(255, 255, 255, 0.07); + } +} + +.campaign-task { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid var(--mds-color-theme-outline-secondary-normal); + border-radius: 0.75rem; + background: var(--mds-color-theme-background-primary-ghost); + overflow: hidden; + + // List Item row — avatar, details, action buttons + // Override Momentum ListItem defaults so the row behaves as a static card, + // not an interactive list item (no hover/active highlight, default cursor). + .campaign-task-list-item { + --mdc-listitem-padding-left-right: 0.75rem; + --mdc-listitem-padding-top-bottom: 0.5rem; + --mdc-listitem-background-color-hover: transparent; + --mdc-listitem-background-color-active: transparent; + --mdc-listitem-cursor: default; + width: 100%; + + .campaign-avatar { + --mdc-avatar-default-background-color: var(--mds-color-theme-avatar-campaign); + --mdc-avatar-default-foreground-color: var(--mds-color-theme-indicator-stable); + } + } + + // Action buttons — vertical column on the right + // Matches Agent Desktop: Accept on top, Skip + Remove icons below + .campaign-task-actions { + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: flex-end; + gap: 0.25rem; + height: 100%; + } + + .campaign-task-skip-remove { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; + } + + .campaign-task-icon-button { + width: 1.75rem !important; + height: 1.75rem !important; + } + + // Expanded view — data panel + cancel button + .campaign-task-expanded { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + width: 100%; + box-sizing: border-box; + border-top: 1px solid var(--mds-color-theme-outline-secondary-normal); + } + + // Cancel button — full-width outlined pill at the bottom + // Use ::part to reach into the shadow DOM button element + .campaign-task-cancel-button { + width: 100%; + + &::part(button) { + width: 100%; + } + } +} diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.tsx b/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.tsx new file mode 100644 index 000000000..afaceccbd --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.tsx @@ -0,0 +1,385 @@ +import React, {useState, useCallback, useRef, useEffect} from 'react'; +import {Button} from '@momentum-design/components/dist/react'; +import {withMetrics} from '@webex/cc-ui-logging'; +import CampaignErrorDialog from '../CampaignErrorDialog/campaign-error-dialog'; +import GlobalVariablesPanel from '../GlobalVariablesPanel/global-variables-panel'; +import CampaignTaskPopover from './CampaignTaskPopover/campaign-task-popover'; +import CampaignTaskListItem from './CampaignTaskListItem/campaign-task-list-item'; +import {CampaignErrorType} from '../CampaignErrorDialog/campaign-error-dialog.types'; +import { + CampaignTaskProps, + CampaignAutoAction, + CampaignCallProcessingDetails, + CAMPAIGN_ACTION_ERROR_MAP, + CampaignErrorActionType, +} from './campaign-task.types'; +import {CallAssociatedDataMap, getCallerIdentifier} from '../task.types'; +import {getAgentViewableGlobalVariables} from '../Task/task.utils'; +import {CANCEL, CAMPAIGN_TASK_REGION_LABEL} from '../constants'; +import './campaign-task.style.scss'; + +const LOG_MODULE = 'cc-components#campaign-task'; + +interface ParticipantWithJoin { + hasJoined?: boolean; + joinTimestamp?: number; +} + +/** + * Extract the agent's joinTimestamp from the task participants. + * Looks up the agent by `agentId` so that we always read the correct + * participant even when multiple participants have joined. + * Returns `undefined` when the agent hasn't joined yet. + */ +const getAgentJoinTimestamp = (task: CampaignTaskProps['task'], agentId?: string): number | undefined => { + const participants = task.data.interaction.participants as Record | undefined; + + if (!participants) return undefined; + + if (agentId && participants[agentId]) { + const agent = participants[agentId]; + return agent.hasJoined && agent.joinTimestamp ? agent.joinTimestamp : undefined; + } + + // Fallback: if agentId is not provided or not found, use first joined participant + for (const participant of Object.values(participants)) { + if (participant.hasJoined && participant.joinTimestamp) { + return participant.joinTimestamp; + } + } + + return undefined; +}; + +const getCampaignCpd = (cpd: Record | undefined): CampaignCallProcessingDetails => { + if (!cpd) return {}; + return cpd as CampaignCallProcessingDetails; +}; + +const CampaignTask: React.FC = ({ + task, + acceptPreviewContact, + skipPreviewContact, + removePreviewContact, + cancelPreviewContact, + isBrowser = false, + logger, + isAccepted = false, + agentId, +}) => { + const cpd = task.data.interaction.callProcessingDetails; + const campaignCpd = getCampaignCpd(cpd as unknown as Record); + const interactionId = task.data.interactionId; + const timeoutTimestamp = campaignCpd.campaignPreviewOfferTimeout; + const autoAction = (campaignCpd.campaignPreviewAutoAction ?? '') as CampaignAutoAction | ''; + + // @ts-expect-error callAssociatedDetails not yet typed in SDK + const callAssociatedDetails = task.data.interaction.callAssociatedDetails; + const ani = callAssociatedDetails?.ani ?? ''; + const dn = callAssociatedDetails?.dn ?? ''; + const customerName = callAssociatedDetails?.customerName; + const outboundType = task.data.interaction.outboundType; + const title = customerName || getCallerIdentifier(ani, dn, outboundType); + const phoneNumber = getCallerIdentifier(ani, dn, outboundType); + + const callAssociatedData = (task.data.interaction as unknown as {callAssociatedData?: CallAssociatedDataMap}) + .callAssociatedData; + const latestGlobalVariables = getAgentViewableGlobalVariables(callAssociatedData); + + // Persist global variables across task updates — some store refreshes + // replace the task with a snapshot that omits callAssociatedData. + // Reset when the interaction changes so stale CAD from a previous task + // is never shown on a new call. + const globalVariablesRef = useRef(latestGlobalVariables); + const prevInteractionIdRef = useRef(interactionId); + if (prevInteractionIdRef.current !== interactionId) { + prevInteractionIdRef.current = interactionId; + globalVariablesRef.current = latestGlobalVariables; + } else if (latestGlobalVariables.length > 0) { + globalVariablesRef.current = latestGlobalVariables; + } + const globalVariables = globalVariablesRef.current; + + const [isAcceptClicked, setIsAcceptClicked] = useState(isAccepted); + const [handleTimestamp, setHandleTimestamp] = useState( + isAccepted ? (getAgentJoinTimestamp(task, agentId) ?? Date.now()) : undefined + ); + const [isAcceptDisabled, setIsAcceptDisabled] = useState(isAccepted); + const [isSkipButtonDisabled, setIsSkipButtonDisabled] = useState( + isAccepted || campaignCpd.campaignPreviewSkipDisabled === 'true' + ); + const [isRemoveButtonDisabled, setIsRemoveButtonDisabled] = useState( + isAccepted || campaignCpd.campaignPreviewRemoveDisabled === 'true' + ); + const [errorType, setErrorType] = useState(null); + + const unmountedRef = useRef(false); + useEffect(() => { + return () => { + unmountedRef.current = true; + }; + }, []); + + // Sync local state when the store-driven isAccepted prop changes. + // This handles the case where the store marks the campaign as accepted + // (e.g. via handleCampaignPreviewReservation) and the component was + // already mounted. + useEffect(() => { + if (isAccepted && !isAcceptClicked) { + setIsAcceptClicked(true); + setHandleTimestamp(getAgentJoinTimestamp(task, agentId) ?? Date.now()); + setIsAcceptDisabled(true); + setIsSkipButtonDisabled(true); + setIsRemoveButtonDisabled(true); + } + }, [isAccepted]); + + // Once the server-side joinTimestamp arrives, align handleTimestamp + // so the timer matches CallControlCAD exactly. + useEffect(() => { + if (!isAcceptClicked) return; + const joinTs = getAgentJoinTimestamp(task, agentId); + if (joinTs && joinTs !== handleTimestamp) { + setHandleTimestamp(joinTs); + } + }, [task, isAcceptClicked]); + + // Reset local state when a new contact is offered on the same task (after skip/remove). + // The SDK emits TASK_CAMPAIGN_CONTACT_UPDATED with updated callProcessingDetails; + // we detect this by tracking the offerTimeout value which changes per contact. + const prevTimeoutRef = useRef(timeoutTimestamp); + + useEffect(() => { + // Only reset when a new contact is offered after skip/remove (not after accept). + // After accept the task data updates but should keep the accepted state. + if (!isAccepted && prevTimeoutRef.current !== undefined && timeoutTimestamp !== prevTimeoutRef.current) { + logger?.info('CC-Widgets: CampaignTask: New contact offered, resetting state', { + module: LOG_MODULE, + method: 'useEffect[timeoutTimestamp]', + }); + setIsAcceptClicked(false); + setHandleTimestamp(undefined); + setIsAcceptDisabled(false); + setIsSkipButtonDisabled(campaignCpd.campaignPreviewSkipDisabled === 'true'); + setIsRemoveButtonDisabled(campaignCpd.campaignPreviewRemoveDisabled === 'true'); + setIsCancelDisabled(false); + setErrorType(null); + } + prevTimeoutRef.current = timeoutTimestamp; + }, [ + timeoutTimestamp, + isAccepted, + campaignCpd.campaignPreviewSkipDisabled, + campaignCpd.campaignPreviewRemoveDisabled, + logger, + ]); + + const disableAllButtons = useCallback((): void => { + setIsAcceptDisabled(true); + setIsSkipButtonDisabled(true); + setIsRemoveButtonDisabled(true); + }, []); + + const resetButtons = useCallback((): void => { + setIsAcceptClicked(false); + setIsAcceptDisabled(false); + setIsSkipButtonDisabled(campaignCpd.campaignPreviewSkipDisabled === 'true'); + setIsRemoveButtonDisabled(campaignCpd.campaignPreviewRemoveDisabled === 'true'); + }, [campaignCpd.campaignPreviewSkipDisabled, campaignCpd.campaignPreviewRemoveDisabled]); + + const handleActionError = useCallback( + (action: CampaignErrorActionType, method: string, error: unknown): void => { + if (unmountedRef.current) return; + + const errorMessage = error instanceof Error ? error.message : String(error); + logger?.error(`CC-Widgets: CampaignTask: ${action} failed: ${errorMessage}`, { + module: LOG_MODULE, + method, + }); + + setErrorType(CAMPAIGN_ACTION_ERROR_MAP[action]); + resetButtons(); + }, + [resetButtons, logger] + ); + + const handleAccept = useCallback((): void => { + if (isAcceptDisabled) return; + + logger?.info('CC-Widgets: CampaignTask: Accept button clicked', { + module: LOG_MODULE, + method: 'handleAccept', + }); + + setIsAcceptClicked(true); + setHandleTimestamp(Date.now()); + disableAllButtons(); + + acceptPreviewContact().catch((error: unknown) => handleActionError('ACCEPT', 'handleAccept', error)); + }, [isAcceptDisabled, acceptPreviewContact, disableAllButtons, handleActionError, logger]); + + const handleSkip = useCallback((): void => { + if (isSkipButtonDisabled) return; + + logger?.info('CC-Widgets: CampaignTask: Skip button clicked', { + module: LOG_MODULE, + method: 'handleSkip', + }); + + disableAllButtons(); + + skipPreviewContact().catch((error: unknown) => handleActionError('SKIP', 'handleSkip', error)); + }, [isSkipButtonDisabled, skipPreviewContact, disableAllButtons, handleActionError, logger]); + + const handleRemove = useCallback((): void => { + if (isRemoveButtonDisabled) return; + + logger?.info('CC-Widgets: CampaignTask: Remove button clicked', { + module: LOG_MODULE, + method: 'handleRemove', + }); + + disableAllButtons(); + + removePreviewContact().catch((error: unknown) => handleActionError('REMOVE', 'handleRemove', error)); + }, [isRemoveButtonDisabled, removePreviewContact, disableAllButtons, handleActionError, logger]); + + const handleTimeout = useCallback((): void => { + logger?.info('CC-Widgets: CampaignTask: Countdown expired, updating UI for auto-action', { + module: LOG_MODULE, + method: 'handleTimeout', + }); + + // Consistent with Agent Desktop: the UI only updates button states on + // timeout — the backend executes the actual auto-action on its own + // timer. Calling the API from the UI would double-fire the action and + // could auto-accept campaigns the agent never saw. + switch (autoAction) { + case 'ACCEPT': + setIsAcceptClicked(true); + setHandleTimestamp(Date.now()); + disableAllButtons(); + logger?.info('CC-Widgets: CampaignTask: Auto-accept UI state set, awaiting backend', { + module: LOG_MODULE, + method: 'handleTimeout', + }); + break; + case 'SKIP': + case 'REMOVE': + disableAllButtons(); + logger?.info(`CC-Widgets: CampaignTask: Auto-${autoAction.toLowerCase()} UI state set, awaiting backend`, { + module: LOG_MODULE, + method: 'handleTimeout', + }); + break; + default: + logger?.warn('CC-Widgets: CampaignTask: No valid auto-action configured', { + module: LOG_MODULE, + method: 'handleTimeout', + }); + break; + } + }, [autoAction, disableAllButtons, logger]); + + const [isCancelDisabled, setIsCancelDisabled] = useState(false); + + const handleCancel = useCallback((): void => { + if (isCancelDisabled) return; + + logger?.info('CC-Widgets: CampaignTask: Cancel button clicked', { + module: LOG_MODULE, + method: 'handleCancel', + }); + + setIsCancelDisabled(true); + disableAllButtons(); + + cancelPreviewContact().catch((error: unknown) => { + if (unmountedRef.current) return; + + const errorMessage = error instanceof Error ? error.message : String(error); + logger?.error(`CC-Widgets: CampaignTask: Cancel failed: ${errorMessage}`, { + module: LOG_MODULE, + method: 'handleCancel', + }); + + setErrorType('CANCEL_FAILED'); + setIsCancelDisabled(false); + resetButtons(); + }); + }, [isCancelDisabled, cancelPreviewContact, disableAllButtons, resetButtons, logger]); + + const handleErrorClose = useCallback((): void => { + setErrorType(null); + }, []); + + const campaignTaskTriggerId = `campaign-task-trigger-${interactionId}`; + + return ( +
+ + + +
+ + + {isBrowser && !isAccepted && ( + + )} +
+ + {errorType !== null && } +
+ ); +}; + +const CampaignTaskWithMetrics = withMetrics(CampaignTask, 'CampaignTask'); +export default CampaignTaskWithMetrics; diff --git a/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.types.ts b/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.types.ts new file mode 100644 index 000000000..5a90f98c8 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CampaignTask/campaign-task.types.ts @@ -0,0 +1,113 @@ +import {ILogger, ITask} from '@webex/cc-store'; +import {CampaignErrorType} from '../CampaignErrorDialog/campaign-error-dialog.types'; + +/** + * Auto-action to perform when the campaign preview offer times out. + * Matches the values from callProcessingDetails.campaignPreviewAutoAction. + */ +export type CampaignAutoAction = 'ACCEPT' | 'SKIP' | 'REMOVE'; + +/** + * Maps a CampaignAutoAction to the corresponding CampaignErrorType + * used when the auto-action or manual action fails. + */ +export const CAMPAIGN_ACTION_ERROR_MAP: Record = { + ACCEPT: 'ACCEPT_FAILED', + SKIP: 'SKIP_FAILED', + REMOVE: 'REMOVE_FAILED', +}; + +/** + * Keys of CAMPAIGN_ACTION_ERROR_MAP — used to type the error handler in CampaignTask. + */ +export type CampaignErrorActionType = keyof typeof CAMPAIGN_ACTION_ERROR_MAP; + +/** + * Campaign-specific fields on `callProcessingDetails`. + * + * These fields are present at runtime on campaign preview reservation + * events but are not yet part of the installed SDK type definitions. + * This bridge type can be removed once the SDK package is updated. + */ +export interface CampaignCallProcessingDetails { + /** Campaign name (not UUID) */ + campaignId?: string; + /** Indicates if the skip action is disabled for campaign preview contacts */ + campaignPreviewSkipDisabled?: string; + /** Indicates if the remove action is disabled for campaign preview contacts */ + campaignPreviewRemoveDisabled?: string; + /** Auto-action to perform when campaign preview offer times out (ACCEPT, SKIP, REMOVE) */ + campaignPreviewAutoAction?: string; + /** Timestamp (ms) when the campaign preview offer expires */ + campaignPreviewOfferTimeout?: string; +} + +/** + * Properties for the CampaignTask component. + * + * The component renders campaign preview contact details, action buttons + * (Accept / Skip / Remove), a countdown timer, and an error dialog. + * When the countdown expires the configured auto-action is triggered. + * + * Following the pattern used by the Task component, SDK operations are + * passed in as callback props rather than passing the cc instance directly. + */ +export interface CampaignTaskProps { + /** + * The campaign preview task (AgentOfferCampaignReservation). + * Campaign metadata is read from `task.data.interaction.callProcessingDetails`. + */ + task: ITask; + + /** + * Accepts the campaign preview contact and initiates the outbound call. + * Wraps `cc.acceptPreviewContact({ interactionId, campaignId })`. + */ + acceptPreviewContact: () => Promise; + + /** + * Skips the campaign preview contact and moves to the next one. + * Wraps `cc.skipPreviewContact({ interactionId, campaignId })`. + */ + skipPreviewContact: () => Promise; + + /** + * Removes the campaign preview contact from the campaign list. + * Wraps `cc.removePreviewContact({ interactionId, campaignId })`. + */ + removePreviewContact: () => Promise; + + /** + * Cancels the campaign preview call by ending the task. + * Wraps `task.end()`. + */ + cancelPreviewContact: () => Promise; + + /** + * Whether the agent is logged in with a Browser (WebRTC) device. + * When true the Cancel button is rendered so the agent can end the + * WebRTC call. For AGENT_DN the phone handles hangup, so the Cancel + * button is hidden — consistent with Agent Desktop behaviour. + */ + isBrowser?: boolean; + + /** + * Logger instance for logging purposes. + */ + logger?: ILogger; + + /** + * Whether this campaign preview has been accepted. + * Driven by the store's `acceptedCampaignIds` set — survives component + * remounts caused by transient task-list updates during the accept + * transition. When `true`, action buttons and countdown are hidden + * and the handle-time timer is shown instead. + */ + isAccepted?: boolean; + + /** + * The logged-in agent's ID. Used to look up the agent's participant + * entry when reading `joinTimestamp` for the handle-time timer. + */ + agentId?: string; +} diff --git a/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.style.scss b/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.style.scss new file mode 100644 index 000000000..85d8b0f8e --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.style.scss @@ -0,0 +1,103 @@ +.global-variables-panel { + background: var(--mds-color-theme-background-glass-overlay-normal); + border-radius: 0.5rem; + overflow-y: auto; + max-height: 7.5rem; + min-height: 0; + width: 100%; + scrollbar-color: var(--mds-color-theme-scrollbar-button-normal) transparent; + + &__list { + display: flex; + flex-direction: column; + gap: 0; + padding: 0.75rem; + margin: 0; + } + + // Single-column: horizontal key-value rows (40%/60% split with ellipsis) + &__row { + display: flex; + align-items: baseline; + width: 100%; + padding-bottom: 0.25rem; + + &:last-child { + padding-bottom: 0; + } + + dt, + dd { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + dt { + width: 40%; + flex-shrink: 0; + } + + dd { + width: 60%; + } + } + + &__label { + color: var(--mds-color-theme-text-primary-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__value { + color: var(--mds-color-theme-text-secondary-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Two-column grid layout used in the popover (matches Figma design) + &--two-column { + max-height: 20rem; + flex-shrink: 1; + overflow-y: auto; + + .global-variables-panel__list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem 0.75rem; + } + + .global-variables-panel__row { + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + padding-bottom: 0; + + dt, + dd { + width: 100%; + white-space: normal; + overflow: visible; + text-overflow: unset; + } + } + + .global-variables-panel__label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .global-variables-panel__value { + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + overflow: visible; + text-overflow: unset; + } + } +} diff --git a/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.tsx b/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.tsx new file mode 100644 index 000000000..648a57b38 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import {Text} from '@momentum-design/components/dist/react'; +import {GlobalVariablesPanelProps} from './global-variables-panel.types'; +import {GLOBAL_VARIABLES_LABEL} from '../constants'; +import './global-variables-panel.style.scss'; + +/** + * GlobalVariablesPanel renders agent-viewable global variables in a scrollable + * glass overlay panel. Used by CampaignTask (inline card), + * CampaignTaskPopover (hover popover), and CallControlCAD. + * + * Supports two layout modes: + * - `single-column` (default) — one variable per row + * - `two-column` — two variables per row (matching the Figma popover design) + */ +const GlobalVariablesPanel: React.FC = ({ + variables, + className, + layout = 'single-column', + panelBackground, +}) => { + if (variables.length === 0) { + return null; + } + + const layoutClass = layout === 'two-column' ? 'global-variables-panel--two-column' : ''; + const panelStyle = panelBackground ? {background: panelBackground} : undefined; + + return ( +
+
+ {variables.map((variable) => + variable.value ? ( +
+
+ + {variable.displayName || variable.name}: + +
+
+ + {variable.value} + +
+
+ ) : null + )} +
+
+ ); +}; + +export default GlobalVariablesPanel; diff --git a/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.types.ts b/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.types.ts new file mode 100644 index 000000000..29980a193 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/GlobalVariablesPanel/global-variables-panel.types.ts @@ -0,0 +1,30 @@ +import {CADVariable} from '../task.types'; + +/** + * Properties for the GlobalVariablesPanel component. + */ +export interface GlobalVariablesPanelProps { + /** + * List of agent-viewable global variables to display. + */ + variables: CADVariable[]; + + /** + * Optional CSS class name for additional styling. + */ + className?: string; + + /** + * Layout mode for the variables grid. + * - `single-column`: one variable per row (used in the inline card) + * - `two-column`: two variables per row (used in the popover) + * @default 'single-column' + */ + layout?: 'single-column' | 'two-column'; + + /** + * CSS background value for the panel container. + * @default 'var(--mds-color-theme-background-glass-normal)' + */ + panelBackground?: string; +} diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx index 3b46d68c9..0b9ea5072 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx @@ -10,7 +10,9 @@ const IncomingTaskComponent: React.FunctionComponent return <>; // hidden component } - // Extract all task data using the utility function + // All incoming tasks (including campaign preview) render the standard Task + // with Answer/Decline buttons. The CampaignTask UI (Accept/Skip/Remove) + // is only shown in the TaskList after the agent has accepted. const taskData = extractIncomingTaskData(incomingTask, isBrowser, logger, isDeclineButtonEnabled); return ( diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx index 73da269c6..5ccc07b12 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx @@ -1,18 +1,35 @@ import React from 'react'; +import {withMetrics} from '@webex/cc-ui-logging'; import {TaskListComponentProps, MEDIA_CHANNEL} from '../task.types'; import Task from '../Task'; +import CampaignTask from '../CampaignTask/campaign-task'; +import {CampaignCallProcessingDetails} from '../CampaignTask/campaign-task.types'; import { extractTaskListItemData, isTaskListEmpty, getTasksArray, createTaskSelectHandler, isCurrentTaskSelected, + isCampaignPreviewTask, + hasAgentJoinedTask, + getActiveCampaignPreviewId, } from './task-list.utils'; import './styles.scss'; -import {withMetrics} from '@webex/cc-ui-logging'; const TaskListComponent: React.FunctionComponent = (props) => { - const {currentTask, taskList, acceptTask, declineTask, isBrowser, onTaskSelect, logger, agentId} = props; + const { + currentTask, + taskList, + acceptTask, + declineTask, + isBrowser, + onTaskSelect, + logger, + agentId, + cc, + hasCampaignPreviewEnabled = true, + acceptedCampaignIds, + } = props; // Early return for empty task list if (isTaskListEmpty(taskList)) { @@ -21,6 +38,10 @@ const TaskListComponent: React.FunctionComponent = (prop // Get tasks as array for mapping const tasks = getTasksArray(taskList!); + + // Only one campaign preview should appear — pick the most recent active one + const activeCampaignId = hasCampaignPreviewEnabled ? getActiveCampaignPreviewId(tasks, agentId) : null; + return (
    {tasks.map((task, index) => { @@ -32,6 +53,34 @@ const TaskListComponent: React.FunctionComponent = (prop module: 'task-list.tsx', method: 'renderItem', }); + + // Campaign preview handling: render only the active one, skip stale duplicates + if (hasCampaignPreviewEnabled && isCampaignPreviewTask(task) && hasAgentJoinedTask(task, agentId)) { + if (task.data.interactionId !== activeCampaignId) { + return null; // skip stale campaign preview + } + const interactionId = task.data.interactionId; + const cpd = task.data.interaction.callProcessingDetails as unknown as + | CampaignCallProcessingDetails + | undefined; + const campaignId = cpd?.campaignId ?? ''; + + return ( + cc.acceptPreviewContact({interactionId, campaignId}).then(() => {})} + skipPreviewContact={() => cc.skipPreviewContact({interactionId, campaignId}).then(() => {})} + removePreviewContact={() => cc.removePreviewContact({interactionId, campaignId}).then(() => {})} + cancelPreviewContact={() => task.end().then(() => {})} + isBrowser={isBrowser} + logger={logger} + isAccepted={acceptedCampaignIds?.has(interactionId) ?? false} + agentId={agentId} + /> + ); + } + return ( { + const outboundType = task.data.interaction.outboundType ?? ''; + const cpd = task.data.interaction.callProcessingDetails as unknown as Record; + const campaignType = cpd?.campaignType ?? ''; + + return ( + CAMPAIGN_PREVIEW_OUTBOUND_TYPES.includes(outboundType) || CAMPAIGN_PREVIEW_CAMPAIGN_TYPES.includes(campaignType) + ); +}; + +/** + * Determines whether the agent has joined the interaction. + * + * Matches the agent desktop logic: + * `taskMap[id]?.interaction.participants[agentId]?.hasJoined` + * + * The SDK types `participants` as `any`; at runtime it is + * `Record`. + */ +export const hasAgentJoinedTask = (task: ITask, agentId: string | undefined): boolean => { + if (!agentId) return false; + const participants = task.data.interaction.participants as Record | undefined; + + return participants?.[agentId]?.hasJoined === true; +}; +/** + * Returns the interactionId of the most recent active campaign preview task + * that the agent has joined. Only one campaign preview should be visible at a time. + */ +export const getActiveCampaignPreviewId = (tasks: ITask[], agentId: string | undefined): string | null => { + const activePreviews = tasks.filter((t) => isCampaignPreviewTask(t) && hasAgentJoinedTask(t, agentId)); + if (activePreviews.length === 0) return null; + // Pick the most recent by createdTimestamp + activePreviews.sort( + (a, b) => (b.data.interaction.createdTimestamp ?? 0) - (a.data.interaction.createdTimestamp ?? 0) + ); + return activePreviews[0].data.interactionId; +}; + /** * Extracts and processes data from a task for rendering in the task list * @param task - The task object diff --git a/packages/contact-center/cc-components/src/components/task/constants.ts b/packages/contact-center/cc-components/src/components/task/constants.ts index 519543c1e..2c15259cc 100644 --- a/packages/contact-center/cc-components/src/components/task/constants.ts +++ b/packages/contact-center/cc-components/src/components/task/constants.ts @@ -38,3 +38,17 @@ export const PHONE_NUMBER = 'Phone Number:'; export const CUSTOMER_NAME = 'Customer Name'; export const RONA = 'RONA:'; export const TIME_LEFT = 'Time left:'; +// Campaign preview task constants +export const CAMPAIGN_ACCEPT = 'Accept'; +export const CAMPAIGN_CONNECTING = 'Connecting...'; +export const CAMPAIGN_SKIP = 'Skip'; +export const CAMPAIGN_SKIP_TOOLTIP = 'Skip this contact'; +export const CAMPAIGN_SKIP_DISABLED_TOOLTIP = "Can't skip this contact"; +export const CAMPAIGN_REMOVE = 'Remove'; +export const CAMPAIGN_REMOVE_TOOLTIP = 'Remove this contact'; +export const CAMPAIGN_REMOVE_DISABLED_TOOLTIP = "Can't remove this contact"; +export const CAMPAIGN_TASK_REGION_LABEL = 'Campaign preview contact'; +export const GLOBAL_VARIABLES_LABEL = 'Contact details'; +export const CAMPAIGN_ACTIONS_LABEL = 'Campaign actions'; +export const HANDLE_TIME = 'Handle Time:'; +export const CAMPAIGN_CALL = 'Campaign call'; diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 3104910a5..ebd58044d 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -165,6 +165,21 @@ export interface TaskProps { * Flag to enable decline button on incoming task component */ isDeclineButtonEnabled?: boolean; + + /** + * Flag to enable campaign preview task rendering. + * When true and the task is a campaign preview interaction, + * the CampaignTask component is rendered instead of the normal Task. + * Defaults to true. + */ + hasCampaignPreviewEnabled?: boolean; + + /** + * Set of interaction IDs for campaign previews that have been accepted. + * Managed by the store — survives component remounts caused by + * transient task-list updates during the accept transition. + */ + acceptedCampaignIds?: Set; } export type IncomingTaskComponentProps = Pick & @@ -172,9 +187,9 @@ export type IncomingTaskComponentProps = Pick & - Partial>; + Partial>; export interface RealTimeTranscriptEntry { id: string; @@ -558,7 +573,14 @@ export type CallControlComponentProps = Pick< | 'getEntryPoints' | 'getQueuesFetcher' | 'consultTransferOptions' ->; +> & { + /** + * Whether the current task is an accepted campaign preview call. + * When `true`, the header renders the campaign icon and + * "Campaign call" label instead of the standard media type. + */ + isCampaignCall?: boolean; +}; export type OutdialAniEntry = { /** Unique identifier for the ANI entry */ diff --git a/packages/contact-center/cc-components/src/index.ts b/packages/contact-center/cc-components/src/index.ts index d8b046b6e..10d986bfb 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -7,6 +7,7 @@ import TaskListComponent from './components/task/TaskList/task-list'; import OutdialCallComponent from './components/task/OutdialCall/outdial-call'; import CampaignErrorDialogComponent from './components/task/CampaignErrorDialog/campaign-error-dialog'; import CampaignCountdownComponent from './components/task/CampaignCountdown/campaign-countdown'; +import CampaignTaskComponent from './components/task/CampaignTask/campaign-task'; import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript'; export { @@ -19,6 +20,7 @@ export { OutdialCallComponent, CampaignErrorDialogComponent, CampaignCountdownComponent, + CampaignTaskComponent, RealTimeTranscriptComponent, }; export * from './components/StationLogin/constants'; @@ -27,3 +29,4 @@ export * from './components/UserState/user-state.types'; export * from './components/task/task.types'; export * from './components/task/CampaignErrorDialog/campaign-error-dialog.types'; export * from './components/task/CampaignCountdown/campaign-countdown.types'; +export * from './components/task/CampaignTask/campaign-task.types'; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap index b6c399b38..5f8a5928c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap @@ -34,7 +34,8 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control data-testid="cc-cad:call-timer" > Voice - - + - + @@ -329,7 +330,8 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control data-testid="cc-cad:call-timer" > Voice - - + - + @@ -417,7 +419,8 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control data-testid="cc-cad:call-timer" > Voice - - + - + @@ -688,7 +691,8 @@ exports[`CallControlCADComponent Snapshots should render basic call states and m data-testid="cc-cad:call-timer" > Chat - - + - + @@ -959,7 +963,8 @@ exports[`CallControlCADComponent Snapshots should render basic call states and m data-testid="cc-cad:call-timer" > Voice - - + - + @@ -1219,7 +1224,8 @@ exports[`CallControlCADComponent Snapshots should render basic call states and m data-testid="cc-cad:call-timer" > Voice - - + - + @@ -1490,7 +1496,8 @@ exports[`CallControlCADComponent Snapshots should render basic call states and m data-testid="cc-cad:call-timer" > Social - - + - + @@ -1762,7 +1769,8 @@ exports[`CallControlCADComponent Snapshots should render consultation and wrapup data-testid="cc-cad:call-timer" > Voice - - + - + @@ -2057,7 +2065,8 @@ exports[`CallControlCADComponent Snapshots should render consultation and wrapup data-testid="cc-cad:call-timer" > Voice - - + - + @@ -2262,7 +2271,8 @@ exports[`CallControlCADComponent Snapshots should render consultation and wrapup data-testid="cc-cad:call-timer" > Voice - - + - + @@ -2523,7 +2533,8 @@ exports[`CallControlCADComponent Snapshots should render consultation and wrapup data-testid="cc-cad:call-timer" > Voice - - + - + diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index afc35b2f7..a3c6a7d30 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -394,34 +394,34 @@ describe('CallControlCADComponent', () => { it('should render agent-viewable global variables', () => { const screen = render(); - const globalVarsContainer = screen.getByTestId('cc-cad:global-variables'); + const globalVarsContainer = screen.getByTestId('global-variables-panel'); expect(globalVarsContainer).toBeInTheDocument(); - expect(screen.getByTestId('cc-cad:global-var-Global_Language')).toBeInTheDocument(); - expect(screen.getByText('Customer Language')).toBeInTheDocument(); + expect(screen.getByText('Customer Language:')).toBeInTheDocument(); expect(screen.getByText('English')).toBeInTheDocument(); - expect(screen.getByTestId('cc-cad:global-var-Global_FeedbackSurveyOptIn')).toBeInTheDocument(); - expect(screen.getByText('Post Call Survey Opt-in')).toBeInTheDocument(); + expect(screen.getByText('Post Call Survey Opt-in:')).toBeInTheDocument(); expect(screen.getByText('true')).toBeInTheDocument(); }); it('should not render non-global variables (e.g. system CAD like ani)', () => { const screen = render(); - expect(screen.queryByTestId('cc-cad:global-var-ani')).not.toBeInTheDocument(); + // ani is a system CAD key, filtered out by getAgentViewableGlobalVariables + expect(screen.queryByText('ani:')).not.toBeInTheDocument(); }); it('should not render global variables where agentViewable is false', () => { const screen = render(); - expect(screen.queryByTestId('cc-cad:global-var-Global_Hidden')).not.toBeInTheDocument(); + // Global_Hidden has agentViewable: false + expect(screen.queryByText('Hidden Variable:')).not.toBeInTheDocument(); }); it('should not render global variables section when no global variables exist', () => { const screen = render(); - expect(screen.queryByTestId('cc-cad:global-variables')).not.toBeInTheDocument(); + expect(screen.queryByTestId('global-variables-panel')).not.toBeInTheDocument(); }); it('should not render global variables section when callAssociatedData is undefined', () => { @@ -439,7 +439,7 @@ describe('CallControlCADComponent', () => { }; const screen = render(); - expect(screen.queryByTestId('cc-cad:global-variables')).not.toBeInTheDocument(); + expect(screen.queryByTestId('global-variables-panel')).not.toBeInTheDocument(); }); it('should use variable name as label when displayName is empty', () => { @@ -459,7 +459,7 @@ describe('CallControlCADComponent', () => { }; const screen = render(); - expect(screen.getByText('Global_NoDisplay')).toBeInTheDocument(); + expect(screen.getByText('Global_NoDisplay:')).toBeInTheDocument(); expect(screen.getByText('some value')).toBeInTheDocument(); }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task-list-item.test.tsx b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task-list-item.test.tsx new file mode 100644 index 000000000..edf0fb642 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task-list-item.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CampaignTaskListItem from '../../../../src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item'; +import {CampaignTaskListItemProps} from '../../../../src/components/task/CampaignTask/CampaignTaskListItem/campaign-task-list-item.types'; +import { + CAMPAIGN_ACCEPT, + CAMPAIGN_CONNECTING, + CAMPAIGN_SKIP, + CAMPAIGN_REMOVE, + CAMPAIGN_ACTIONS_LABEL, +} from '../../../../src/components/task/constants'; + +// Mock child components that rely on browser APIs (Web Workers, timers) +jest.mock('../../../../src/components/task/CampaignCountdown/campaign-countdown', () => { + const MockCountdown = () => Time left: 00:30; + MockCountdown.displayName = 'CampaignCountdown'; + return {__esModule: true, default: MockCountdown}; +}); + +jest.mock('../../../../src/components/task/TaskTimer/index', () => { + const MockTaskTimer = () => 00:00; + MockTaskTimer.displayName = 'TaskTimer'; + return {__esModule: true, default: MockTaskTimer}; +}); + +const defaultProps: CampaignTaskListItemProps = { + title: 'John Doe', + phoneNumber: '+1-408-555-0002', + customerName: 'John Doe', + timeoutTimestamp: String(Date.now() + 30000), + isAcceptClicked: false, + isAccepted: false, + isAcceptDisabled: false, + isSkipDisabled: false, + isRemoveDisabled: false, + onAccept: jest.fn(), + onSkip: jest.fn(), + onRemove: jest.fn(), + onTimeout: jest.fn(), + logger: undefined, +}; + +const renderComponent = (overrides: Partial = {}) => + render(); + +describe('CampaignTaskListItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────── + + it('should render the title', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task-title')).toHaveTextContent('John Doe'); + }); + + it('should render the phone number when customerName and phoneNumber differ', () => { + renderComponent({customerName: 'John Doe', phoneNumber: '+1-408-555-0002'}); + expect(screen.getByTestId('campaign-task-phone')).toHaveTextContent('+1-408-555-0002'); + }); + + it('should NOT render phone when phoneNumber equals customerName', () => { + renderComponent({customerName: 'John Doe', phoneNumber: 'John Doe'}); + expect(screen.queryByTestId('campaign-task-phone')).not.toBeInTheDocument(); + }); + + it('should NOT render phone when customerName is undefined', () => { + renderComponent({customerName: undefined}); + expect(screen.queryByTestId('campaign-task-phone')).not.toBeInTheDocument(); + }); + + it('should render the campaign avatar', () => { + const {container} = renderComponent(); + const avatar = container.querySelector('[slot="leading-controls"]'); + expect(avatar).toBeInTheDocument(); + }); + + // ── Countdown / Handle Timer toggle ──────────────────────────────── + + it('should render countdown when not accepted', () => { + renderComponent({isAcceptClicked: false}); + expect(screen.getByTestId('mock-countdown')).toBeInTheDocument(); + }); + + it('should still render countdown when accept clicked but not yet confirmed by backend', () => { + renderComponent({isAcceptClicked: true, isAccepted: false}); + expect(screen.getByTestId('mock-countdown')).toBeInTheDocument(); + }); + + it('should NOT render countdown when accepted by backend', () => { + renderComponent({isAcceptClicked: true, isAccepted: true, handleTimestamp: Date.now()}); + expect(screen.queryByTestId('mock-countdown')).not.toBeInTheDocument(); + }); + + it('should render handle time timer when accepted by backend with handleTimestamp', () => { + renderComponent({isAcceptClicked: true, isAccepted: true, handleTimestamp: Date.now()}); + expect(screen.getByTestId('mock-task-timer')).toBeInTheDocument(); + }); + + it('should NOT render handle time timer when not accepted', () => { + renderComponent({isAcceptClicked: false}); + expect(screen.queryByTestId('mock-task-timer')).not.toBeInTheDocument(); + }); + + it('should NOT render handle time timer when handleTimestamp is undefined', () => { + renderComponent({isAcceptClicked: true, handleTimestamp: undefined}); + expect(screen.queryByTestId('mock-task-timer')).not.toBeInTheDocument(); + }); + + // ── Action buttons visibility ────────────────────────────────────── + + it('should render Accept, Skip, and Remove buttons when not accepted', () => { + renderComponent({isAcceptClicked: false}); + expect(screen.getByTestId('campaign-task-actions')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-accept-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + + it('should show Connecting button and disabled Skip/Remove when accept clicked but not confirmed', () => { + renderComponent({isAcceptClicked: true, isAccepted: false}); + expect(screen.getByTestId('campaign-task-actions')).toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-accept-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-connecting-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-connecting-button')).toHaveTextContent(CAMPAIGN_CONNECTING); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + + it('should NOT render any action buttons when campaign is accepted by backend', () => { + renderComponent({isAcceptClicked: true, isAccepted: true, handleTimestamp: Date.now()}); + expect(screen.queryByTestId('campaign-task-actions')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-accept-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-skip-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-remove-button')).not.toBeInTheDocument(); + }); + + // ── Button disabled states ───────────────────────────────────────── + + it('should pass disabled prop to Accept button when isAcceptDisabled is true', () => { + renderComponent({isAcceptDisabled: true}); + const button = screen.getByTestId('campaign-task-accept-button'); + // Momentum web components set disabled as a JS property via @lit/react; + // JSDOM does not upgrade custom elements so we verify the property directly. + expect((button as unknown as {disabled: boolean}).disabled).toBe(true); + }); + + it('should pass disabled prop to Skip button when isSkipDisabled is true', () => { + renderComponent({isSkipDisabled: true}); + const button = screen.getByTestId('campaign-task-skip-button'); + expect((button as unknown as {disabled: boolean}).disabled).toBe(true); + }); + + it('should pass disabled prop to Remove button when isRemoveDisabled is true', () => { + renderComponent({isRemoveDisabled: true}); + const button = screen.getByTestId('campaign-task-remove-button'); + expect((button as unknown as {disabled: boolean}).disabled).toBe(true); + }); + + // ── Button click handlers ────────────────────────────────────────── + + it('should call onAccept when Accept button is clicked', () => { + const onAccept = jest.fn(); + renderComponent({onAccept}); + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + expect(onAccept).toHaveBeenCalledTimes(1); + }); + + it('should call onSkip when Skip button is clicked', () => { + const onSkip = jest.fn(); + renderComponent({onSkip}); + fireEvent.click(screen.getByTestId('campaign-task-skip-button')); + expect(onSkip).toHaveBeenCalledTimes(1); + }); + + it('should call onRemove when Remove button is clicked', () => { + const onRemove = jest.fn(); + renderComponent({onRemove}); + fireEvent.click(screen.getByTestId('campaign-task-remove-button')); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + // ── Accessibility ────────────────────────────────────────────────── + + it('should set correct aria-label on the actions container', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task-actions')).toHaveAttribute('aria-label', CAMPAIGN_ACTIONS_LABEL); + }); + + it('should set correct aria-labels on action buttons', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task-accept-button')).toHaveAttribute('aria-label', CAMPAIGN_ACCEPT); + expect(screen.getByTestId('campaign-task-skip-button')).toHaveAttribute('aria-label', CAMPAIGN_SKIP); + expect(screen.getByTestId('campaign-task-remove-button')).toHaveAttribute('aria-label', CAMPAIGN_REMOVE); + }); + + // ── Custom testIdPrefix ──────────────────────────────────────────── + + it('should use custom testIdPrefix for data-testid attributes', () => { + renderComponent({testIdPrefix: 'campaign-popover'}); + expect(screen.getByTestId('campaign-popover-list-item')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-title')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-accept-button')).toBeInTheDocument(); + }); + + // ── No timeout timestamp ─────────────────────────────────────────── + + it('should NOT render countdown when timeoutTimestamp is undefined', () => { + renderComponent({timeoutTimestamp: undefined, isAcceptClicked: false}); + expect(screen.queryByTestId('mock-countdown')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task-popover.test.tsx b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task-popover.test.tsx new file mode 100644 index 000000000..f6fe8ee65 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task-popover.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CampaignTaskPopover from '../../../../src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover'; +import {CampaignTaskPopoverProps} from '../../../../src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover.types'; +import {ITask} from '@webex/cc-store'; + +// ── Mocks ──────────────────────────────────────────────────────────── + +jest.mock('../../../../src/components/task/CampaignCountdown/campaign-countdown', () => { + const MockCountdown = () => Time left: 00:30; + MockCountdown.displayName = 'CampaignCountdown'; + return {__esModule: true, default: MockCountdown}; +}); + +jest.mock('../../../../src/components/task/TaskTimer/index', () => { + const MockTaskTimer = () => 00:00; + MockTaskTimer.displayName = 'TaskTimer'; + return {__esModule: true, default: MockTaskTimer}; +}); + +// ── Helpers ────────────────────────────────────────────────────────── + +const TIMEOUT_TIMESTAMP = String(Date.now() + 30000); + +const createMockTask = (): ITask => + ({ + data: { + interactionId: 'interaction-1', + interaction: { + callProcessingDetails: { + campaignPreviewSkipDisabled: 'false', + campaignPreviewRemoveDisabled: 'false', + campaignPreviewAutoAction: 'ACCEPT', + campaignPreviewOfferTimeout: TIMEOUT_TIMESTAMP, + }, + callAssociatedDetails: { + ani: '+14085550001', + dn: '+14085550002', + customerName: 'Jane Smith', + }, + callAssociatedData: { + Global_Campaign: { + name: 'Global_Campaign', + displayName: 'Campaign', + value: 'Test Campaign', + type: 'STRING', + agentEditable: false, + agentViewable: true, + global: true, + isSecure: false, + secureKeyId: '', + secureKeyVersion: 0, + }, + }, + outboundType: 'OUTDIAL', + }, + }, + }) as unknown as ITask; + +const defaultProps: CampaignTaskPopoverProps = { + task: createMockTask(), + triggerId: 'campaign-task-trigger-interaction-1', + isAcceptClicked: false, + isAccepted: false, + isAcceptDisabled: false, + isSkipDisabled: false, + isRemoveDisabled: false, + onAccept: jest.fn(), + onSkip: jest.fn(), + onRemove: jest.fn(), + onTimeout: jest.fn(), + handleTimestamp: undefined, +}; + +const renderComponent = (overrides: Partial = {}) => + render(); + +describe('CampaignTaskPopover', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────── + + it('should render the popover', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task-popover')).toBeInTheDocument(); + }); + + it('should render the list item with campaign-popover testIdPrefix', () => { + renderComponent(); + expect(screen.getByTestId('campaign-popover-list-item')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-title')).toHaveTextContent('Jane Smith'); + }); + + it('should render the variables panel with two-column layout', () => { + renderComponent(); + const panel = screen.getByTestId('global-variables-panel'); + expect(panel).toBeInTheDocument(); + expect(panel.className).toContain('global-variables-panel--two-column'); + }); + + it('should render global variables inside the panel', () => { + renderComponent(); + expect(screen.getByText('Campaign:')).toBeInTheDocument(); + expect(screen.getByText('Test Campaign')).toBeInTheDocument(); + }); + + // ── panelBackground prop ─────────────────────────────────────────── + + it('should set the variables panel background to background-primary-hover', () => { + renderComponent(); + // JSDOM cannot parse CSS custom properties (var()), so the style + // attribute is completely stripped. Verify the panel renders with the + // correct two-column layout — the actual CSS value is validated in + // browser/E2E tests. + const panel = screen.getByTestId('global-variables-panel'); + expect(panel).toBeInTheDocument(); + expect(panel.className).toContain('global-variables-panel--two-column'); + }); + + // ── Action buttons visibility ────────────────────────────────────── + + it('should render action buttons when not accepted', () => { + renderComponent({isAcceptClicked: false}); + expect(screen.getByTestId('campaign-popover-accept-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-remove-button')).toBeInTheDocument(); + }); + + it('should show Connecting button and disabled Skip/Remove when accept clicked but not confirmed', () => { + renderComponent({isAcceptClicked: true, isAccepted: false}); + expect(screen.queryByTestId('campaign-popover-accept-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-connecting-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-popover-remove-button')).toBeInTheDocument(); + }); + + it('should hide all action buttons when accepted by backend', () => { + renderComponent({isAcceptClicked: true, isAccepted: true, handleTimestamp: Date.now()}); + expect(screen.queryByTestId('campaign-popover-accept-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-popover-skip-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-popover-remove-button')).not.toBeInTheDocument(); + }); + + // ── Countdown / Handle timer ─────────────────────────────────────── + + it('should render countdown when not accepted', () => { + renderComponent({isAcceptClicked: false}); + expect(screen.getByTestId('mock-countdown')).toBeInTheDocument(); + }); + + it('should render handle time timer when accepted by backend', () => { + renderComponent({isAcceptClicked: true, isAccepted: true, handleTimestamp: Date.now()}); + expect(screen.queryByTestId('mock-countdown')).not.toBeInTheDocument(); + expect(screen.getByTestId('mock-task-timer')).toBeInTheDocument(); + }); + + // ── Phone number ─────────────────────────────────────────────────── + + it('should show phone number when different from customer name', () => { + renderComponent(); + expect(screen.getByTestId('campaign-popover-phone')).toBeInTheDocument(); + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task.test.tsx b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task.test.tsx new file mode 100644 index 000000000..2cc7190e4 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-task.test.tsx @@ -0,0 +1,558 @@ +import React from 'react'; +import {render, screen, fireEvent, act, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CampaignTask from '../../../../src/components/task/CampaignTask/campaign-task'; +import {CampaignTaskProps} from '../../../../src/components/task/CampaignTask/campaign-task.types'; +import {ITask} from '@webex/cc-store'; + +// ── Mocks ──────────────────────────────────────────────────────────── + +// Capture the onTimeout callback from the most recent CampaignCountdown render +let capturedOnTimeout: (() => void) | undefined; + +jest.mock('../../../../src/components/task/CampaignCountdown/campaign-countdown', () => { + const MockCountdown = ({onTimeout}: {onTimeout?: () => void}) => { + capturedOnTimeout = onTimeout; + return Time left: 00:30; + }; + MockCountdown.displayName = 'CampaignCountdown'; + return {__esModule: true, default: MockCountdown}; +}); + +jest.mock('../../../../src/components/task/TaskTimer/index', () => { + const MockTaskTimer = () => 00:00; + MockTaskTimer.displayName = 'TaskTimer'; + return {__esModule: true, default: MockTaskTimer}; +}); + +jest.mock('../../../../src/components/task/CampaignErrorDialog/campaign-error-dialog', () => { + const MockDialog = ({errorType, onClose}: {errorType: string; onClose: () => void}) => ( +
    + +
    + ); + MockDialog.displayName = 'CampaignErrorDialog'; + return {__esModule: true, default: MockDialog}; +}); + +jest.mock('../../../../src/components/task/CampaignTask/CampaignTaskPopover/campaign-task-popover', () => { + const MockPopover = () =>
    ; + MockPopover.displayName = 'CampaignTaskPopover'; + return {__esModule: true, default: MockPopover}; +}); + +jest.mock('@webex/cc-ui-logging', () => ({ + withMetrics: (component: React.ComponentType>) => component, +})); + +// ── Helpers ────────────────────────────────────────────────────────── + +const TIMEOUT_TIMESTAMP = String(Date.now() + 30000); + +const createMockTask = (overrides: Record = {}): ITask => { + const cpd = { + campaignPreviewSkipDisabled: 'false', + campaignPreviewRemoveDisabled: 'false', + campaignPreviewAutoAction: 'ACCEPT', + campaignPreviewOfferTimeout: TIMEOUT_TIMESTAMP, + ...(overrides.cpd as Record), + }; + + return { + data: { + interactionId: 'interaction-1', + interaction: { + callProcessingDetails: cpd, + callAssociatedDetails: { + ani: '+14085550001', + dn: '+14085550002', + customerName: 'Jane Smith', + }, + callAssociatedData: {}, + outboundType: 'OUTDIAL', + ...(overrides.interaction as Record), + }, + ...(overrides.data as Record), + }, + } as unknown as ITask; +}; + +const createDefaultProps = (overrides: Partial = {}): CampaignTaskProps => ({ + task: createMockTask(), + acceptPreviewContact: jest.fn().mockResolvedValue(undefined), + skipPreviewContact: jest.fn().mockResolvedValue(undefined), + removePreviewContact: jest.fn().mockResolvedValue(undefined), + cancelPreviewContact: jest.fn().mockResolvedValue(undefined), + isBrowser: false, + logger: { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + }, + ...overrides, +}); + +const renderComponent = (overrides: Partial = {}) => + render(); + +describe('CampaignTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + capturedOnTimeout = undefined; + }); + + // ── Initial rendering ────────────────────────────────────────────── + + it('should render the campaign task section', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task')).toBeInTheDocument(); + }); + + it('should render the task list item with title', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task-title')).toHaveTextContent('Jane Smith'); + }); + + it('should render action buttons in initial state', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task-accept-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + + it('should render the countdown in initial state', () => { + renderComponent(); + expect(screen.getByTestId('mock-countdown')).toBeInTheDocument(); + }); + + it('should render the variables panel', () => { + const task = createMockTask({ + interaction: { + callProcessingDetails: { + campaignPreviewOfferTimeout: TIMEOUT_TIMESTAMP, + campaignPreviewSkipDisabled: 'false', + campaignPreviewRemoveDisabled: 'false', + campaignPreviewAutoAction: 'ACCEPT', + }, + callAssociatedDetails: {ani: '+14085550001', dn: '+14085550002', customerName: 'Jane Smith'}, + callAssociatedData: { + CampaignId: { + name: 'CampaignId', + displayName: 'Campaign', + value: 'CM_001', + type: 'STRING', + agentEditable: false, + agentViewable: true, + global: true, + isSecure: false, + secureKeyId: '', + secureKeyVersion: 0, + }, + }, + outboundType: 'OUTDIAL', + }, + }); + renderComponent({task}); + expect(screen.getByTestId('global-variables-panel')).toBeInTheDocument(); + }); + + // ── Cancel button (Browser mode) ────────────────────────────────── + + it('should render Cancel button when isBrowser is true', () => { + renderComponent({isBrowser: true}); + expect(screen.getByTestId('campaign-task-cancel-button')).toBeInTheDocument(); + }); + + it('should NOT render Cancel button when isBrowser is false', () => { + renderComponent({isBrowser: false}); + expect(screen.queryByTestId('campaign-task-cancel-button')).not.toBeInTheDocument(); + }); + + // ── Accept flow ──────────────────────────────────────────────────── + + it('should show Connecting button and keep Skip/Remove visible after Accept is clicked', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + expect(acceptPreviewContact).toHaveBeenCalledTimes(1); + // Accept button replaced with Connecting button + expect(screen.queryByTestId('campaign-task-accept-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-connecting-button')).toBeInTheDocument(); + // Skip/Remove still visible (disabled) + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + + it('should keep Cancel button visible after Accept is clicked (browser mode)', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({isBrowser: true, acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + // Cancel still visible — hidden only when isAccepted becomes true + expect(screen.getByTestId('campaign-task-cancel-button')).toBeInTheDocument(); + }); + + it('should keep countdown visible after Accept is clicked (hidden only when backend confirms)', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + // Countdown still visible — handle timer only shown when isAccepted + expect(screen.getByTestId('mock-countdown')).toBeInTheDocument(); + }); + + it('should show error dialog when accept fails', async () => { + const acceptPreviewContact = jest.fn().mockRejectedValue(new Error('Network error')); + renderComponent({acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + await waitFor(() => { + expect(screen.getByTestId('campaign-error-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-error-dialog')).toHaveAttribute('data-error-type', 'ACCEPT_FAILED'); + }); + }); + + it('should re-enable buttons when accept fails', async () => { + const acceptPreviewContact = jest.fn().mockRejectedValue(new Error('fail')); + renderComponent({acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + await waitFor(() => { + expect(screen.getByTestId('campaign-task-accept-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + }); + + // ── Skip flow ────────────────────────────────────────────────────── + + it('should call skipPreviewContact and disable buttons when Skip is clicked', async () => { + const skipPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({skipPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-skip-button')); + }); + + expect(skipPreviewContact).toHaveBeenCalledTimes(1); + // After skip, buttons should be disabled (waiting for backend event) + expect((screen.getByTestId('campaign-task-accept-button') as unknown as {disabled: boolean}).disabled).toBe(true); + }); + + it('should show error dialog when skip fails', async () => { + const skipPreviewContact = jest.fn().mockRejectedValue(new Error('fail')); + renderComponent({skipPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-skip-button')); + }); + + await waitFor(() => { + expect(screen.getByTestId('campaign-error-dialog')).toHaveAttribute('data-error-type', 'SKIP_FAILED'); + }); + }); + + it('should not call skipPreviewContact when Skip is disabled', () => { + const skipPreviewContact = jest.fn(); + const task = createMockTask({cpd: {campaignPreviewSkipDisabled: 'true'}}); + renderComponent({task, skipPreviewContact}); + + fireEvent.click(screen.getByTestId('campaign-task-skip-button')); + expect(skipPreviewContact).not.toHaveBeenCalled(); + }); + + // ── Remove flow ──────────────────────────────────────────────────── + + it('should call removePreviewContact when Remove is clicked', async () => { + const removePreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({removePreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-remove-button')); + }); + + expect(removePreviewContact).toHaveBeenCalledTimes(1); + }); + + it('should show error dialog when remove fails', async () => { + const removePreviewContact = jest.fn().mockRejectedValue(new Error('fail')); + renderComponent({removePreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-remove-button')); + }); + + await waitFor(() => { + expect(screen.getByTestId('campaign-error-dialog')).toHaveAttribute('data-error-type', 'REMOVE_FAILED'); + }); + }); + + it('should not call removePreviewContact when Remove is disabled', () => { + const removePreviewContact = jest.fn(); + const task = createMockTask({cpd: {campaignPreviewRemoveDisabled: 'true'}}); + renderComponent({task, removePreviewContact}); + + fireEvent.click(screen.getByTestId('campaign-task-remove-button')); + expect(removePreviewContact).not.toHaveBeenCalled(); + }); + + // ── Cancel flow (Browser mode) ───────────────────────────────────── + + it('should call cancelPreviewContact when Cancel is clicked', async () => { + const cancelPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({isBrowser: true, cancelPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-cancel-button')); + }); + + expect(cancelPreviewContact).toHaveBeenCalledTimes(1); + }); + + it('should show error dialog when cancel fails', async () => { + const cancelPreviewContact = jest.fn().mockRejectedValue(new Error('fail')); + renderComponent({isBrowser: true, cancelPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-cancel-button')); + }); + + await waitFor(() => { + expect(screen.getByTestId('campaign-error-dialog')).toHaveAttribute('data-error-type', 'CANCEL_FAILED'); + }); + }); + + // ── Error dialog close ───────────────────────────────────────────── + + it('should dismiss error dialog when close button is clicked', async () => { + const acceptPreviewContact = jest.fn().mockRejectedValue(new Error('fail')); + renderComponent({acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + await waitFor(() => { + expect(screen.getByTestId('campaign-error-dialog')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-error-close')); + }); + + expect(screen.queryByTestId('campaign-error-dialog')).not.toBeInTheDocument(); + }); + + // ── Accept state persists across task data updates ───────────────── + + it('should hide buttons and show handle time once isAccepted becomes true', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + const task = createMockTask(); + const {rerender} = render(); + + // Accept the campaign + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + // Still in connecting state — buttons visible but Accept replaced with Connecting + expect(screen.getByTestId('campaign-task-connecting-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + + // Backend confirms — isAccepted becomes true + const updatedTask = createMockTask({cpd: {campaignPreviewOfferTimeout: String(Date.now() + 60000)}}); + rerender(); + + // Buttons should now be hidden — backend confirmed acceptance + expect(screen.queryByTestId('campaign-task-accept-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-connecting-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-skip-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('campaign-task-remove-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('mock-task-timer')).toBeInTheDocument(); + }); + + // ── State reset on new contact after skip/remove ─────────────────── + + it('should reset buttons when a new contact is offered (timeout changes while not accepted)', () => { + const task1 = createMockTask({cpd: {campaignPreviewOfferTimeout: '1000'}}); + const props = createDefaultProps({task: task1}); + const {rerender} = render(); + + // Buttons should be visible initially + expect(screen.getByTestId('campaign-task-accept-button')).toBeInTheDocument(); + + // Simulate new contact offer with different timeout + const task2 = createMockTask({cpd: {campaignPreviewOfferTimeout: '2000'}}); + rerender(); + + // Buttons should still be visible (reset occurred, state is fresh) + expect(screen.getByTestId('campaign-task-accept-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + + // ── Disabled button guards ───────────────────────────────────────── + + it('should not call acceptPreviewContact when Accept is already disabled', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({acceptPreviewContact}); + + // Click accept to disable it + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + // Now buttons are gone — the guard prevents double calls + expect(acceptPreviewContact).toHaveBeenCalledTimes(1); + }); + + // ── Caller identifier fallback ───────────────────────────────────── + + it('should use ANI as title when customerName is not available', () => { + const task = createMockTask({ + interaction: { + callProcessingDetails: { + campaignPreviewOfferTimeout: TIMEOUT_TIMESTAMP, + campaignPreviewSkipDisabled: 'false', + campaignPreviewRemoveDisabled: 'false', + campaignPreviewAutoAction: 'ACCEPT', + }, + callAssociatedDetails: {ani: '+14085550001', dn: '', customerName: undefined}, + callAssociatedData: {}, + outboundType: 'OUTDIAL', + }, + }); + renderComponent({task}); + expect(screen.getByTestId('campaign-task-title')).toHaveTextContent('+14085550001'); + }); + + // ── Timeout behavior (UI-only, no API calls) ───────────────────── + + describe('handleTimeout — consistent with Agent Desktop', () => { + it('should NOT call acceptPreviewContact when countdown expires with ACCEPT autoAction', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({acceptPreviewContact}); + + // Trigger timeout via the captured callback + expect(capturedOnTimeout).toBeDefined(); + await act(async () => { + capturedOnTimeout!(); + }); + + // Accept API should NOT be called — backend handles auto-accept + expect(acceptPreviewContact).not.toHaveBeenCalled(); + // UI shows Connecting state (accept clicked locally) — countdown still visible since !isAccepted + expect(screen.getByTestId('campaign-task-connecting-button')).toBeInTheDocument(); + expect(screen.getByTestId('mock-countdown')).toBeInTheDocument(); + }); + + it('should NOT call skipPreviewContact when countdown expires with SKIP autoAction', async () => { + const skipPreviewContact = jest.fn().mockResolvedValue(undefined); + const task = createMockTask({cpd: {campaignPreviewAutoAction: 'SKIP'}}); + renderComponent({task, skipPreviewContact}); + + expect(capturedOnTimeout).toBeDefined(); + await act(async () => { + capturedOnTimeout!(); + }); + + // Skip API should NOT be called — backend handles auto-skip + expect(skipPreviewContact).not.toHaveBeenCalled(); + // Buttons should be disabled + expect((screen.getByTestId('campaign-task-accept-button') as unknown as {disabled: boolean}).disabled).toBe(true); + expect((screen.getByTestId('campaign-task-skip-button') as unknown as {disabled: boolean}).disabled).toBe(true); + expect((screen.getByTestId('campaign-task-remove-button') as unknown as {disabled: boolean}).disabled).toBe(true); + }); + + it('should NOT call removePreviewContact when countdown expires with REMOVE autoAction', async () => { + const removePreviewContact = jest.fn().mockResolvedValue(undefined); + const task = createMockTask({cpd: {campaignPreviewAutoAction: 'REMOVE'}}); + renderComponent({task, removePreviewContact}); + + expect(capturedOnTimeout).toBeDefined(); + await act(async () => { + capturedOnTimeout!(); + }); + + // Remove API should NOT be called — backend handles auto-remove + expect(removePreviewContact).not.toHaveBeenCalled(); + // Buttons should be disabled + expect((screen.getByTestId('campaign-task-accept-button') as unknown as {disabled: boolean}).disabled).toBe(true); + }); + + it('should show Connecting button and disable Skip/Remove on timeout for ACCEPT autoAction', async () => { + renderComponent(); + + expect(capturedOnTimeout).toBeDefined(); + await act(async () => { + capturedOnTimeout!(); + }); + + // After auto-accept timeout, Accept replaced with Connecting, Skip/Remove still visible but disabled + expect(screen.queryByTestId('campaign-task-accept-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-connecting-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-skip-button')).toBeInTheDocument(); + expect(screen.getByTestId('campaign-task-remove-button')).toBeInTheDocument(); + }); + + it('should log a warning when autoAction is invalid/empty', async () => { + const logger = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + }; + const task = createMockTask({cpd: {campaignPreviewAutoAction: ''}}); + renderComponent({task, logger}); + + expect(capturedOnTimeout).toBeDefined(); + await act(async () => { + capturedOnTimeout!(); + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('No valid auto-action configured'), + expect.objectContaining({method: 'handleTimeout'}) + ); + }); + }); + + // ── Accessibility ────────────────────────────────────────────────── + + it('should have correct aria-label on the section', () => { + renderComponent(); + expect(screen.getByTestId('campaign-task')).toHaveAttribute('aria-label', 'Campaign preview contact'); + }); + + it('should set aria-busy to true when accept is clicked', async () => { + const acceptPreviewContact = jest.fn().mockResolvedValue(undefined); + renderComponent({acceptPreviewContact}); + + await act(async () => { + fireEvent.click(screen.getByTestId('campaign-task-accept-button')); + }); + + expect(screen.getByTestId('campaign-task')).toHaveAttribute('aria-busy', 'true'); + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-variables-panel.test.tsx b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-variables-panel.test.tsx new file mode 100644 index 000000000..8cf86f143 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CampaignTask/campaign-variables-panel.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import GlobalVariablesPanel from '../../../../src/components/task/GlobalVariablesPanel/global-variables-panel'; +import {CADVariable} from '../../../../src/components/task/task.types'; +import {GLOBAL_VARIABLES_LABEL} from '../../../../src/components/task/constants'; + +const makeVariable = (name: string, value: string): CADVariable => ({ + name, + displayName: name, + value, + type: 'STRING', + agentEditable: false, + agentViewable: true, + global: true, + isSecure: false, + secureKeyId: '', + secureKeyVersion: 0, +}); + +const sampleVariables: CADVariable[] = [ + makeVariable('Campaign ID', 'CM_Predictive_201004'), + makeVariable('LCM Key', 'f63839fk33dd31'), + makeVariable('Campaign group', 'Design'), + makeVariable('Company', 'Comps Super'), +]; + +describe('GlobalVariablesPanel', () => { + // ── Empty state ──────────────────────────────────────────────────── + + it('should return null when variables array is empty', () => { + const {container} = render(); + expect(container.firstChild).toBeNull(); + }); + + // ── Rendering variables ──────────────────────────────────────────── + + it('should render the panel with variables', () => { + render(); + expect(screen.getByTestId('global-variables-panel')).toBeInTheDocument(); + }); + + it('should render all variable labels and values', () => { + render(); + expect(screen.getByText('Campaign ID:')).toBeInTheDocument(); + expect(screen.getByText('CM_Predictive_201004')).toBeInTheDocument(); + expect(screen.getByText('LCM Key:')).toBeInTheDocument(); + expect(screen.getByText('f63839fk33dd31')).toBeInTheDocument(); + expect(screen.getByText('Campaign group:')).toBeInTheDocument(); + expect(screen.getByText('Design')).toBeInTheDocument(); + }); + + it('should skip variables with no value', () => { + const vars: CADVariable[] = [makeVariable('HasValue', 'yes'), {...makeVariable('NoValue', ''), value: ''}]; + render(); + expect(screen.getByText('HasValue:')).toBeInTheDocument(); + expect(screen.queryByText('NoValue:')).not.toBeInTheDocument(); + }); + + // ── Accessibility ────────────────────────────────────────────────── + + it('should render the definition list with correct aria-label', () => { + render(); + const panel = screen.getByTestId('global-variables-panel'); + const dl = panel.querySelector('dl'); + expect(dl).toHaveAttribute('aria-label', GLOBAL_VARIABLES_LABEL); + }); + + // ── Layout modes ─────────────────────────────────────────────────── + + it('should apply single-column layout by default', () => { + render(); + const panel = screen.getByTestId('global-variables-panel'); + expect(panel.className).toContain('global-variables-panel'); + expect(panel.className).not.toContain('global-variables-panel--two-column'); + }); + + it('should apply two-column layout class when layout="two-column"', () => { + render(); + const panel = screen.getByTestId('global-variables-panel'); + expect(panel.className).toContain('global-variables-panel--two-column'); + }); + + // ── panelBackground prop ─────────────────────────────────────────── + + it('should NOT set inline background style when panelBackground is not provided', () => { + render(); + const panel = screen.getByTestId('global-variables-panel'); + expect(panel.style.background).toBe(''); + }); + + it('should set inline background style when panelBackground is provided', () => { + render( + + ); + // JSDOM cannot parse CSS custom properties (var()), so the style + // attribute is completely stripped. Verify the panel still renders + // correctly — the actual CSS value is validated in browser/E2E tests. + const panel = screen.getByTestId('global-variables-panel'); + expect(panel).toBeInTheDocument(); + }); + + // ── className prop ───────────────────────────────────────────────── + + it('should apply additional className when provided', () => { + render(); + const panel = screen.getByTestId('global-variables-panel'); + expect(panel.className).toContain('custom-class'); + }); + + it('should not add trailing spaces when className is not provided', () => { + render(); + const panel = screen.getByTestId('global-variables-panel'); + expect(panel.className).not.toMatch(/\s$/); + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.snapshot.tsx index 7691f585e..effb9d870 100644 --- a/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.snapshot.tsx @@ -40,6 +40,12 @@ describe('TaskListComponent', () => { trace: jest.fn(), }; + const mockCc = { + skipPreviewContact: jest.fn().mockResolvedValue(undefined), + removePreviewContact: jest.fn().mockResolvedValue(undefined), + acceptPreviewContact: jest.fn().mockResolvedValue(undefined), + } as unknown as TaskListComponentProps['cc']; + // Default props using TaskListComponentProps interface const defaultProps: TaskListComponentProps = { currentTask: null, @@ -50,6 +56,7 @@ describe('TaskListComponent', () => { onTaskSelect: mockOnTaskSelect, logger: mockLogger, agentId: mockTask.data.agentId, + cc: mockCc, }; // Utility function spies diff --git a/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.tsx b/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.tsx index b775e63e5..5a01c56fd 100644 --- a/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.tsx +++ b/packages/contact-center/cc-components/tests/components/task/TaskList/task-list.tsx @@ -40,6 +40,12 @@ describe('TaskListComponent', () => { trace: jest.fn(), }; + const mockCc = { + skipPreviewContact: jest.fn().mockResolvedValue(undefined), + removePreviewContact: jest.fn().mockResolvedValue(undefined), + acceptPreviewContact: jest.fn().mockResolvedValue(undefined), + } as unknown as TaskListComponentProps['cc']; + // Default props using TaskListComponentProps interface const defaultProps: TaskListComponentProps = { currentTask: null, @@ -50,6 +56,7 @@ describe('TaskListComponent', () => { onTaskSelect: mockOnTaskSelect, logger: mockLogger, agentId: '', + cc: mockCc, }; // Utility function spies diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index cdfd850a4..ca897ecf5 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -24,6 +24,7 @@ const WebTaskList = r2wc(TaskList, { onTaskAccepted: 'function', onTaskDeclined: 'function', onTaskSelected: 'function', + hasCampaignPreviewEnabled: 'boolean', }, }); diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index 368ceda5d..534d7d643 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -23,7 +23,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/contact-center": "3.12.0-next.14", + "@webex/contact-center": "3.12.0-next.35", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 4bced8ddd..38de25e04 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -53,6 +53,7 @@ class Store implements IStore { isDigitalChannelsInitialized: boolean = false; dataCenter: string = ''; realtimeTranscriptionData: Partial[] = []; + acceptedCampaignIds: Set = new Set(); constructor() { makeAutoObservable(this, { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 116aa574c..6ca66dc60 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -17,12 +17,16 @@ import { ContactServiceQueuesResponse, ContactServiceQueueSearchParams, AddressBook, + TaskResponse, } from '@webex/contact-center'; import { OutdialAniEntriesResponse, OutdialAniParams, } from 'node_modules/@webex/contact-center/dist/types/services/config/types'; -import {DestinationType} from 'node_modules/@webex/contact-center/dist/types/services/task/types'; +import { + DestinationType, + PreviewContactPayload, +} from 'node_modules/@webex/contact-center/dist/types/services/task/types'; import { AgentProfileUpdate, LogContext, @@ -60,6 +64,9 @@ interface IContactCenter { setAgentState(data: StateChange): Promise; getOutdialAniEntries(params: OutdialAniParams): Promise; getAccessToken(): Promise; + acceptPreviewContact(payload: PreviewContactPayload): Promise; + skipPreviewContact(payload: PreviewContactPayload): Promise; + removePreviewContact(payload: PreviewContactPayload): Promise; } // To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 type IWebex = { @@ -152,6 +159,7 @@ interface IStore { isDigitalChannelsInitialized: boolean; dataCenter: string; realtimeTranscriptionData: Partial[]; + acceptedCampaignIds: Set; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; } @@ -184,6 +192,8 @@ interface IStoreWrapper extends IStore { setOnError(callback: (widgetName: string, error: Error) => void): void; setDataCenter(value: string): void; getAccessToken(): Promise; + addAcceptedCampaign(interactionId: string): void; + removeAcceptedCampaign(interactionId: string): void; } interface IWrapupCode { @@ -232,6 +242,8 @@ enum TASK_EVENTS { TASK_POST_CALL_ACTIVITY = 'task:postCallActivity', TASK_OUTDIAL_FAILED = 'task:outdialFailed', REAL_TIME_TRANSCRIPTION = 'REAL_TIME_TRANSCRIPTION', + TASK_CAMPAIGN_PREVIEW_RESERVATION = 'task:campaignPreviewReservation', + TASK_CAMPAIGN_CONTACT_UPDATED = 'task:campaignContactUpdated', } // TODO: remove this once cc sdk exports this enum // Events that are received on the contact center SDK @@ -259,6 +271,9 @@ type ICustomState = ICustomStateSet | ICustomStateReset; const ENGAGED_LABEL = 'ENGAGED'; const ENGAGED_USERNAME = 'Engaged'; +const RESERVED_LABEL = 'RESERVED'; +const RESERVED_USERNAME = 'Reserved'; + type AgentLoginProfile = { agentName?: string; orgId?: string; @@ -353,6 +368,8 @@ export { TASK_EVENTS, ENGAGED_LABEL, ENGAGED_USERNAME, + RESERVED_LABEL, + RESERVED_USERNAME, DIAL_NUMBER, EXTENSION, DESKTOP, diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 0b53cc974..8230ca38b 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -13,6 +13,8 @@ import { BuddyDetails, ENGAGED_LABEL, ENGAGED_USERNAME, + RESERVED_LABEL, + RESERVED_USERNAME, ContactServiceQueue, ContactServiceQueueSearchParams, EntryPointListResponse, @@ -150,6 +152,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.realtimeTranscriptionData; } + get acceptedCampaignIds() { + return this.store.acceptedCampaignIds; + } + setDataCenter = (value: string): void => { this.store.dataCenter = value; }; @@ -236,6 +242,12 @@ class StoreWrapper implements IStoreWrapper { // Don't assign the task as current task is incoming if (isIncomingTask(task, this.agentId)) return; + // Don't assign a pending campaign preview as current task. + // The agent has joined the telephony reservation but hasn't accepted the + // campaign preview yet (Accept/Skip/Remove buttons still showing). + // CallControl should only render after the preview is explicitly accepted. + if (task && this.isCampaignPreview(task) && task.data.interaction.state === 'new') return; + runInAction(() => { // Determine if the new task is the same as the current task. let isSameTask = false; @@ -426,6 +438,12 @@ class StoreWrapper implements IStoreWrapper { handleTaskRemove = (taskToRemove: ITask) => { if (taskToRemove) { const taskId = taskToRemove.data?.interactionId; + // Clean up accepted/dismissed campaign tracking now that the task is + // fully removed (after wrapup). This is safe because the task will + // no longer render in any component. + if (taskId && this.store.acceptedCampaignIds.has(taskId)) { + this.removeAcceptedCampaign(taskId); + } if (taskId && this.realtimeTranscriptionListeners[taskId]) { taskToRemove.off(TASK_EVENTS.REAL_TIME_TRANSCRIPTION, this.realtimeTranscriptionListeners[taskId]); delete this.realtimeTranscriptionListeners[taskId]; @@ -456,6 +474,8 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.handleConferenceEnded); taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); + taskToRemove.off(TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION, this.handleCampaignPreviewReservation); + taskToRemove.off(TASK_EVENTS.TASK_CAMPAIGN_CONTACT_UPDATED, this.refreshTaskList); if (this.deviceType === DEVICE_TYPE_BROWSER) { taskToRemove.off(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); this.setCallControlAudio(null); @@ -490,8 +510,61 @@ class StoreWrapper implements IStoreWrapper { } }; + private static readonly CAMPAIGN_PREVIEW_OUTBOUND_TYPES = ['STANDARD_PREVIEW_CAMPAIGN', 'DIRECT_PREVIEW_CAMPAIGN']; + private static readonly CAMPAIGN_PREVIEW_CAMPAIGN_TYPES = ['preview_standard', 'preview_direct']; + + /** + * Checks if a task is a campaign preview interaction. + * Matches agent desktop logic that checks both outboundType and campaignType. + */ + private isCampaignPreview = (task: ITask): boolean => { + const outboundType = task.data.interaction.outboundType ?? ''; + const cpd = task.data.interaction.callProcessingDetails as unknown as + | Record + | undefined; + const campaignType = cpd?.campaignType ?? ''; + + return ( + StoreWrapper.CAMPAIGN_PREVIEW_OUTBOUND_TYPES.includes(outboundType) || + StoreWrapper.CAMPAIGN_PREVIEW_CAMPAIGN_TYPES.includes(campaignType) + ); + }; + + /** + * Handles the campaign preview reservation event (agent accepted the preview). + * Transitions state from RESERVED to ENGAGED, matching agent desktop behavior. + */ + addAcceptedCampaign = (interactionId: string): void => { + runInAction(() => { + this.store.acceptedCampaignIds = new Set(this.store.acceptedCampaignIds).add(interactionId); + }); + }; + + removeAcceptedCampaign = (interactionId: string): void => { + runInAction(() => { + const next = new Set(this.store.acceptedCampaignIds); + next.delete(interactionId); + this.store.acceptedCampaignIds = next; + }); + }; + + handleCampaignPreviewReservation = (event: ITask) => { + const interactionId = event?.data?.interactionId; + if (interactionId) { + this.addAcceptedCampaign(interactionId); + } + runInAction(() => { + this.setState({ + developerName: ENGAGED_LABEL, + name: ENGAGED_USERNAME, + }); + }); + this.refreshTaskList(); + }; + handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); + this.refreshTaskList(); }; @@ -502,10 +575,25 @@ class StoreWrapper implements IStoreWrapper { } runInAction(() => { this.setCurrentTask(task); - this.setState({ - developerName: ENGAGED_LABEL, - name: ENGAGED_USERNAME, - }); + // Campaign preview that is still pending acceptance (state 'new') + // keeps the agent in RESERVED. Once the agent accepts the preview + // the interaction state transitions away from 'new' and the agent + // moves to ENGAGED — matching Agent Desktop behaviour. + if (this.isCampaignPreview(task) && task.data.interaction.state === 'new') { + this.setState({ + developerName: RESERVED_LABEL, + name: RESERVED_USERNAME, + }); + } else { + // Campaign preview with state !== 'new' means it was accepted. + if (this.isCampaignPreview(task)) { + this.addAcceptedCampaign(task.data.interactionId); + } + this.setState({ + developerName: ENGAGED_LABEL, + name: ENGAGED_USERNAME, + }); + } }); }; @@ -616,6 +704,11 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.refreshTaskList); task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, this.refreshTaskList); task.on(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); + + // Campaign preview: transition RESERVED → ENGAGED when the agent accepts + task.on(TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION, this.handleCampaignPreviewReservation); + task.on(TASK_EVENTS.TASK_CAMPAIGN_CONTACT_UPDATED, this.refreshTaskList); + const taskId = task.data?.interactionId; if (taskId && !this.realtimeTranscriptionListeners[taskId]) { this.realtimeTranscriptionListeners[taskId] = (payload: RealTimeTranscriptionEventPayload) => @@ -648,6 +741,36 @@ class StoreWrapper implements IStoreWrapper { this.refreshTaskList(); }; + /** + * Handles the initial arrival of a campaign preview task. + * The SDK emits TASK_CAMPAIGN_PREVIEW_RESERVATION instead of TASK_INCOMING + * for campaign preview tasks so the call does not ring out to the customer + * before the agent explicitly accepts the preview contact. + */ + handleIncomingCampaignPreview = (event) => { + const task: ITask = event; + + this.registerTaskEventListeners(task); + + if (this.onIncomingTask && !this.taskList[task.data.interactionId]) { + this.onIncomingTask({task}); + this.handleTaskMuteState(task); + } + + // Agent enters RESERVED state when a campaign preview arrives. + // The transition to ENGAGED happens after the agent explicitly + // accepts the preview (via handleCampaignPreviewReservation or + // handleTaskAssigned with a non-'new' interaction state). + runInAction(() => { + this.setState({ + developerName: RESERVED_LABEL, + name: RESERVED_USERNAME, + }); + }); + + this.refreshTaskList(); + }; + handleStateChange = (data) => { this.store.logger.info('CC-Widgets: handleStateChange(): agent state changed', { module: 'storeEventsWrapper.ts', @@ -694,7 +817,19 @@ class StoreWrapper implements IStoreWrapper { this.setConsultStartTimeStamp(Date.now()); } - if ( + if (this.isCampaignPreview(task) && task.data.interaction.state === 'new') { + this.setState({ + developerName: RESERVED_LABEL, + name: RESERVED_USERNAME, + }); + } else if (this.isCampaignPreview(task)) { + // Hydrating an accepted campaign preview — restore accepted state. + this.addAcceptedCampaign(task.data.interactionId); + this.setState({ + developerName: ENGAGED_LABEL, + name: ENGAGED_USERNAME, + }); + } else if ( (['wrapUp', 'connected'].includes(task.data.interaction.state) && !task.data.isConsulted) || task.data.wrapUpRequired ) { @@ -860,6 +995,7 @@ class StoreWrapper implements IStoreWrapper { this.setTeamId(''); this.setDigitalChannelsInitialized(false); this.store.realtimeTranscriptionData = []; + this.store.acceptedCampaignIds = new Set(); this.realtimeTranscriptionListeners = {}; }); }; @@ -886,6 +1022,7 @@ class StoreWrapper implements IStoreWrapper { 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_CAMPAIGN_PREVIEW_RESERVATION, this.handleIncomingCampaignPreview); 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); @@ -899,6 +1036,7 @@ class StoreWrapper implements IStoreWrapper { ccSDK.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); ccSDK.off(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); ccSDK.off(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); + ccSDK.off(TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION, this.handleIncomingCampaignPreview); ccSDK.off(TASK_EVENTS.TASK_MERGED, this.handleTaskMerged); ccSDK.off(CC_EVENTS.AGENT_MULTI_LOGIN, this.handleMultiLoginCloseSession); ccSDK.off(CC_EVENTS.AGENT_LOGOUT_SUCCESS, handleLogOut); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index df2d54fb3..45b743d24 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -103,6 +103,7 @@ jest.mock('../src/store', () => ({ allowConsultToQueue: false, isDeclineButtonEnabled: false, isDigitalChannelsInitialized: false, + acceptedCampaignIds: new Set(), setShowMultipleLoginAlert: jest.fn(), setCurrentState: jest.fn(), setLastStateChangeTimestamp: jest.fn(), @@ -1203,7 +1204,7 @@ describe('storeEventsWrapper', () => { // The call is answered and the task is assigned to the agent act(() => { - mockTaskOnSpy.mock.calls[1][1](); + mockTaskOnSpy.mock.calls[1][1](mockTask); }); waitFor(() => { @@ -1213,7 +1214,7 @@ describe('storeEventsWrapper', () => { // Task end stage: the task is completed act(() => { - mockTaskOnSpy.mock.calls[0][1]({wrapupRequired: true}); + mockTaskOnSpy.mock.calls[0][1](mockTask); }); waitFor(() => { @@ -2224,4 +2225,121 @@ describe('storeEventsWrapper', () => { expect(storeWrapper.currentTask).not.toEqual(taskWithoutJoined); }); }); + + describe('campaign preview task lifecycle', () => { + const createCampaignPreviewTask = (interactionId: string): ITask => + ({ + data: { + interactionId, + interaction: { + state: 'new', + outboundType: 'STANDARD_PREVIEW_CAMPAIGN', + callProcessingDetails: { + campaignType: 'preview_standard', + }, + }, + }, + on: jest.fn(), + off: jest.fn(), + }) as unknown as ITask; + + beforeEach(() => { + jest.clearAllMocks(); + storeWrapper['store'].acceptedCampaignIds = new Set(); + storeWrapper['store'].taskList = {}; + storeWrapper['store'].currentTask = null; + }); + + describe('handleTaskEnd — campaign preview (unaccepted)', () => { + it('should call refreshTaskList and let the backend drive task removal', () => { + const task = createCampaignPreviewTask('campaign-1'); + storeWrapper['store'].taskList = {'campaign-1': task}; + storeWrapper['store'].currentTask = task; + // SDK still returns the task — refreshTaskList will keep it in taskList + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({'campaign-1': task}); + + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + + storeWrapper.handleTaskEnd(); + + // refreshTaskList should be called (normal path, no force cleanup) + expect(refreshSpy).toHaveBeenCalled(); + }); + }); + + describe('handleTaskEnd — accepted campaign preview', () => { + it('should call refreshTaskList for accepted campaign', () => { + const task = createCampaignPreviewTask('campaign-accepted'); + storeWrapper['store'].acceptedCampaignIds = new Set(['campaign-accepted']); + storeWrapper['store'].taskList = {'campaign-accepted': task}; + storeWrapper['store'].currentTask = task; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({'campaign-accepted': task}); + + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + + storeWrapper.handleTaskEnd(); + + // acceptedCampaignIds should NOT be cleaned up here (deferred to handleTaskRemove) + expect(storeWrapper['store'].acceptedCampaignIds.has('campaign-accepted')).toBe(true); + // refreshTaskList SHOULD be called (normal path) + expect(refreshSpy).toHaveBeenCalled(); + }); + }); + + describe('handleTaskRemove — campaign ID cleanup', () => { + it('should remove interactionId from acceptedCampaignIds on task removal', () => { + const task = createCampaignPreviewTask('campaign-remove'); + storeWrapper['store'].acceptedCampaignIds = new Set(['campaign-remove']); + storeWrapper['store'].taskList = {'campaign-remove': task}; + storeWrapper['store'].currentTask = task; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({}); + + storeWrapper.handleTaskRemove(task); + + expect(storeWrapper['store'].acceptedCampaignIds.has('campaign-remove')).toBe(false); + }); + + it('should not affect acceptedCampaignIds when task is not an accepted campaign', () => { + const task = createCampaignPreviewTask('campaign-notaccepted'); + storeWrapper['store'].acceptedCampaignIds = new Set(['some-other-id']); + storeWrapper['store'].taskList = {'campaign-notaccepted': task}; + storeWrapper['store'].currentTask = task; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({}); + + storeWrapper.handleTaskRemove(task); + + // The other accepted campaign should remain + expect(storeWrapper['store'].acceptedCampaignIds.has('some-other-id')).toBe(true); + }); + }); + + describe('handleTaskEnd — non-campaign tasks', () => { + it('should call refreshTaskList for a regular (non-campaign) task', () => { + const regularTask: ITask = { + data: { + interactionId: 'regular-1', + interaction: { + state: 'connected', + outboundType: 'OUTDIAL', + }, + }, + on: jest.fn(), + off: jest.fn(), + } as unknown as ITask; + + storeWrapper['store'].taskList = {'regular-1': regularTask}; + storeWrapper['store'].currentTask = regularTask; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({'regular-1': regularTask}); + + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + + storeWrapper.handleTaskEnd(); + + // Should call refreshTaskList normally + expect(refreshSpy).toHaveBeenCalled(); + // taskList should still contain the task (SDK still returns it) + expect(storeWrapper['store'].taskList['regular-1']).toBeDefined(); + }); + }); + }); }); diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 022629749..b5424e4ad 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -2,11 +2,34 @@ import React from 'react'; import {observer} from 'mobx-react-lite'; import {ErrorBoundary} from 'react-error-boundary'; -import store from '@webex/cc-store'; +import store, {ITask} from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlComponent} from '@webex/cc-components'; +const CAMPAIGN_PREVIEW_OUTBOUND_TYPES = ['STANDARD_PREVIEW_CAMPAIGN', 'DIRECT_PREVIEW_CAMPAIGN']; +const CAMPAIGN_PREVIEW_CAMPAIGN_TYPES = ['preview_standard', 'preview_direct']; + +/** + * Checks whether the task is a campaign preview that the agent has not + * explicitly accepted. Uses the store's acceptedCampaignIds as the + * source of truth — the participants.hasJoined flag is unreliable + * because CampaignContactUpdated payloads can set it even when the + * agent only skipped or removed the preview. + */ +const isUnacceptedCampaignPreview = (task: ITask, acceptedCampaignIds: Set): boolean => { + const outboundType = task.data.interaction.outboundType ?? ''; + const cpd = task.data.interaction.callProcessingDetails as unknown as Record; + const campaignType = cpd?.campaignType ?? ''; + + const isCampaignPreview = + CAMPAIGN_PREVIEW_OUTBOUND_TYPES.includes(outboundType) || CAMPAIGN_PREVIEW_CAMPAIGN_TYPES.includes(campaignType); + + if (!isCampaignPreview) return false; + + return !acceptedCampaignIds.has(task.data.interactionId); +}; + const CallControlInternal: React.FunctionComponent = observer( ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute, consultTransferOptions, conferenceEnabled}) => { const { @@ -20,23 +43,37 @@ const CallControlInternal: React.FunctionComponent = observer( allowConsultToQueue, isMuted, agentId, + acceptedCampaignIds, } = store; + const callControlProps = useCallControl({ + currentTask, + onHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + logger, + deviceType, + featureFlags, + isMuted, + conferenceEnabled, + agentId, + }); + + if (!currentTask) { + return <>; + } + + // Hide call control when the current task is a campaign preview that + // the agent has not yet accepted. Matches agent desktop behavior where + // call controls are only shown after the preview contact is accepted. + if (isUnacceptedCampaignPreview(currentTask, acceptedCampaignIds)) { + return <>; + } + const result = { - ...useCallControl({ - currentTask, - onHoldResume, - onEnd, - onWrapUp, - onRecordingToggle, - onToggleMute, - logger, - deviceType, - featureFlags, - isMuted, - conferenceEnabled, - agentId, - }), + ...callControlProps, wrapupCodes, consultStartTimeStamp, callControlAudio, diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index df353bd8b..3b7180357 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -2,11 +2,30 @@ import React from 'react'; import {observer} from 'mobx-react-lite'; import {ErrorBoundary} from 'react-error-boundary'; -import store from '@webex/cc-store'; +import store, {ITask} from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; import {CallControlCADComponent} from '@webex/cc-components'; +const CAMPAIGN_PREVIEW_OUTBOUND_TYPES = ['STANDARD_PREVIEW_CAMPAIGN', 'DIRECT_PREVIEW_CAMPAIGN']; +const CAMPAIGN_PREVIEW_CAMPAIGN_TYPES = ['preview_standard', 'preview_direct']; + +const isCampaignPreviewTask = (task: ITask): boolean => { + const outboundType = task.data.interaction.outboundType ?? ''; + const cpd = task.data.interaction.callProcessingDetails as unknown as Record; + const campaignType = cpd?.campaignType ?? ''; + + return ( + CAMPAIGN_PREVIEW_OUTBOUND_TYPES.includes(outboundType) || CAMPAIGN_PREVIEW_CAMPAIGN_TYPES.includes(campaignType) + ); +}; + +const isUnacceptedCampaignPreview = (task: ITask, acceptedCampaignIds: Set): boolean => { + if (!isCampaignPreviewTask(task)) return false; + + return !acceptedCampaignIds.has(task.data.interactionId); +}; + const CallControlCADInternal: React.FunctionComponent = observer( ({ onHoldResume, @@ -30,22 +49,36 @@ const CallControlCADInternal: React.FunctionComponent = observ deviceType, isMuted, agentId, + acceptedCampaignIds, } = store; + + const callControlProps = useCallControl({ + currentTask, + onHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + logger, + deviceType, + featureFlags, + isMuted, + conferenceEnabled, + agentId, + }); + + if (!currentTask) { + return <>; + } + + if (isUnacceptedCampaignPreview(currentTask, acceptedCampaignIds)) { + return <>; + } + + const isCampaignCall = currentTask ? isCampaignPreviewTask(currentTask) : false; + const result = { - ...useCallControl({ - currentTask, - onHoldResume, - onEnd, - onWrapUp, - onRecordingToggle, - onToggleMute, - logger, - deviceType, - featureFlags, - isMuted, - conferenceEnabled, - agentId, - }), + ...callControlProps, wrapupCodes, consultStartTimeStamp, callControlAudio, @@ -54,6 +87,7 @@ const CallControlCADInternal: React.FunctionComponent = observ allowConsultToQueue, logger, consultTransferOptions, + isCampaignCall, }; return ; diff --git a/packages/contact-center/task/src/TaskList/index.tsx b/packages/contact-center/task/src/TaskList/index.tsx index 3cd675c52..649721a85 100644 --- a/packages/contact-center/task/src/TaskList/index.tsx +++ b/packages/contact-center/task/src/TaskList/index.tsx @@ -8,15 +8,18 @@ import {useTaskList} from '../helper'; import {TaskListProps} from '../task.types'; const TaskListInternal: React.FunctionComponent = observer( - ({onTaskAccepted, onTaskDeclined, onTaskSelected}) => { - const {cc, taskList, currentTask, deviceType, logger, agentId} = store; + ({onTaskAccepted, onTaskDeclined, onTaskSelected, hasCampaignPreviewEnabled}) => { + const {cc, taskList, currentTask, deviceType, logger, agentId, acceptedCampaignIds} = store; const result = useTaskList({cc, deviceType, logger, taskList, onTaskAccepted, onTaskDeclined, onTaskSelected}); const props = { ...result, + cc, currentTask, logger, agentId, + hasCampaignPreviewEnabled, + acceptedCampaignIds, }; return ; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index d2c35e5fa..36f855a68 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -15,7 +15,14 @@ export type UseTaskListProps = Pick & Partial>; -export type TaskListProps = Partial>; +export type TaskListProps = Partial> & { + /** + * Flag to enable campaign preview task rendering. + * When true and the task is a campaign preview, CampaignTask is rendered. + * Defaults to true. + */ + hasCampaignPreviewEnabled?: boolean; +}; export type RealTimeTranscriptProps = Pick; diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index 13ab6fee5..02d62d583 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -512,6 +512,9 @@ const mockCC: IContactCenter = { setAgentState: jest.fn().mockResolvedValue({}), getOutdialAniEntries: jest.fn().mockResolvedValue({entries: []}), getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), + acceptPreviewContact: jest.fn().mockResolvedValue({}), + skipPreviewContact: jest.fn().mockResolvedValue({}), + removePreviewContact: jest.fn().mockResolvedValue({}), }; const mockCallAssociatedData: Record< 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 8860bf10e..604b18432 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -31,6 +31,7 @@ import EngageWidget from './EngageWidget'; // This is not to be included to a production app. // Have added here for debugging purposes window['store'] = store; + const defaultWidgets = { stationLogin: true, stationLoginProfile: false, @@ -65,6 +66,7 @@ function App() { const [showOutdialFailedModal, setShowOutdialFailedModal] = useState(false); const [outdialFailedReason, setOutdialFailedReason] = useState(''); const [isAddressBookEnabled, setIsAddressBookEnabled] = useState(true); + const [hasCampaignPreviewEnabled, setHasCampaignPreviewEnabled] = useState(true); const [isLoggedIn, setIsLoggedIn] = useState(false); const [incomingTasks, setIncomingTasks] = useState([]); const [loginType, setLoginType] = useState('token'); @@ -859,52 +861,54 @@ function App() {
    )} -
    -
    -
    -  Call Control and Call Control with CAD  - { - setConferenceEnabled(!conferenceEnabled); - }} - /> - {selectedWidgets.callControl && store.currentTask && ( -
    - Call Control - - -
    - )} - {selectedWidgets.callControlCAD && store.currentTask && ( -
    - Call Control with Call Associated Data (CAD) - -
    - )} -
    -
    -
    + { + setConferenceEnabled(!conferenceEnabled); + }} + /> + + {selectedWidgets.callControl && ( +
    +
    +
    + Call Control + +
    +
    +
    + )} + + {selectedWidgets.callControlCAD && ( +
    +
    +
    + Call Control with Call Associated Data (CAD) + +
    +
    +
    + )} {selectedWidgets.incomingTask && ( <> @@ -947,10 +951,21 @@ function App() {
    Task List + setHasCampaignPreviewEnabled(!hasCampaignPreviewEnabled)} + />
    diff --git a/widgets-samples/cc/samples-cc-wc-app/app.js b/widgets-samples/cc/samples-cc-wc-app/app.js index b93ec401a..ffdda096d 100644 --- a/widgets-samples/cc/samples-cc-wc-app/app.js +++ b/widgets-samples/cc/samples-cc-wc-app/app.js @@ -26,8 +26,8 @@ const taskListCheckbox = document.getElementById('taskListCheckbox'); const callControlCheckbox = document.getElementById('callControlCheckbox'); const callControlCADCheckbox = document.getElementById('callControlCADCheckbox'); const outdialCallCheckbox = document.getElementById('outdialCallCheckbox'); - let isMultiLoginEnabled = false; +let hasCampaignPreviewEnabled = true; let integrationEnv = false; // Load integration environment setting from localStorage on page load @@ -87,6 +87,15 @@ function enableMultiLogin() { else isMultiLoginEnabled = true; } +function toggleCampaignPreview(event) { + hasCampaignPreviewEnabled = event.target.checked; + // Sync both checkboxes + document.querySelectorAll('.campaign-preview-checkbox').forEach(function (cb) { + cb.checked = hasCampaignPreviewEnabled; + }); + ccTaskList.hasCampaignPreviewEnabled = hasCampaignPreviewEnabled; +} + function doOAuthLogin() { let redirectUri = `${window.location.protocol}//${window.location.host}`; @@ -193,6 +202,7 @@ function initWidgets() { ccIncomingTask.onDeclined = onDeclined; ccTaskList.onTaskAccepted = onTaskAccepted; ccTaskList.onTaskDeclined = onTaskDeclined; + ccTaskList.hasCampaignPreviewEnabled = hasCampaignPreviewEnabled; ccCallControl.onHoldResume = onHoldResume; ccCallControl.onEnd = onEnd; ccCallControl.onWrapUp = onWrapUp; @@ -239,7 +249,19 @@ function loginSuccess() { } if (taskListCheckbox.checked) { ccTaskList.classList.remove('disabled'); - widgetsContainer.appendChild(ccTaskList); + + const taskListContainer = document.createElement('div'); + taskListContainer.className = 'box'; + taskListContainer.innerHTML = ` +
    +
    + Task List +
    +
    + `; + + taskListContainer.querySelector('fieldset').appendChild(ccTaskList); + widgetsContainer.appendChild(taskListContainer); } if (callControlCheckbox.checked) { ccCallControl.classList.remove('disabled'); diff --git a/yarn.lock b/yarn.lock index 253da91a1..7da8d1ba6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9586,21 +9586,21 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.12.0-next.10": - version: 3.12.0-next.10 - resolution: "@webex/calling@npm:3.12.0-next.10" +"@webex/calling@npm:3.12.0-next.28": + version: 3.12.0-next.28 + resolution: "@webex/calling@npm:3.12.0-next.28" dependencies: "@types/platform": "npm:1.3.4" - "@webex/internal-media-core": "npm:2.23.3" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.2" - "@webex/media-helpers": "npm:3.12.0-next.1" + "@webex/internal-media-core": "npm:2.24.1" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" + "@webex/media-helpers": "npm:3.12.0-next.3" async-mutex: "npm:0.4.0" buffer: "npm:6.0.3" jest-html-reporters: "npm:3.0.11" platform: "npm:1.3.6" uuid: "npm:8.3.2" xstate: "npm:4.30.6" - checksum: 10c0/b44f78b7b828d4a31dc7976d50fd6544cc43b6a21f2c3c2dc6ea17ce2598b6dda99ce757a995274923ed2da6449452220658c723f95734e38dc8040134b76a4f + checksum: 10c0/2576168249681670551c5fe64b92935eb1e383198efcf6833329bc022c2b594d7ebeeec94c4179f738b0154a785e394a6fcdfe47c220bc5eec970a6c064c79f0 languageName: node linkType: hard @@ -9761,7 +9761,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" - "@webex/contact-center": "npm:3.12.0-next.14" + "@webex/contact-center": "npm:3.12.0-next.35" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" babel-loader: "npm:9.2.1" @@ -9993,6 +9993,13 @@ __metadata: languageName: node linkType: hard +"@webex/common-timers@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/common-timers@npm:3.12.0-next.1" + checksum: 10c0/8181a7e7195df093db1ca649b32ada69b949b9f8f9d18c275120584f60ec912f5b2fa7772186f63c959eace900abb1ea4619db5bbac5839d6feda5c89758ae9a + languageName: node + linkType: hard + "@webex/common@npm:1.161.0": version: 1.161.0 resolution: "@webex/common@npm:1.161.0" @@ -10056,6 +10063,21 @@ __metadata: languageName: node linkType: hard +"@webex/common@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/common@npm:3.12.0-next.1" + dependencies: + backoff: "npm:^2.5.0" + bowser: "npm:^2.11.0" + core-decorators: "npm:^0.20.0" + global: "npm:^4.4.0" + lodash: "npm:^4.17.21" + safe-buffer: "npm:^5.2.0" + urlsafe-base64: "npm:^1.0.0" + checksum: 10c0/6b604810d385f432671c3435211ddf097ac98d1d779acf418d1a53a9d8064e889d3381b2d48b0e43c008c806ce64387dccb1001b714acef2ab4b93f44fbb2bfe + languageName: node + linkType: hard + "@webex/component-adapter-interfaces@npm:^1.28.0, @webex/component-adapter-interfaces@npm:^1.30.5": version: 1.30.18 resolution: "@webex/component-adapter-interfaces@npm:1.30.18" @@ -10110,21 +10132,21 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-next.14": - version: 3.12.0-next.14 - resolution: "@webex/contact-center@npm:3.12.0-next.14" +"@webex/contact-center@npm:3.12.0-next.35": + version: 3.12.0-next.35 + resolution: "@webex/contact-center@npm:3.12.0-next.35" dependencies: "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.10" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.3" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.2" - "@webex/internal-plugin-support": "npm:3.12.0-next.3" - "@webex/plugin-authorization": "npm:3.12.0-next.2" - "@webex/plugin-logger": "npm:3.12.0-next.2" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/calling": "npm:3.12.0-next.28" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" + "@webex/internal-plugin-support": "npm:3.12.0-next.17" + "@webex/plugin-authorization": "npm:3.12.0-next.15" + "@webex/plugin-logger": "npm:3.12.0-next.15" + "@webex/webex-core": "npm:3.12.0-next.15" jest-html-reporters: "npm:3.0.11" lodash: "npm:^4.17.21" - checksum: 10c0/afb90bd91b61844b523fd8862b2ded0be3d99a0ca3caaa90ec546e699ca94f757bacd5d85d7d228ee92c1e76ed0bb5dbbf52a11e6570a8bd4a87c63bc2310212 + checksum: 10c0/78df9c9f462a4a483c84476e3bf29045d6ad6c2e80332cc31a78b487302126f5fc19bc85a9243b7f6ea8d7805c9567909c85c510e0665d8b00e3eed4dd8bbfbd languageName: node linkType: hard @@ -10168,6 +10190,15 @@ __metadata: languageName: node linkType: hard +"@webex/helper-html@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/helper-html@npm:3.12.0-next.1" + dependencies: + lodash: "npm:^4.17.21" + checksum: 10c0/cf325224593d4f2b52d0542828ef60bc22343721aa84047e97b3c12bc133f1e105db06c01ae9fb6f32bef6cae2a8f2e8b6c4aecae5083ffc72fad811dc6f88be + languageName: node + linkType: hard + "@webex/helper-image@npm:2.60.4": version: 2.60.4 resolution: "@webex/helper-image@npm:2.60.4" @@ -10219,6 +10250,23 @@ __metadata: languageName: node linkType: hard +"@webex/helper-image@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/helper-image@npm:3.12.0-next.1" + dependencies: + "@webex/http-core": "npm:3.12.0-next.1" + "@webex/test-helper-chai": "npm:3.12.0-next.1" + "@webex/test-helper-file": "npm:3.12.0-next.1" + "@webex/test-helper-mocha": "npm:3.12.0-next.1" + exifr: "npm:^5.0.3" + gm: "npm:^1.23.1" + lodash: "npm:^4.17.21" + mime: "npm:^2.4.4" + safe-buffer: "npm:^5.2.0" + checksum: 10c0/1ab6b056cf208541876299b6ba984856c2f65ccf1f2f2c34b3e2eb0eb963b14eba4759178dbb99e86dfbbaa6e515ec958d591450ac16902339f138b86a78a461 + languageName: node + linkType: hard + "@webex/http-core@npm:1.161.0": version: 1.161.0 resolution: "@webex/http-core@npm:1.161.0" @@ -10296,6 +10344,24 @@ __metadata: languageName: node linkType: hard +"@webex/http-core@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/http-core@npm:3.12.0-next.1" + dependencies: + "@webex/common": "npm:3.12.0-next.1" + file-type: "npm:^16.0.1" + global: "npm:^4.4.0" + is-function: "npm:^1.0.1" + lodash: "npm:^4.17.21" + parse-headers: "npm:^2.0.2" + qs: "npm:^6.7.3" + request: "npm:^2.88.0" + safe-buffer: "npm:^5.2.0" + xtend: "npm:^4.0.2" + checksum: 10c0/438aae8087f00da83b7a8790a74605167fc60ff5256bb79cd900803ee547b99046b8d2c7898f0c4d91092f28eee65ba8a8c2df591c3d784fff1b5e2b65af3876 + languageName: node + linkType: hard + "@webex/internal-media-core@npm:0.0.7-beta": version: 0.0.7-beta resolution: "@webex/internal-media-core@npm:0.0.7-beta" @@ -10333,23 +10399,23 @@ __metadata: languageName: node linkType: hard -"@webex/internal-media-core@npm:2.23.3": - version: 2.23.3 - resolution: "@webex/internal-media-core@npm:2.23.3" +"@webex/internal-media-core@npm:2.24.1": + version: 2.24.1 + resolution: "@webex/internal-media-core@npm:2.24.1" dependencies: "@babel/runtime": "npm:^7.18.9" "@babel/runtime-corejs2": "npm:^7.25.0" "@webex/rtcstats": "npm:^1.5.5" "@webex/ts-sdp": "npm:1.8.2" "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-client-media-engine": "npm:3.39.3" + "@webex/web-client-media-engine": "npm:3.39.10" events: "npm:^3.3.0" ip-anonymize: "npm:^0.1.0" typed-emitter: "npm:^2.1.0" uuid: "npm:^8.3.2" webrtc-adapter: "npm:^8.1.2" xstate: "npm:^4.30.6" - checksum: 10c0/06aadf963b88210ff8ff36cba83fd0028a709e1bb35b82bec09df1b0281807551ec7d9aeea355c5f9071395b54c624867e6c27885d0458e4cba66d1a2cde2fba + checksum: 10c0/2878a9e275312b852317fe85b45aca127fa6ea7057d62888d1e550064ec433f0e980b8ab62cecde5e440dabdba0e22d32e9d35ac375869f0c1ca958d7fb0c605 languageName: node linkType: hard @@ -10435,21 +10501,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.12.0-next.3": - version: 3.12.0-next.3 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.3" +"@webex/internal-plugin-conversation@npm:3.12.0-next.17": + version: 3.12.0-next.17 + resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.17" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/helper-html": "npm:3.11.0-next.1" - "@webex/helper-image": "npm:3.11.0-next.1" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.3" - "@webex/internal-plugin-user": "npm:3.12.0-next.2" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/helper-html": "npm:3.12.0-next.1" + "@webex/helper-image": "npm:3.12.0-next.1" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" + "@webex/internal-plugin-user": "npm:3.12.0-next.16" + "@webex/webex-core": "npm:3.12.0-next.15" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/1831ce3e16cade65c1c05d56d770f7673164ad57a71f5c6d6bfe5794b3e3fc473b5a1f4f0ca99a9976bda3564ba5a219692da633740606c455855eef3f85d143 + checksum: 10c0/0d7345f75b13b905b6c3cc5a94bb37f36788a1750789b09a392e2046686916eceddc1d1913c34d850d3b2c5a33b8f3b5945415985c0d637b9879d4a57cb4065d languageName: node linkType: hard @@ -10503,20 +10569,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-device@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.2" +"@webex/internal-plugin-device@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/internal-plugin-device@npm:3.12.0-next.15" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/common-timers": "npm:3.11.0-next.1" - "@webex/http-core": "npm:3.11.0-next.1" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.2" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/common-timers": "npm:3.12.0-next.1" + "@webex/http-core": "npm:3.12.0-next.1" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" + "@webex/webex-core": "npm:3.12.0-next.15" ampersand-collection: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/b3381809d8e27f3ccd8be080a930f1db4da50b49889815ac6f3b03828b6333ee6da6843da7d5147a7d7db57131c1fb2386960f974f6ce8376604d5babfe4bced + checksum: 10c0/c9bef045a01429c75ef55eee0fcdc08e6d647e4a35a81217963157a24c7e454093419a9a68203bdd3fb3f1081ba269d96d410b870e1eae5f4154691f40dfb8ed languageName: node linkType: hard @@ -10612,17 +10678,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.12.0-next.3": - version: 3.12.0-next.3 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.3" +"@webex/internal-plugin-encryption@npm:3.12.0-next.16": + version: 3.12.0-next.16 + resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.16" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/common-timers": "npm:3.11.0-next.1" - "@webex/http-core": "npm:3.11.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.3" - "@webex/test-helper-file": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/common-timers": "npm:3.12.0-next.1" + "@webex/http-core": "npm:3.12.0-next.1" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" + "@webex/test-helper-file": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" asn1js: "npm:^2.0.26" debug: "npm:^4.3.4" isomorphic-webcrypto: "npm:^2.3.8" @@ -10634,7 +10700,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/e11e700f07d011c59558133be1711a66b6754ff3ee542f95782f41690f9d392441bda2ee6610ef8d2b5f924d7e5aee6c5cb6a8fad9a097aeec24b687b9a98196 + checksum: 10c0/ed33bdfca34f2fe0bb81ef7b7ebcd4b336c7c36fdd6bc659941e56b5666448e7daf19fb1a0f7481d25ddb1924cbda8a536b87328196facccda0dca98e0942bc7 languageName: node linkType: hard @@ -10672,14 +10738,14 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-feature@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.2" +"@webex/internal-plugin-feature@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.15" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/webex-core": "npm:3.12.0-next.15" lodash: "npm:^4.17.21" - checksum: 10c0/ecf27e45b15e73b37b6b04c4a6395c5bb4c1c4cc0c6315bb96dd795ee16720e8b78515e9e863e90424208183803e717af37bc53add013e2d4e77dc571fdbaa63 + checksum: 10c0/c6d99e9b93ac300ec601c8d1da5a33e5249aadf49aec14166f5a14000045fcf07b28bf55890691680dab219f7f5291e9b2b74af56bf6473121eb862d402c439b languageName: node linkType: hard @@ -10826,27 +10892,27 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-mercury@npm:3.12.0-next.3": - version: 3.12.0-next.3 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.3" +"@webex/internal-plugin-mercury@npm:3.12.0-next.16": + version: 3.12.0-next.16 + resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.16" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/common-timers": "npm:3.11.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/internal-plugin-feature": "npm:3.12.0-next.2" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.2" - "@webex/test-helper-chai": "npm:3.11.0-next.1" - "@webex/test-helper-mocha": "npm:3.11.0-next.1" - "@webex/test-helper-mock-web-socket": "npm:3.11.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.11.0-next.1" - "@webex/test-helper-refresh-callback": "npm:3.11.0-next.1" - "@webex/test-helper-test-users": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/common-timers": "npm:3.12.0-next.1" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/internal-plugin-feature": "npm:3.12.0-next.15" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" + "@webex/test-helper-chai": "npm:3.12.0-next.1" + "@webex/test-helper-mocha": "npm:3.12.0-next.1" + "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.1" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" + "@webex/test-helper-refresh-callback": "npm:3.12.0-next.1" + "@webex/test-helper-test-users": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" backoff: "npm:^2.5.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" ws: "npm:^8.17.1" - checksum: 10c0/14bc522908ce1e49258633f72d1f2e90d443da1972b3558a144e0f2c2ad582466f1e6ad46040d0fdea5e1747f43e0879f9e49df6a698611d5b6e74b8fea395d1 + checksum: 10c0/0cfe3cb25a5b42513103ff147b850476c6f0d885e317a13dc2fe34f3a1d9e605edbd75b421ea667b1470c414b82998340e37d64bb33176e19844fb1466347ded languageName: node linkType: hard @@ -10896,19 +10962,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-metrics@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.2" +"@webex/internal-plugin-metrics@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.15" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/common-timers": "npm:3.11.0-next.1" - "@webex/test-helper-chai": "npm:3.11.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/common-timers": "npm:3.12.0-next.1" + "@webex/test-helper-chai": "npm:3.12.0-next.1" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" ip-anonymize: "npm:^0.1.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/7a9210e93fd87c7ab63097ee1ef23be79f878b5c415adbbafd4bce673511e5da46b65af4be931ecf4b36b1ff729b7a865a7246e79b3fe8fd6c0604af03c8a2fd + checksum: 10c0/d8b1d3f1b1f34589f57e9a30030cd8922a95d009f3e2d0b57dd57ade34c439a371d38bb4c0960ccab65498c55b9b0f3d12451e3b0d66a95a0f59757b23c55a83 languageName: node linkType: hard @@ -10974,18 +11040,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.12.0-next.3": - version: 3.12.0-next.3 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.3" +"@webex/internal-plugin-search@npm:3.12.0-next.17": + version: 3.12.0-next.17 + resolution: "@webex/internal-plugin-search@npm:3.12.0-next.17" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.3" - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.3" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" + "@webex/webex-core": "npm:3.12.0-next.15" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/941f05e67ab5556e22f7b2dd01cb1541e91b40b7953a33c1cb14b3697149387e1fa17eb4415a7efd7fca1003e7e594edc1724e900918848d7b7696b97d73f675 + checksum: 10c0/b5e13de9b8312333a7ff48630ea0bb306da5b2d250626806e08d3cad5a43c20a8a8f119175687d9320a7ea2e8c13c2d8daa8aa8d1cc6e400f5aab89b501f553d languageName: node linkType: hard @@ -11023,20 +11089,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.12.0-next.3": - version: 3.12.0-next.3 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.3" +"@webex/internal-plugin-support@npm:3.12.0-next.17": + version: 3.12.0-next.17 + resolution: "@webex/internal-plugin-support@npm:3.12.0-next.17" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/internal-plugin-search": "npm:3.12.0-next.3" - "@webex/test-helper-chai": "npm:3.11.0-next.1" - "@webex/test-helper-file": "npm:3.11.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.11.0-next.1" - "@webex/test-helper-test-users": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/internal-plugin-search": "npm:3.12.0-next.17" + "@webex/test-helper-chai": "npm:3.12.0-next.1" + "@webex/test-helper-file": "npm:3.12.0-next.1" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" + "@webex/test-helper-test-users": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/16c3d2b25083019e8c0ce96fd240ca5554351fabbb70939ed3158cd9e507166e5a1977a9350a105d53497aeb19ece37ce5f9f534b70f54116c5b174b39a16962 + checksum: 10c0/e2d792681c264c646d47187a3a7a8f995fc63b1c724f78762d2e019db8798ae5b7ca6a7a516cf8938e66915f8c969c08a400806ffb51edf787397629bacc3c1c languageName: node linkType: hard @@ -11102,19 +11168,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-user@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.2" +"@webex/internal-plugin-user@npm:3.12.0-next.16": + version: 3.12.0-next.16 + resolution: "@webex/internal-plugin-user@npm:3.12.0-next.16" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/test-helper-chai": "npm:3.11.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.11.0-next.1" - "@webex/test-helper-test-users": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/test-helper-chai": "npm:3.12.0-next.1" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" + "@webex/test-helper-test-users": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/6180efaed0dbab8f72230e0787b221b278ee6d816657ad9fdd65814c3de512e9cf543ac49aa7660c602a3bc5e80ee5ced081456e9d7313fbc6017643f0eb45d7 + checksum: 10c0/5f625971f05369f2ae1b22570383f5e6739291c66d1ac0dd44b7d2dfab19c844c0daafd6d5faa1afea3ef62656a27091a89604ab5e53479a9bc1663e5cc735ca languageName: node linkType: hard @@ -11161,14 +11227,14 @@ __metadata: languageName: node linkType: hard -"@webex/media-helpers@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/media-helpers@npm:3.12.0-next.1" +"@webex/media-helpers@npm:3.12.0-next.3": + version: 3.12.0-next.3 + resolution: "@webex/media-helpers@npm:3.12.0-next.3" dependencies: - "@webex/internal-media-core": "npm:2.23.3" + "@webex/internal-media-core": "npm:2.24.1" "@webex/ts-events": "npm:^1.1.0" "@webex/web-media-effects": "npm:2.33.5" - checksum: 10c0/f24e7c3a555df46e50745037b2810a9e0122c07091ec2fbee1deea0cbe348be243ea13bfa6a4c91f7fe48914a5893f29235a097948c7a46e8edb7acb44191a58 + checksum: 10c0/8dbfe90267737fcbca097b336a0a5771c8231d6035e65329ba2bac7ddaaba6155c4c0fbd846cf63ccd7d6155c69804746ab292311e15b92146d2fb0f749e41df languageName: node linkType: hard @@ -11254,20 +11320,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-browser@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.2" +"@webex/plugin-authorization-browser@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.15" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/plugin-authorization-node": "npm:3.12.0-next.2" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.2" - "@webex/storage-adapter-spec": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/plugin-authorization-node": "npm:3.12.0-next.15" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.15" + "@webex/storage-adapter-spec": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/9c103e69e22e6e0f58ac6f7fea1b4251fac55810041cfa930a4a70525464e55af8fd9f45ec989a3d8e0f556ee0ccc0a1285ec7e23b40d23af40a3b3e8db6c1fe + checksum: 10c0/04f15ad355e5431dcad6a545f2b1e5c6e99e734743a310107996319cbcd080f6a5d0e115e297fdf414a3aa6c421bba7f786abfbee1fd7aec60d0333796712313 languageName: node linkType: hard @@ -11297,16 +11363,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-node@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.2" +"@webex/plugin-authorization-node@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.15" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.2" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/internal-plugin-device": "npm:3.12.0-next.15" + "@webex/webex-core": "npm:3.12.0-next.15" jsonwebtoken: "npm:^9.0.0" uuid: "npm:^3.3.2" - checksum: 10c0/cadeab8eb4cf98122d8bcde2dda0fe290f4caf6042b6019a30195bdd1f7aac2d1178cd53b973593f31c4c7c4b7fcfe8fcfd8650caa3ca59b882f81e63478135b + checksum: 10c0/8bca668f4e7a17504674a02291ea699df1b350deea8cb9ab1c411e484e17ab765274227f5d1d4a9f4b2e3a4e7223739e38ec1b551780758e12900687960b87f3 languageName: node linkType: hard @@ -11330,13 +11396,13 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.2" +"@webex/plugin-authorization@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/plugin-authorization@npm:3.12.0-next.15" dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.2" - "@webex/plugin-authorization-node": "npm:3.12.0-next.2" - checksum: 10c0/ecac030f20f4fbfd1387e93379ac2c51c3f041a5aed3631990c17de5c7248ecf703a98ca558dca14dfe0b1b31cf8c25b3a03caa5845e837d5052a0da931b4faa + "@webex/plugin-authorization-browser": "npm:3.12.0-next.15" + "@webex/plugin-authorization-node": "npm:3.12.0-next.15" + checksum: 10c0/97f50fb9b111628ea46be900e6f69ca61a7220c9dcc1b66a705f452178ecb9622ebdcb6f442139da09141fc34111b503035e38ce005d5ca5adff3cb86766148c languageName: node linkType: hard @@ -11414,17 +11480,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-logger@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/plugin-logger@npm:3.12.0-next.2" +"@webex/plugin-logger@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/plugin-logger@npm:3.12.0-next.15" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/test-helper-chai": "npm:3.11.0-next.1" - "@webex/test-helper-mocha": "npm:3.11.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" + "@webex/common": "npm:3.12.0-next.1" + "@webex/test-helper-chai": "npm:3.12.0-next.1" + "@webex/test-helper-mocha": "npm:3.12.0-next.1" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" lodash: "npm:^4.17.21" - checksum: 10c0/3403014494ac83591e96a9ca0e2af8cef2c47058252969700f0f4806e4863cef94c1cee8a5b23a39e2ae241f1bf41788a464aff14f15462ce44ab686ea24244f + checksum: 10c0/3db9830fb48a66caa3ed0c9a2a3548f6bf9c3b9b07cfd0eeda0cb090c71db42399bbbfc5747848a7069a1d75cf34f072a08adf277caa76e50fc490afeeb833af languageName: node linkType: hard @@ -11752,14 +11818,14 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-local-storage@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.2" +"@webex/storage-adapter-local-storage@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.15" dependencies: - "@webex/storage-adapter-spec": "npm:3.11.0-next.1" - "@webex/test-helper-mocha": "npm:3.11.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.2" - checksum: 10c0/5cdb4040a8205ab7f485e5a00c9e344f13195d65c3f7543093c0d14a0a7aef3528104c978e9f2bf10f0b4dc0b37070b1ebed6f7b197b8d694c007dbe4bd74b9b + "@webex/storage-adapter-spec": "npm:3.12.0-next.1" + "@webex/test-helper-mocha": "npm:3.12.0-next.1" + "@webex/webex-core": "npm:3.12.0-next.15" + checksum: 10c0/211a82fcf5d06d2eb7c47c08538e4d23334354767d442b03219382aaef0d8537186850345d1b128b1f8014df82c7e06eee3bea88edeea97125fa113f83c4c5e2 languageName: node linkType: hard @@ -11790,6 +11856,15 @@ __metadata: languageName: node linkType: hard +"@webex/storage-adapter-spec@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/storage-adapter-spec@npm:3.12.0-next.1" + dependencies: + "@webex/test-helper-chai": "npm:3.12.0-next.1" + checksum: 10c0/daaf7fa3eeae4749485d1f64eba9f2c20bbdaec1870873f6b393ec0f3dc6a8728c0dbf46c8969bb49c34208ad0cf56cbc5fde0575d3e1a786e8a744be2c57ea5 + languageName: node + linkType: hard + "@webex/test-fixtures@workspace:*, @webex/test-fixtures@workspace:packages/contact-center/test-fixtures": version: 0.0.0-use.local resolution: "@webex/test-fixtures@workspace:packages/contact-center/test-fixtures" @@ -11854,6 +11929,17 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-chai@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-chai@npm:3.12.0-next.1" + dependencies: + "@webex/test-helper-file": "npm:3.12.0-next.1" + check-error: "npm:^1.0.2" + lodash: "npm:^4.17.21" + checksum: 10c0/5d8139752365ed2dc3cd4ae790b5ca542d2c689108df414082a907a8bdad674293fffe49858b7cec64bac1085579b6d55f7c3aea40edefc4d6d23f17b3b46bd1 + languageName: node + linkType: hard + "@webex/test-helper-file@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-file@npm:2.60.4" @@ -11893,6 +11979,19 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-file@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-file@npm:3.12.0-next.1" + dependencies: + "@webex/common": "npm:3.12.0-next.1" + "@webex/test-helper-make-local-url": "npm:3.12.0-next.1" + es6-promise: "npm:^4.2.8" + file-type: "npm:^16.0.1" + xhr: "npm:^2.5.0" + checksum: 10c0/6ed8abaec3268ac09dafbb9da2e639c3b3f10f01ffe619c10e78b9834caf36b1fba55d4cab031ff55f6b159be40f1c61a3f5ac6e782f1ba626e6782392485643 + languageName: node + linkType: hard + "@webex/test-helper-make-local-url@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-make-local-url@npm:2.60.4" @@ -11914,6 +12013,13 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-make-local-url@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-make-local-url@npm:3.12.0-next.1" + checksum: 10c0/13dd73a17c3d241b02affcabf831828dcef0df8850240142159ce15b0970a66ca2caf5d4c40cf54d9fde27b61abcf86d40a0895069e3f8d0f79f769f5c952ab4 + languageName: node + linkType: hard + "@webex/test-helper-mocha@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-mocha@npm:2.60.4" @@ -11941,6 +12047,15 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-mocha@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-mocha@npm:3.12.0-next.1" + dependencies: + bowser: "npm:^2.11.0" + checksum: 10c0/3df1caa81e06216abcfeee71b340684dc43c85660c6f4b1ecf7db692cd8901496aac8e9572acdd20b1b443f4330e4a1ed7f1a0ae9f94e9f7f6123011762b42f1 + languageName: node + linkType: hard + "@webex/test-helper-mock-web-socket@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-mock-web-socket@npm:2.60.4" @@ -11962,6 +12077,13 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-mock-web-socket@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-mock-web-socket@npm:3.12.0-next.1" + checksum: 10c0/a572fa41d1420a36d57fc57b9705297dff5c62fb479ef6b7433ce56102b3e331f894dad679a04ec98cf4c45f8007cbc4129a07036dc9d4f5a11b0f40a18adc5c + languageName: node + linkType: hard + "@webex/test-helper-mock-webex@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-mock-webex@npm:2.60.4" @@ -11995,6 +12117,17 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-mock-webex@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-mock-webex@npm:3.12.0-next.1" + dependencies: + ampersand-state: "npm:^5.0.3" + es6-promise: "npm:^4.2.8" + lodash: "npm:^4.17.21" + checksum: 10c0/d422edd71d6ac2031215ab479e409686526324698e96fb0baaf588047779e33fa138273c213716c4937f78b254646b74aa8fc08d663b0aeebb02439e4804f88a + languageName: node + linkType: hard + "@webex/test-helper-refresh-callback@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-refresh-callback@npm:2.60.4" @@ -12016,6 +12149,13 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-refresh-callback@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-refresh-callback@npm:3.12.0-next.1" + checksum: 10c0/3144709abb36533e76891052e8d805c8377129a099a5972f2f2dad99cff163b555d683babc5a5cb1316fe92d6ba9588e890121c67df3355e47137dab5d7829a0 + languageName: node + linkType: hard + "@webex/test-helper-retry@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-retry@npm:2.60.4" @@ -12043,6 +12183,15 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-retry@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-retry@npm:3.12.0-next.1" + dependencies: + es6-promise: "npm:^4.2.8" + checksum: 10c0/0a94cef8493b53a43c42187ca4cfa6e4fefd569203c97018b59f93c99225f9ff07e24ecce150f231e552c948789925c82abc7aa68a9a96e8aa59967f162126fc + languageName: node + linkType: hard + "@webex/test-helper-test-users@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-test-users@npm:2.60.4" @@ -12084,6 +12233,17 @@ __metadata: languageName: node linkType: hard +"@webex/test-helper-test-users@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-helper-test-users@npm:3.12.0-next.1" + dependencies: + "@webex/test-helper-retry": "npm:3.12.0-next.1" + "@webex/test-users": "npm:3.12.0-next.1" + lodash: "npm:^4.17.21" + checksum: 10c0/e4f9f44c1cc88042fa6b8b5d1f0b8379158fac09cb5cdc4abd2d5c656b0f4bc863b16baea885f0f4aaea9aa7aa8e10663268490d85528f9e0fa1d3e819d11198 + languageName: node + linkType: hard + "@webex/test-users@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-users@npm:2.60.4" @@ -12126,6 +12286,20 @@ __metadata: languageName: node linkType: hard +"@webex/test-users@npm:3.12.0-next.1": + version: 3.12.0-next.1 + resolution: "@webex/test-users@npm:3.12.0-next.1" + dependencies: + "@webex/http-core": "npm:3.12.0-next.1" + "@webex/test-helper-mocha": "npm:3.12.0-next.1" + btoa: "npm:^1.2.1" + lodash: "npm:^4.17.21" + node-random-name: "npm:^1.0.1" + uuid: "npm:^3.3.2" + checksum: 10c0/29ba5766b6c150ec3d1ec908dc4078816dedae21a773b056102fafe7ff474856573dfb53885e63475fdac3a35900827224578e24588d75127ebde5f00405ba68 + languageName: node + linkType: hard + "@webex/test-users@npm:^1.157.0": version: 1.161.0 resolution: "@webex/test-users@npm:1.161.0" @@ -12237,9 +12411,9 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.39.3": - version: 3.39.3 - resolution: "@webex/web-client-media-engine@npm:3.39.3" +"@webex/web-client-media-engine@npm:3.39.10": + version: 3.39.10 + resolution: "@webex/web-client-media-engine@npm:3.39.10" dependencies: "@webex/json-multistream": "npm:^2.4.3" "@webex/rtcstats": "npm:^1.5.5" @@ -12252,7 +12426,7 @@ __metadata: js-logger: "npm:^1.6.1" typed-emitter: "npm:^2.1.0" uuid: "npm:^8.3.2" - checksum: 10c0/a32ce03b347a0451c1ee59a79c7c3eb2408d4dafaeb2c4143918d161b8961a67c6843a9f0d9a8676ba2d15dc2369d66de30a23fe2eff909c12eab23db08db400 + checksum: 10c0/c5508660eebcb3afb922b00425d9b8a4f31576d4824e40fe6f501229c7e032996a8e16fa2f4388a278eadc67048c8c3852613e4fc9bcb23ff97d55c32b5eaed1 languageName: node linkType: hard @@ -12348,14 +12522,14 @@ __metadata: languageName: node linkType: hard -"@webex/webex-core@npm:3.12.0-next.2": - version: 3.12.0-next.2 - resolution: "@webex/webex-core@npm:3.12.0-next.2" +"@webex/webex-core@npm:3.12.0-next.15": + version: 3.12.0-next.15 + resolution: "@webex/webex-core@npm:3.12.0-next.15" dependencies: - "@webex/common": "npm:3.11.0-next.1" - "@webex/common-timers": "npm:3.11.0-next.1" - "@webex/http-core": "npm:3.11.0-next.1" - "@webex/storage-adapter-spec": "npm:3.11.0-next.1" + "@webex/common": "npm:3.12.0-next.1" + "@webex/common-timers": "npm:3.12.0-next.1" + "@webex/http-core": "npm:3.12.0-next.1" + "@webex/storage-adapter-spec": "npm:3.12.0-next.1" ampersand-collection: "npm:^2.0.2" ampersand-events: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" @@ -12364,7 +12538,7 @@ __metadata: jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/12f6906d583b838bd06f80afcadcf06da5934bcb088504a7496606b2f994cc9f2d989aecd5a7a6f8db410d5cedef82d74f2429cbe2cf334dcec780684f88f4c5 + checksum: 10c0/295175e66e511f3d63649baba8cbf325a10b4f6adeddbfbfb2a7a483ee68c25f0730ea13a5c6e3c90ec5540d4cb94cc8d90083bbff14ac7d201b5217a60dbe80 languageName: node linkType: hard