diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index aced9fa80..3440f3cc4 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -16,32 +16,22 @@ const ConsultTransferListComponent: React.FC return ( - + - - + + {title} {subtitle && ( - + {subtitle} )} - +
- +
diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts new file mode 100644 index 000000000..84c7268dc --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts @@ -0,0 +1,337 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import { + AddressBookEntry, + ContactServiceQueue, + EntryPointRecord, + ILogger, + FetchPaginatedList, + PaginatedListParams, + TransformPaginatedData, +} from '@webex/cc-store'; +import { + CategoryType, + UseConsultTransferParams, + CATEGORY_DIAL_NUMBER, + CATEGORY_ENTRY_POINT, + CATEGORY_QUEUES, + CATEGORY_AGENTS, +} from '../../task.types'; +import {debounce} from './call-control-custom.utils'; +import {DEFAULT_PAGE_SIZE} from '../../constants'; + +/** + * React hook to load, transform and manage paginated data with optional search. + * + * @template T - The item type returned by the provided `fetchFunction` (raw API/entity). + * @template U - The transformed item type stored internally and returned to consumers. + * @param fetchFunction - Fetcher that returns a paginated list of items of type T. + * @param transformFunction - Mapper that converts each T into U for UI consumption. + * @param categoryName - Human-readable name used for logging/telemetry. + * @param logger - Optional logger instance for diagnostics. + * @returns An object containing the transformed data (U[]), pagination state and helpers. + */ +export const usePaginatedData = ( + fetchFunction: FetchPaginatedList | undefined, + transformFunction: TransformPaginatedData, + categoryName: string, + logger?: ILogger +) => { + const MODULE = 'cc-components#consult-transfer-popover-hooks.ts'; + const [data, setData] = useState([]); + const [page, setPage] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + + const loadData = useCallback( + async (currentPage = 0, search = '', reset = false) => { + if (!fetchFunction) { + setData([]); + setHasMore(false); + return; + } + + setLoading(true); + try { + const apiParams: PaginatedListParams = { + page: currentPage, + pageSize: DEFAULT_PAGE_SIZE, + }; + + if (search && search.trim()) { + apiParams.search = search; + } + + logger?.info(`CC-Components: Loading ${categoryName}`, { + module: MODULE, + method: 'usePaginatedData#loadData', + }); + const response = await fetchFunction(apiParams); + + if (!response || !response.data) { + logger?.error(`CC-Components: No data received from fetch function for ${categoryName}`, { + module: MODULE, + method: 'usePaginatedData#loadData', + }); + if (reset || currentPage === 0) { + setData([]); + } + setHasMore(false); + return; + } + + logger?.info(`CC-Components: Loaded ${response.data.length} ${categoryName}`, { + module: MODULE, + method: 'usePaginatedData#loadData', + }); + + const transformedEntries = response.data.map((entry, index) => transformFunction(entry, currentPage, index)); + + if (reset || currentPage === 0) { + setData(transformedEntries); + } else { + setData((prev) => [...prev, ...transformedEntries]); + } + + const newPage = response.meta?.page ?? currentPage; + const totalPages = response.meta?.totalPages ?? 0; + + setPage(newPage); + setHasMore(totalPages > 0 && newPage < totalPages - 1); + + logger?.info('CC-Components: Pagination state updated', { + module: MODULE, + method: 'usePaginatedData#loadData', + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger?.error(`CC-Components: Error loading ${categoryName}`, { + module: MODULE, + method: 'usePaginatedData#loadData', + error: errorMessage, + }); + if (reset || currentPage === 0) { + setData([]); + } + setHasMore(false); + } finally { + setLoading(false); + } + }, + [fetchFunction, transformFunction, logger, categoryName] + ); + + const reset = useCallback(() => { + setData([]); + setPage(0); + setHasMore(true); + }, []); + + return {data, page, hasMore, loading, loadData, reset}; +}; + +export function useConsultTransferPopover({ + showDialNumberTab, + showEntryPointTab, + getAddressBookEntries, + getEntryPoints, + getQueues, + logger, +}: UseConsultTransferParams) { + const [selectedCategory, setSelectedCategory] = useState(CATEGORY_AGENTS); + const [searchQuery, setSearchQuery] = useState(''); + const loadMoreRef = useRef(null); + + const { + data: dialNumbers, + page: dialNumbersPage, + hasMore: hasMoreDialNumbers, + loading: loadingDialNumbers, + loadData: loadDialNumbers, + reset: resetDialNumbers, + } = usePaginatedData( + getAddressBookEntries, + (entry) => ({ + id: entry.id, + name: entry.name, + number: entry.number, + organizationId: entry.organizationId, + version: entry.version, + createdTime: entry.createdTime, + lastUpdatedTime: entry.lastUpdatedTime, + }), + CATEGORY_DIAL_NUMBER, + logger + ); + + const { + data: entryPoints, + page: entryPointsPage, + hasMore: hasMoreEntryPoints, + loading: loadingEntryPoints, + loadData: loadEntryPoints, + reset: resetEntryPoints, + } = usePaginatedData( + getEntryPoints, + (entry) => ({id: entry.id, name: entry.name}), + CATEGORY_ENTRY_POINT, + logger + ); + + const { + data: queuesData, + page: queuesPage, + hasMore: hasMoreQueues, + loading: loadingQueues, + loadData: loadQueues, + reset: resetQueues, + } = usePaginatedData( + getQueues, + (entry) => ({id: entry.id, name: entry.name, description: entry.description}), + CATEGORY_QUEUES, + logger + ); + + const loadNextPage = useCallback(() => { + if (!canLoadCategory(selectedCategory)) return; + const nextPage = currentPageForCategory(selectedCategory) + 1; + loadCategory(selectedCategory, nextPage, searchQuery); + }, [ + selectedCategory, + hasMoreDialNumbers, + hasMoreEntryPoints, + hasMoreQueues, + loadingDialNumbers, + loadingEntryPoints, + loadingQueues, + dialNumbersPage, + entryPointsPage, + queuesPage, + searchQuery, + loadDialNumbers, + loadEntryPoints, + loadQueues, + ]); + + const debouncedSearchRef = useRef>(); + if (!debouncedSearchRef.current) { + const triggerSearch = (query: string, category: CategoryType) => { + if (query.length === 0 || query.length >= 2) { + loadCategory(category, 0, query, true); + } + }; + debouncedSearchRef.current = debounce(triggerSearch, 500); + } + + useEffect(() => { + return () => { + debouncedSearchRef.current = undefined; + }; + }, []); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchQuery(value); + if (selectedCategory !== CATEGORY_AGENTS) { + debouncedSearchRef.current?.(value, selectedCategory); + } + }, + [selectedCategory] + ); + + const handleCategoryChange = useCallback( + (category: CategoryType) => { + setSelectedCategory(category); + setSearchQuery(''); + resetDialNumbers(); + resetEntryPoints(); + resetQueues(); + }, + [resetDialNumbers, resetEntryPoints, resetQueues] + ); + + const createCategoryClickHandler = (category: CategoryType) => () => handleCategoryChange(category); + const handleAgentsClick = createCategoryClickHandler(CATEGORY_AGENTS); + const handleQueuesClick = createCategoryClickHandler(CATEGORY_QUEUES); + const handleDialNumberClick = createCategoryClickHandler(CATEGORY_DIAL_NUMBER); + const handleEntryPointClick = createCategoryClickHandler(CATEGORY_ENTRY_POINT); + + // Helper: determines if the given category can load next page now + const canLoadCategory = (category: CategoryType): boolean => { + if (category === CATEGORY_DIAL_NUMBER) return hasMoreDialNumbers && !loadingDialNumbers; + if (category === CATEGORY_ENTRY_POINT) return hasMoreEntryPoints && !loadingEntryPoints; + if (category === CATEGORY_QUEUES) return hasMoreQueues && !loadingQueues; + return false; + }; + + // Helper: gets current page number for the given category + const currentPageForCategory = (category: CategoryType): number => { + if (category === CATEGORY_DIAL_NUMBER) return dialNumbersPage; + if (category === CATEGORY_ENTRY_POINT) return entryPointsPage; + if (category === CATEGORY_QUEUES) return queuesPage; + return 0; + }; + + // Helper: invokes appropriate loader for the given category + const loadCategory = (category: CategoryType, page: number, search: string, reset = false) => { + switch (category) { + case CATEGORY_DIAL_NUMBER: + loadDialNumbers(page, search, reset); + break; + case CATEGORY_ENTRY_POINT: + loadEntryPoints(page, search, reset); + break; + case CATEGORY_QUEUES: + loadQueues(page, search, reset); + break; + default: + break; + } + }; + + useEffect(() => { + const loadMoreElement = loadMoreRef.current; + if (!loadMoreElement) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + loadNextPage(); + } + }, + {threshold: 1.0} + ); + observer.observe(loadMoreElement); + return () => { + observer.unobserve(loadMoreElement); + }; + }, [loadNextPage]); + + useEffect(() => { + if (selectedCategory === CATEGORY_DIAL_NUMBER && showDialNumberTab && dialNumbers.length === 0) { + loadCategory(CATEGORY_DIAL_NUMBER, 0, '', true); + } else if (selectedCategory === CATEGORY_ENTRY_POINT && showEntryPointTab && entryPoints.length === 0) { + loadCategory(CATEGORY_ENTRY_POINT, 0, '', true); + } else if (selectedCategory === CATEGORY_QUEUES && queuesData.length === 0) { + loadCategory(CATEGORY_QUEUES, 0, '', true); + } + }, [selectedCategory]); + + return { + selectedCategory, + searchQuery, + loadMoreRef, + dialNumbers, + hasMoreDialNumbers, + loadingDialNumbers, + entryPoints, + hasMoreEntryPoints, + loadingEntryPoints, + queuesData, + hasMoreQueues, + loadingQueues, + handleSearchChange, + handleAgentsClick, + handleQueuesClick, + handleDialNumberClick, + handleEntryPointClick, + }; +} diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index 593456e1a..b17e2158a 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -1,143 +1,244 @@ -import React, {useState} from 'react'; -import {Text, TabListNext, TabNext, ListNext} from '@momentum-ui/react-collaboration'; +import React from 'react'; +import {Text, ListNext, TextInput, Button} from '@momentum-ui/react-collaboration'; import ConsultTransferListComponent from './consult-transfer-list-item'; import {ConsultTransferPopoverComponentProps} from '../../task.types'; import ConsultTransferEmptyState from './consult-transfer-empty-state'; +import {isAgentsEmpty, handleAgentSelection, handleQueueSelection} from './call-control-custom.utils'; +import {useConsultTransferPopover} from './consult-transfer-popover-hooks'; import { - isAgentsEmpty, - isQueuesEmpty, - handleTabSelection, - handleAgentSelection, - handleQueueSelection, - getEmptyStateMessage, -} from './call-control-custom.utils'; -import DialNumberUI from './consult-transfer-dial-number'; -import {AGENTS, DIAL_NUMBER, QUEUES} from '../../constants'; + SEARCH_PLACEHOLDER, + CLEAR_SEARCH, + SCROLL_TO_LOAD_MORE, + LOADING_MORE_QUEUES, + LOADING_MORE_DIAL_NUMBERS, + LOADING_MORE_ENTRY_POINTS, + NO_DATA_AVAILABLE_CONSULT_TRANSFER, +} from '../../constants'; const ConsultTransferPopoverComponent: React.FC = ({ heading, buttonIcon, buddyAgents, - queues, + getAddressBookEntries, + getEntryPoints, + getQueues, onAgentSelect, onQueueSelect, onDialNumberSelect, + onEntryPointSelect, allowConsultToQueue, + consultTransferOptions, logger, }) => { - const [selectedTab, setSelectedTab] = useState(AGENTS); - const filteredAgents = buddyAgents; - const filteredQueues = queues; + const {showDialNumberTab = true, showEntryPointTab = true} = consultTransferOptions || {}; + const { + selectedCategory, + searchQuery, + loadMoreRef, + dialNumbers, + hasMoreDialNumbers, + loadingDialNumbers, + entryPoints, + hasMoreEntryPoints, + loadingEntryPoints, + queuesData, + hasMoreQueues, + loadingQueues, + handleSearchChange, + handleAgentsClick, + handleQueuesClick, + handleDialNumberClick, + handleEntryPointClick, + } = useConsultTransferPopover({ + showDialNumberTab, + showEntryPointTab, + getAddressBookEntries, + getEntryPoints, + getQueues, + logger, + }); - const noAgents = isAgentsEmpty(filteredAgents); - const noQueues = isQueuesEmpty(filteredQueues); - const showTabs = true; // Always show tabs to allow dial number option + const noAgents = isAgentsEmpty(buddyAgents, logger); - const renderList = (items, getKey, getTitle, handleSelect) => ( + const renderList = ( + items: T[], + onButtonPress: (item: T) => void + ) => ( {items.map((item) => ( -
e.stopPropagation()} - style={{cursor: 'pointer', pointerEvents: 'auto'}} - > +
e.stopPropagation()} className="consult-list-item-wrapper"> handleSelect(getKey(item), getTitle(item))} + onButtonPress={() => onButtonPress(item)} logger={logger} />
))} {items.length === 0 && ( - No {selectedTab.toLowerCase()} found + No {selectedCategory.toLowerCase()} found )} ); + const noQueues = queuesData.length === 0; + const noDialNumbers = !showDialNumberTab || dialNumbers.length === 0; + const noEntryPoints = !showEntryPointTab || entryPoints.length === 0; + + const hasAnyData = !noAgents || !noQueues || !noDialNumbers || !noEntryPoints; + return (
- + {heading} - {/* Only show tabs if at least one list is available */} - {showTabs && ( - { - handleTabSelection(key as string, setSelectedTab, logger); - }} +
+ handleSearchChange(value)} + clearAriaLabel={CLEAR_SEARCH} + aria-labelledby="consult-search-label" + className="consult-search-input" + /> +
+ +
+ + + )} + {showEntryPointTab && ( + + )} +
- {/* If queues tab is selected and empty */} - {showTabs && selectedTab === QUEUES && noQueues && ( - - )} + {!hasAnyData && } - {/* Render lists if not empty */} - {showTabs && - selectedTab === AGENTS && + {selectedCategory === 'Agents' && !noAgents && renderList( - filteredAgents, - (agent) => agent.agentId, - (agent) => agent.agentName, - (id, name) => { - handleAgentSelection(id, name, onAgentSelect, logger); - } + buddyAgents.map((agent) => ({id: agent.agentId, name: agent.agentName})), + (item) => handleAgentSelection(item.id, item.name, onAgentSelect, logger) )} - {showTabs && - selectedTab === QUEUES && - !noQueues && - renderList( - filteredQueues, - (queue) => queue.id, - (queue) => queue.name, - (id, name) => { - handleQueueSelection(id, name, onQueueSelect, logger); - } - )} - {showTabs && selectedTab === DIAL_NUMBER && ( - { - onDialNumberSelect(dialNumber); - }} - logger={logger} - /> + {selectedCategory === 'Queues' && !noQueues && ( +
+ {renderList( + queuesData.map((q) => ({id: q.id, name: q.name})), + (item) => handleQueueSelection(item.id, item.name, onQueueSelect, logger) + )} + {hasMoreQueues && ( +
+ {loadingQueues ? ( + + {LOADING_MORE_QUEUES} + + ) : ( + + {SCROLL_TO_LOAD_MORE} + + )} +
+ )} +
+ )} + + {showDialNumberTab && selectedCategory === 'Dial Number' && !noDialNumbers && ( +
+ {renderList( + dialNumbers.map((d) => ({id: d.id, name: d.name, number: d.number})), + (item) => { + if (item.number) { + if (onDialNumberSelect) { + onDialNumberSelect(item.number); + } + } + } + )} + {hasMoreDialNumbers && ( +
+ {loadingDialNumbers ? ( + + {LOADING_MORE_DIAL_NUMBERS} + + ) : ( + + {SCROLL_TO_LOAD_MORE} + + )} +
+ )} +
+ )} + + {showEntryPointTab && selectedCategory === 'Entry Point' && !noEntryPoints && ( +
+ {renderList( + entryPoints.map((e) => ({id: e.id, name: e.name})), + (item) => { + if (onEntryPointSelect) { + onEntryPointSelect(item.id, item.name); + } + } + )} + {hasMoreEntryPoints && ( +
+ {loadingEntryPoints ? ( + + {LOADING_MORE_ENTRY_POINTS} + + ) : ( + + {SCROLL_TO_LOAD_MORE} + + )} +
+ )} +
)}
); diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss index c27d7c03b..f560902d8 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss @@ -71,7 +71,7 @@ .md-button-circle-wrapper.call-control-button { width: 1rem; - height: 1rem; + height: 1rem; min-width: 2rem; min-height: 2rem; padding: 0.5rem; @@ -115,8 +115,8 @@ --mdc-icon-fill-color: var(--mds-color-theme-button-primary-disabled); } .call-control-consult-button-cancel-icon { - --mdc-icon-fill-color: var(--mds-color-theme-button-primary-disabled); -} + --mdc-icon-fill-color: var(--mds-color-theme-button-primary-disabled); + } } .call-control-button-icon { @@ -139,7 +139,7 @@ .md-button-circle-wrapper.call-control-button-muted { width: 1rem; - height: 1rem; + height: 1rem; min-width: 2rem; min-height: 2rem; padding: 0.5rem; @@ -167,7 +167,6 @@ } } - .wrapup-button { display: inline-flex; justify-content: center; @@ -212,6 +211,52 @@ background-color: var(--mds-color-theme-background-secondary-hover); } } + + .consult-list-item-wrapper { + cursor: pointer; + pointer-events: auto; + } + + .consult-search-input { + width: 100%; + } + + .consult-category-buttons { + margin: 0.5rem 0; + display: flex; + flex-direction: row; + gap: 0.5rem; + flex-wrap: wrap; + } + + .consult-category-button-standard { + min-width: 5rem; + } + + .consult-category-button-wide { + min-width: 6.25rem; + } + + .consult-category-button-standard:not(.consult-category-button-active), + .consult-category-button-wide:not(.consult-category-button-active) { + background-color: transparent; + border-color: transparent; + box-shadow: none; + } + + .consult-category-button-standard:not(.consult-category-button-active):hover, + .consult-category-button-wide:not(.consult-category-button-active):hover { + background-color: var(--mds-color-theme-background-secondary-hover); + border-color: transparent; + } + + .consult-load-more { + padding: 0.5rem; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + } } .agent-tablist { @@ -245,6 +290,57 @@ pointer-events: auto; } +.call-control-list-item { + padding: 0.75rem 1rem; + margin: 0.125rem 0; + border-radius: 0.5rem; + transition: background-color 0.2s ease; +} + +.call-control-list-item-start { + margin-right: 0.75rem; + display: flex; + align-items: center; +} + +.call-control-list-item-middle { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + overflow: hidden; + padding-right: 0.5rem; +} + +.call-control-list-item-title { + margin: 0; + margin-bottom: 0.125rem; + line-height: 1.3; + font-weight: 500; + font-size: 0.875rem; + color: var(--mds-color-theme-text-primary-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.call-control-list-item-subtitle { + margin: 0; + line-height: 1.2; + font-size: 0.75rem; + color: var(--mds-color-theme-text-secondary-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.call-control-list-item-end { + display: flex; + align-items: center; + margin-left: 0.5rem; +} + .custom-text { margin: 0 !important; line-height: 1 !important; @@ -263,7 +359,7 @@ .consult-agent-name { line-height: 0; - margin-top: -8px; + margin-top: -0.5rem; } .consult-buttons-container { @@ -292,7 +388,7 @@ flex-direction: column; align-items: center; justify-content: center; - min-height: 320px; + min-height: 20rem; padding: 2rem 0 1rem 0; text-align: center; } @@ -305,14 +401,14 @@ } .consult-empty-message { - color: var(--mds-color-theme-text-secondary-normal, #000000B2); + color: var(--mds-color-theme-text-secondary-normal, #000000b2); margin-top: 1.5rem; } @media only screen and (max-width: 600px) { .agent-popover { - .agent-popover-content { - min-width: 20rem; + .agent-popover-content { + min-width: 20rem; + } } } -} \ No newline at end of file diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index aea258963..ae4214fe8 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -48,8 +48,6 @@ function CallControlComponent(props: CallControlComponentProps) { setIsRecording, buddyAgents, loadBuddyAgents, - queues, - loadQueues, transferCall, consultCall, consultInitiated, @@ -63,6 +61,10 @@ function CallControlComponent(props: CallControlComponentProps) { logger, secondsUntilAutoWrapup, cancelAutoWrapup, + getAddressBookEntries, + getEntryPoints, + getQueuesFetcher, + consultTransferOptions, } = props; useEffect(() => { @@ -159,7 +161,6 @@ function CallControlComponent(props: CallControlComponentProps) { setShowAgentMenu(true); setAgentMenuType(button.menuType as CallControlMenuType); loadBuddyAgents(); - loadQueues(); }} onHide={() => { setShowAgentMenu(false); @@ -209,11 +210,25 @@ function CallControlComponent(props: CallControlComponentProps) { heading={button.menuType} buttonIcon={button.icon} buddyAgents={buddyAgents} - queues={queues} + getAddressBookEntries={getAddressBookEntries} + getEntryPoints={getEntryPoints} + getQueues={getQueuesFetcher} onAgentSelect={(agentId, agentName) => handleTargetSelect(agentId, agentName, 'agent')} onQueueSelect={(queueId, queueName) => handleTargetSelect(queueId, queueName, 'queue')} + onEntryPointSelect={(entryPointId, entryPointName) => + handleTargetSelect(entryPointId, entryPointName, 'entryPoint') + } onDialNumberSelect={(dialNumber) => handleTargetSelect(dialNumber, dialNumber, 'dialNumber')} allowConsultToQueue={allowConsultToQueue} + consultTransferOptions={ + isTelephony + ? consultTransferOptions + : { + ...consultTransferOptions, + showDialNumberTab: false, + showEntryPointTab: false, + } + } logger={logger} /> ) : null} 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 310fdfc1d..ff01108b1 100644 --- a/packages/contact-center/cc-components/src/components/task/constants.ts +++ b/packages/contact-center/cc-components/src/components/task/constants.ts @@ -18,6 +18,16 @@ export const UNMUTE_CALL = 'Unmute'; export const DIAL_NUMBER = 'DialNumber'; export const AGENTS = 'Agents'; export const QUEUES = 'Queues'; +// Generic UI strings +export const SEARCH_PLACEHOLDER = 'Search...'; +export const CLEAR_SEARCH = 'Clear search'; +export const SCROLL_TO_LOAD_MORE = 'Scroll to load more'; +export const LOADING_MORE_QUEUES = 'Loading more queues...'; +export const LOADING_MORE_DIAL_NUMBERS = 'Loading more dial numbers...'; +export const LOADING_MORE_ENTRY_POINTS = 'Loading more entry points...'; +export const NO_DATA_AVAILABLE_CONSULT_TRANSFER = 'No data available for consult transfer.'; +// Pagination +export const DEFAULT_PAGE_SIZE = 25; // CallControlCAD constants export const NO_CUSTOMER_NAME = 'No Customer Name'; export const NO_CALLER_ID = 'No Caller ID'; 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 a96c41b06..c19ce4d2c 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 @@ -6,6 +6,9 @@ import { BuddyDetails, DestinationType, ContactServiceQueue, + AddressBookEntry, + EntryPointRecord, + FetchPaginatedList, } from '@webex/cc-store'; type Enum> = T[keyof T]; @@ -409,6 +412,20 @@ export interface ControlProps { * Function to cancel the auto wrap-up timer. */ cancelAutoWrapup: () => void; + + /** Fetch paginated address book entries for dial numbers */ + getAddressBookEntries?: FetchPaginatedList; + + /** Fetch paginated entry points */ + getEntryPoints?: FetchPaginatedList; + + /** Fetch paginated queues (filtered by media type in store) */ + getQueuesFetcher?: FetchPaginatedList; + + /** + * Options to configure consult/transfer popover behavior. + */ + consultTransferOptions?: ConsultTransferOptions; } export type CallControlComponentProps = Pick< @@ -454,6 +471,10 @@ export type CallControlComponentProps = Pick< | 'logger' | 'secondsUntilAutoWrapup' | 'cancelAutoWrapup' + | 'getAddressBookEntries' + | 'getEntryPoints' + | 'getQueuesFetcher' + | 'consultTransferOptions' >; /** @@ -509,11 +530,16 @@ export interface ConsultTransferPopoverComponentProps { heading: string; buttonIcon: string; buddyAgents: BuddyDetails[]; - queues?: ContactServiceQueue[]; + getAddressBookEntries?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; + getQueues?: FetchPaginatedList; onAgentSelect?: (agentId: string, agentName: string) => void; onQueueSelect?: (queueId: string, queueName: string) => void; + onEntryPointSelect?: (entryPointId: string, entryPointName: string) => void; onDialNumberSelect?: (dialNumber: string) => void; allowConsultToQueue: boolean; + /** Options governing popover visibility/behavior */ + consultTransferOptions?: ConsultTransferOptions; logger: ILogger; } @@ -664,3 +690,36 @@ export interface TimerUIState { iconName: string; formattedTime: string; } + +/** + * Categories displayed in Consult/Transfer popover. + */ +export type CategoryType = 'Agents' | 'Queues' | 'Dial Number' | 'Entry Point'; + +/** Category string constants to avoid typos and ease reuse */ +export const CATEGORY_DIAL_NUMBER: CategoryType = 'Dial Number'; +export const CATEGORY_ENTRY_POINT: CategoryType = 'Entry Point'; +export const CATEGORY_QUEUES: CategoryType = 'Queues'; +export const CATEGORY_AGENTS: CategoryType = 'Agents'; + +/** + * Parameters for `useConsultTransferPopover` hook. + */ +export type UseConsultTransferParams = { + showDialNumberTab: boolean; + showEntryPointTab: boolean; + getAddressBookEntries?: FetchPaginatedList; + getEntryPoints?: FetchPaginatedList; + getQueues?: FetchPaginatedList; + logger?: ILogger; +}; + +/** + * Options to configure Consult/Transfer popover behavior and visibility. + */ +export interface ConsultTransferOptions { + /** Show the Dial Number tab. Defaults to true. */ + showDialNumberTab?: boolean; + /** Show the Entry Point tab. Defaults to true. */ + showEntryPointTab?: boolean; +} diff --git a/packages/contact-center/cc-components/src/wc.ts b/packages/contact-center/cc-components/src/wc.ts index eb786815c..1553aab77 100644 --- a/packages/contact-center/cc-components/src/wc.ts +++ b/packages/contact-center/cc-components/src/wc.ts @@ -58,6 +58,7 @@ const commonPropsForCallControl: Record -
- - -
-
-`; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap index 0eba3b4df..8ed318e08 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-list-item.snapshot.tsx.snap @@ -14,6 +14,7 @@ exports[`ConsultTransferListComponent Snapshots Interactions should render compo tabindex="0" >
John Doe Manager
@@ -95,6 +95,7 @@ exports[`ConsultTransferListComponent Snapshots Interactions should render compo tabindex="0" >
John Doe Manager
@@ -176,6 +176,7 @@ exports[`ConsultTransferListComponent Snapshots Interactions should render compo tabindex="0" >
John Doe Manager
@@ -257,6 +257,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Doe Manager
@@ -338,6 +338,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Doe Manager
@@ -419,6 +419,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Doe Manager
@@ -500,6 +500,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
Alexander Benjamin Christopher Manager
@@ -581,6 +581,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Doe Senior Manager of Customer Relations and Business Development
@@ -662,6 +662,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Manager
@@ -743,6 +743,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John O'Connor & Jane Manager
@@ -824,6 +824,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Doe Manager
@@ -905,6 +905,7 @@ exports[`ConsultTransferListComponent Snapshots Rendering - Tests for UI element tabindex="0" >
John Doe
@@ -978,6 +979,7 @@ exports[`ConsultTransferListComponent Snapshots State Management should update w tabindex="0" >
John Doe Manager
@@ -1059,6 +1060,7 @@ exports[`ConsultTransferListComponent Snapshots State Management should update w tabindex="0" >
John Doe Senior Manager
@@ -1140,6 +1141,7 @@ exports[`ConsultTransferListComponent Snapshots State Management should update w tabindex="0" >
Jane Smith Manager
diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap index 9ae44f4a0..6b4be453d 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/__snapshots__/consult-transfer-popover.snapshot.tsx.snap @@ -6,77 +6,116 @@ exports[`ConsultTransferPopoverComponent Snapshots Interactions should render co > Select an Agent +
+
+
+ +
+
+
+
+
  • Agent One
  • Agent Two
    @@ -233,80 +274,509 @@ exports[`ConsultTransferPopoverComponent Snapshots Interactions should render co > Select an Agent +
    +
    +
    + +
    +
    +
    +
    +
    + + + + +
    +
    +
      +
      +
    • +
      + +
      +
      + + Queue One + +
      +
      +
      + +
      +
      +
    • +
      +
    +
    +
    +`; + +exports[`ConsultTransferPopoverComponent Snapshots Interactions should render component after switching to queues tab 1`] = ` +
    + + Select an Agent + +
    +
    +
    + +
    +
    +
    +
    +
    + + + + +
    +
    +
      +
      +
    • +
      + +
      +
      + + Queue One + +
      +
      +
      + +
      +
      +
    • +
      +
    +
    +
    +`; + +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render the component with heading and category buttons 1`] = ` +
    + + Select an Agent + +
    +
    +
    + +
    +
    +
    +
    +
    • - Queue One + Agent One
      - +
    • - Queue Two + Agent Two
      - +
      @@ -446,86 +926,125 @@ exports[`ConsultTransferPopoverComponent Snapshots Interactions should render co
    • `; -exports[`ConsultTransferPopoverComponent Snapshots Interactions should render component after switching to queues tab 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with agents having different states 1`] = `
      Select an Agent +
      +
      +
      + +
      +
      +
      +
      +
      • - Queue One + Available Agent
        - +
      • - Queue Two + Busy Agent
        - + + +
        +
        +
      • +
        +
        +
      • +
        + +
        +
        + + Idle Agent + +
        +
        +
        +
        @@ -665,83 +1269,122 @@ exports[`ConsultTransferPopoverComponent Snapshots Interactions should render co
      • `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render the component with tabs and agents 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with allowConsultToQueue false 1`] = `
        Select an Agent +
        +
        +
        + +
        +
        +
        +
        +
        • Agent One
          @@ -815,7 +1459,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
        • Agent Two
          @@ -892,920 +1537,115 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
          `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with agents having different states 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with custom empty message 1`] = `
          Select an Agent +
          +
          +
          + +
          +
          +
          +
          -
          -
            -
            -
          • -
            - -
            -
            - - Available Agent - -
            -
            -
            - -
            -
            -
          • -
            -
            -
          • -
            - -
            -
            - - Busy Agent - -
            -
            -
            - -
            -
            -
          • -
            -
            -
          • -
            - -
            -
            - - Idle Agent - -
            -
            -
            - -
            -
            -
          • -
            -
          -
          -`; - -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with allowConsultToQueue false 1`] = ` -
          - - Select an Agent - -
          - - - -
          -
            -
            -
          • -
            - -
            -
            - - Agent One - -
            -
            -
            - -
            -
            -
          • -
            -
            -
          • -
            - -
            -
            - - Agent Two - -
            -
            -
            - -
            -
            -
          • -
            -
          -
          -`; - -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with custom empty message 1`] = ` -
          - - Select an Agent - -
          - - - -
          -
          -
          -
          - We can't find any agent available for now. -
          -
          -
          -`; - -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with different heading 1`] = ` -
          - - Choose Transfer Target - -
          - - - -
          -
            -
            -
          • -
            - -
            -
            - - Agent One - -
            -
            -
            - -
            -
            -
          • -
            -
            -
          • -
            - -
            -
            - - Agent Two - -
            -
            -
            - -
            -
            -
          • -
            -
          -
          -`; - -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with empty agents list 1`] = ` -
          - - Select an Agent - -
          - -
          - We can't find any agent available for now. + No data available for consult transfer.
          `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with empty queues list 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with different heading 1`] = `
          - Select an Agent + Choose Transfer Target +
          +
          +
          + +
          +
          +
          +
          +
          • Agent One
            @@ -1973,7 +1853,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
          • Agent Two
            @@ -2050,313 +1931,365 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
            `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with single agent 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with empty agents list 1`] = `
            Select an Agent +
            +
            +
            + +
            +
            +
            +
            +
            -
              +
              -
            • -
              - -
              -
              - - Single Agent - -
              -
              -
              - -
              -
              -
            • + No data available for consult transfer.
              -
            +
            `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with single queue 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with empty queues list 1`] = `
            Select an Agent +
            +
            +
            + +
            +
            +
            +
            -
            -
              -
              -
            • -
              - -
              -
              - - Agent One - -
              -
              -
              - -
              -
              -
            • + Entry Point + + +
              +
            +`; + +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with single agent 1`] = ` +
            + + Select an Agent + +
            +
            +
            + +
            +
            +
            +
            + + + + +
            +
            • - Agent Two + Single Agent
              @@ -2430,83 +2364,317 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
              `; -exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render without tabs when showTabs is false 1`] = ` +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render with single queue 1`] = ` +
              + + Select an Agent + +
              +
              +
              + +
              +
              +
              +
              +
              + + + + +
              +
              +
                +
                +
              • +
                + +
                +
                + + Single Queue + +
                +
                +
                + +
                +
                +
              • +
                +
              +
              +
              +`; + +exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component should render without errors when rendered with minimal props 1`] = `
              Select an Agent +
              +
              +
              + +
              +
              +
              +
              +
              • Agent One
                @@ -2580,7 +2749,7 @@ exports[`ConsultTransferPopoverComponent Snapshots Rendering - Tests for UI elem
              • Agent Two
                @@ -2663,77 +2833,116 @@ exports[`ConsultTransferPopoverComponent Snapshots State Management should updat > Select an Agent +
                +
                +
                + +
                +
                +
                +
                +
                • New Agent One
                  @@ -2812,77 +3022,116 @@ exports[`ConsultTransferPopoverComponent Snapshots State Management should updat > New Heading +
                  +
                  +
                  + +
                  +
                  +
                  +
                  +
                  • Agent One
                    @@ -2956,7 +3206,7 @@ exports[`ConsultTransferPopoverComponent Snapshots State Management should updat
                  • Agent Two
                    @@ -3039,223 +3290,191 @@ exports[`ConsultTransferPopoverComponent Snapshots State Management should updat > Select an Agent +
                    +
                    +
                    + +
                    +
                    +
                    +
                    -
                    -
                      -
                      -
                    • -
                      - -
                      -
                      - - Agent One - -
                      -
                      + +
                      +
                      +
                        +
                        +
                      • - + +
                      • -
                      -
                    • -
                      -
                      -
                    • -
                      -
                      -
                      - - Agent Two - -
                      -
                      - + +
                      -
                    • - -
                    -
                  +
                • +
                  +
                +
                `; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx index c89750dea..d7e457f9c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx @@ -1,5 +1,6 @@ import '@testing-library/jest-dom'; -import {BuddyDetails, ContactServiceQueue} from '@webex/cc-store'; +import {BuddyDetails} from '@webex/cc-store'; +import {mockAgents, mockQueueDetails} from '@webex/test-fixtures'; import { createConsultButtons, getVisibleButtons, @@ -41,76 +42,6 @@ const loggerMock = { trace: jest.fn(), }; -const mockBuddyAgents: BuddyDetails[] = [ - { - agentId: 'agent1', - agentName: 'John Doe', - state: 'Available', - teamId: 'team1', - dn: 'dn1', - siteId: 'site1', - }, - { - agentId: 'agent2', - agentName: 'Jane Smith', - state: 'Available', - teamId: 'team1', - dn: 'dn2', - siteId: 'site1', - }, - { - agentId: 'agent3', - agentName: '', - state: 'Available', - teamId: 'team1', - dn: 'dn3', - siteId: 'site1', - }, - { - agentId: '', - agentName: 'Invalid Agent', - state: 'Available', - teamId: 'team1', - dn: 'dn4', - siteId: 'site1', - }, -]; - -const mockQueues: ContactServiceQueue[] = [ - { - id: 'queue1', - name: 'Support Queue', - description: 'Support Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue, - { - id: 'queue2', - name: 'Sales Queue', - description: 'Sales Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue, - { - id: 'queue3', - name: '', - description: 'Empty Name Queue', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue, - { - id: '', - name: 'Invalid Queue', - description: 'Invalid Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue, -]; - describe('Call Control Custom Utils', () => { beforeEach(() => { jest.clearAllMocks(); @@ -418,15 +349,39 @@ describe('Call Control Custom Utils', () => { describe('shouldShowTabs', () => { it('should return true when agents exist', () => { - expect(shouldShowTabs(mockBuddyAgents, [])).toBe(true); + expect( + shouldShowTabs( + mockAgents.map((a) => ({ + agentId: a.agentId, + agentName: a.agentName, + state: a.state, + teamId: a.teamId, + dn: a.dn, + siteId: a.siteId, + })), + [] + ) + ).toBe(true); }); it('should return true when queues exist', () => { - expect(shouldShowTabs([], mockQueues)).toBe(true); + expect(shouldShowTabs([], mockQueueDetails)).toBe(true); }); it('should return true when both exist', () => { - expect(shouldShowTabs(mockBuddyAgents, mockQueues)).toBe(true); + expect( + shouldShowTabs( + mockAgents.map((a) => ({ + agentId: a.agentId, + agentName: a.agentName, + state: a.state, + teamId: a.teamId, + dn: a.dn, + siteId: a.siteId, + })), + mockQueueDetails + ) + ).toBe(true); }); it('should return false when both are empty', () => { @@ -440,7 +395,18 @@ describe('Call Control Custom Utils', () => { describe('isAgentsEmpty', () => { it('should return false when agents exist', () => { - expect(isAgentsEmpty(mockBuddyAgents)).toBe(false); + expect( + isAgentsEmpty( + mockAgents.map((a) => ({ + agentId: a.agentId, + agentName: a.agentName, + state: a.state, + teamId: a.teamId, + dn: a.dn, + siteId: a.siteId, + })) + ) + ).toBe(false); }); it('should return true when agents array is empty', () => { @@ -458,7 +424,7 @@ describe('Call Control Custom Utils', () => { describe('isQueuesEmpty', () => { it('should return false when queues exist', () => { - expect(isQueuesEmpty(mockQueues)).toBe(false); + expect(isQueuesEmpty(mockQueueDetails)).toBe(false); }); it('should return true when queues array is empty', () => { @@ -561,11 +527,20 @@ describe('Call Control Custom Utils', () => { describe('createAgentListData', () => { it('should transform buddy agents to list data', () => { - const result = createAgentListData(mockBuddyAgents); + const result = createAgentListData( + mockAgents.map((a) => ({ + agentId: a.agentId, + agentName: a.agentName, + state: a.state, + teamId: a.teamId, + dn: a.dn, + siteId: a.siteId, + })) + ); - expect(result).toHaveLength(4); - expect(result[0]).toEqual({id: 'agent1', name: 'John Doe'}); - expect(result[1]).toEqual({id: 'agent2', name: 'Jane Smith'}); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({id: mockAgents[0].agentId, name: mockAgents[0].agentName}); + expect(result[1]).toEqual({id: mockAgents[1].agentId, name: mockAgents[1].agentName}); }); it('should handle empty array', () => { @@ -576,11 +551,11 @@ describe('Call Control Custom Utils', () => { describe('createQueueListData', () => { it('should transform queues to list data', () => { - const result = createQueueListData(mockQueues); + const result = createQueueListData(mockQueueDetails); - expect(result).toHaveLength(4); - expect(result[0]).toEqual({id: 'queue1', name: 'Support Queue'}); - expect(result[1]).toEqual({id: 'queue2', name: 'Sales Queue'}); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({id: mockQueueDetails[0].id, name: mockQueueDetails[0].name}); + expect(result[1]).toEqual({id: mockQueueDetails[1].id, name: mockQueueDetails[1].name}); }); it('should handle empty array', () => { @@ -800,55 +775,19 @@ describe('Call Control Custom Utils', () => { describe('isQueueAvailable', () => { it('should return true for valid queue', () => { - expect( - isQueueAvailable({ - id: 'queue1', - name: 'Support Queue', - description: 'Support Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue) - ).toBe(true); + expect(isQueueAvailable({...mockQueueDetails[0], id: 'queue1', name: 'Support Queue'})).toBe(true); }); it('should return false for missing id', () => { - expect( - isQueueAvailable({ - id: '', - name: 'Support Queue', - description: 'Support Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue) - ).toBeFalsy(); + expect(isQueueAvailable({...mockQueueDetails[0], id: '', name: 'Support Queue'})).toBeFalsy(); }); it('should return false for missing name', () => { - expect( - isQueueAvailable({ - id: 'queue1', - name: '', - description: 'Support Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue) - ).toBeFalsy(); + expect(isQueueAvailable({...mockQueueDetails[0], id: 'queue1', name: ''})).toBeFalsy(); }); it('should return false for whitespace-only name', () => { - expect( - isQueueAvailable({ - id: 'queue1', - name: ' ', - description: 'Support Queue Description', - queueType: 'inbound', - checkAgentAvailability: true, - channelType: 'telephony', - } as ContactServiceQueue) - ).toBeFalsy(); + expect(isQueueAvailable({...mockQueueDetails[0], id: 'queue1', name: ' '})).toBeFalsy(); }); it('should return false for null queue', () => { @@ -862,10 +801,23 @@ describe('Call Control Custom Utils', () => { describe('filterAvailableAgents', () => { it('should filter out invalid agents', () => { - const result = filterAvailableAgents(mockBuddyAgents); + const result = filterAvailableAgents( + mockAgents.map((a) => ({ + agentId: a.agentId, + agentName: a.agentName, + state: a.state, + teamId: a.teamId, + dn: a.dn, + siteId: a.siteId, + })) + ); expect(result).toHaveLength(2); - expect(result[0]).toEqual(expect.objectContaining({agentId: 'agent1', agentName: 'John Doe'})); - expect(result[1]).toEqual(expect.objectContaining({agentId: 'agent2', agentName: 'Jane Smith'})); + expect(result[0]).toEqual( + expect.objectContaining({agentId: mockAgents[0].agentId, agentName: mockAgents[0].agentName}) + ); + expect(result[1]).toEqual( + expect.objectContaining({agentId: mockAgents[1].agentId, agentName: mockAgents[1].agentName}) + ); }); it('should return empty array for null input', () => { @@ -879,10 +831,10 @@ describe('Call Control Custom Utils', () => { describe('filterAvailableQueues', () => { it('should filter out invalid queues', () => { - const result = filterAvailableQueues(mockQueues); + const result = filterAvailableQueues(mockQueueDetails); expect(result).toHaveLength(2); - expect(result[0]).toEqual(expect.objectContaining({id: 'queue1', name: 'Support Queue'})); - expect(result[1]).toEqual(expect.objectContaining({id: 'queue2', name: 'Sales Queue'})); + expect(result[0]).toEqual(expect.objectContaining({id: mockQueueDetails[0].id, name: mockQueueDetails[0].name})); + expect(result[1]).toEqual(expect.objectContaining({id: mockQueueDetails[1].id, name: mockQueueDetails[1].name})); }); it('should return empty array for null input', () => { diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-dial-number.snapshot.test.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-dial-number.snapshot.test.tsx deleted file mode 100644 index 64a7f6461..000000000 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-dial-number.snapshot.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import {render} from '@testing-library/react'; -import DialNumberUI from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-dial-number'; - -describe('DialNumberUI snapshot', () => { - const loggerMock = { - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - trace: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }; - const onButtonPressMock = jest.fn(); - const defaultProps = { - title: 'Dial Number', - buttonIcon: 'call', - onButtonPress: onButtonPressMock, - logger: loggerMock, - }; - - it('matches snapshot', () => { - const {container} = render(); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index 95603e202..11652b518 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -67,36 +67,23 @@ describe('CallControlListItemPresentational', () => { expect(initialsSpan).toHaveTextContent('JD'); expect(initialsSpan).toHaveAttribute('aria-hidden', 'true'); - // Verify middle section with title and subtitle + // Verify middle section with title and subtitle (class-based layout) const middleSection = screen.container.querySelector('[data-position="middle"]'); expect(middleSection).toBeInTheDocument(); - expect(middleSection).toHaveStyle({ - flex: '1', - display: 'flex', - flexDirection: 'column', - marginLeft: '8px', - minWidth: '0', - overflow: 'hidden', - }); + expect(middleSection).toHaveClass('call-control-list-item-middle'); // Verify title text - const titleElements = screen.container.querySelectorAll('mdc-text'); - expect(titleElements[0]).toHaveTextContent('John Doe'); - expect(titleElements[0]).toHaveAttribute('tagname', 'p'); - expect(titleElements[0]).toHaveAttribute('type', 'body-large-regular'); - expect(titleElements[0]).toHaveStyle({ - margin: '0px', - lineHeight: '1.2', - }); + const textElements = screen.container.querySelectorAll('mdc-text'); + expect(textElements[0]).toHaveTextContent('John Doe'); + expect(textElements[0]).toHaveAttribute('tagname', 'div'); + expect(textElements[0]).toHaveAttribute('type', 'body-large-regular'); + expect(textElements[0]).toHaveClass('call-control-list-item-title'); // Verify subtitle text - expect(titleElements[1]).toHaveTextContent('Manager'); - expect(titleElements[1]).toHaveAttribute('tagname', 'p'); - expect(titleElements[1]).toHaveAttribute('type', 'body-midsize-regular'); - expect(titleElements[1]).toHaveStyle({ - margin: '0px', - lineHeight: '1.2', - }); + expect(textElements[1]).toHaveTextContent('Manager'); + expect(textElements[1]).toHaveAttribute('tagname', 'div'); + expect(textElements[1]).toHaveAttribute('type', 'body-midsize-regular'); + expect(textElements[1]).toHaveClass('call-control-list-item-subtitle'); // Verify end section with button const endSection = screen.container.querySelector('[data-position="end"]'); @@ -110,7 +97,7 @@ describe('CallControlListItemPresentational', () => { const button = screen.container.querySelector('button'); expect(button).toBeInTheDocument(); expect(button).toHaveAttribute('data-color', 'join'); - expect(button).toHaveAttribute('data-size', '28'); + expect(button).toHaveAttribute('data-size', '32'); expect(button).toHaveAttribute('data-disabled', 'false'); expect(button).toHaveAttribute('tabindex', '-1'); expect(button).toHaveAttribute('type', 'button'); @@ -141,8 +128,8 @@ describe('CallControlListItemPresentational', () => { const screen = await render(); // Should only have one mdc-text element (title only) - const textElements = screen.container.querySelectorAll('mdc-text'); - expect(textElements).toHaveLength(1); - expect(textElements[0]).toHaveTextContent('John Doe'); + const titleOnlyElements = screen.container.querySelectorAll('mdc-text'); + expect(titleOnlyElements).toHaveLength(1); + expect(titleOnlyElements[0]).toHaveTextContent('John Doe'); }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx index baf2dc497..57b1cfa23 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import {render, fireEvent, act} from '@testing-library/react'; +import {render, fireEvent, act, waitFor} from '@testing-library/react'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; import {ContactServiceQueue} from '@webex/cc-store'; @@ -29,6 +29,32 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const mockOnAgentSelect = jest.fn(); const mockOnQueueSelect = jest.fn(); + const buildQueue = (id: string, name: string, description: string = 'Queue'): ContactServiceQueue => ({ + organizationId: 'org-test', + id, + version: 1, + name, + description, + queueType: 'INBOUND', + checkAgentAvailability: true, + channelType: 'TELEPHONY', + serviceLevelThreshold: 20, + maxActiveContacts: 25, + maxTimeInQueue: 600, + defaultMusicInQueueMediaFileId: 'media-1', + active: true, + monitoringPermitted: true, + parkingPermitted: true, + recordingPermitted: true, + recordingAllCallsPermitted: true, + pauseRecordingPermitted: true, + controlFlowScriptUrl: 'https://example.com/flow', + ivrRequeueUrl: 'https://example.com/requeue', + routingType: 'LONGEST_AVAILABLE_AGENT', + queueRoutingType: 'TEAM_BASED', + callDistributionGroups: [], + }); + const defaultProps = { heading: 'Select an Agent', buttonIcon: 'agent-icon', @@ -50,16 +76,11 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { siteId: 'site1', }, ], - queues: [ - {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, - {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, - ], + getQueues: async () => ({data: [buildQueue('queue1', 'Queue One')], meta: {page: 0, totalPages: 1}}), onAgentSelect: mockOnAgentSelect, onQueueSelect: mockOnQueueSelect, allowConsultToQueue: true, logger: mockLogger, - showTabs: true, - emptyMessage: 'No agents or queues available', }; beforeEach(() => { @@ -67,26 +88,29 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { }); describe('Rendering - Tests for UI elements and visual states of ConsultTransferPopoverComponent component', () => { - it('should render the component with tabs and agents', async () => { + it('should render the component with heading and category buttons', async () => { let screen; await act(async () => { screen = render(); }); - + expect(screen.getByText('Select an Agent')).toBeInTheDocument(); + const btns = Array.from(screen.container.querySelectorAll('button')).map( + (b) => (b as HTMLButtonElement).textContent + ); + expect(btns).toEqual(expect.arrayContaining(['Agents', 'Queues', 'Dial Number', 'Entry Point'])); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); - it('should render without tabs when showTabs is false', async () => { - const noTabsProps = {...defaultProps, showTabs: false}; + it('should render without errors when rendered with minimal props', async () => { let screen; await act(async () => { - screen = render(); + screen = render(); }); - const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); + expect(container).toBeInTheDocument(); expect(container).toMatchSnapshot(); }); @@ -96,7 +120,7 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { await act(async () => { screen = render(); }); - + expect(screen.getByText('Choose Transfer Target')).toBeInTheDocument(); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); @@ -108,19 +132,30 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { await act(async () => { screen = render(); }); - + const emptyState = screen.container.querySelector('.consult-empty-message'); + expect(emptyState?.textContent).toBe('No data available for consult transfer.'); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); it('should render with empty queues list', async () => { - const emptyQueuesProps = {...defaultProps, queues: []}; + const emptyQueuesProps = { + ...defaultProps, + getQueues: async () => ({data: [], meta: {page: 0, totalPages: 0}}), + }; let screen; await act(async () => { screen = render(); }); + const queuesButton = Array.from(screen.container.querySelectorAll('button')).find( + (btn) => (btn as HTMLButtonElement).textContent === 'Queues' + ) as HTMLButtonElement | undefined; + if (queuesButton) fireEvent.click(queuesButton); + const list = screen.container.querySelector('.agent-list'); + expect(list).toBeNull(); + expect(screen.container.querySelectorAll('.call-control-list-item').length).toBe(0); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); @@ -133,6 +168,12 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { screen = render(); }); + const queuesButton = Array.from(screen.container.querySelectorAll('button')).find((btn) => { + const el = btn as HTMLButtonElement; + return el && el.textContent === 'Queues'; + }) as HTMLButtonElement | undefined; + expect(queuesButton).toBeDefined(); + expect(queuesButton?.disabled).toBe(true); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); @@ -142,14 +183,16 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { const customEmptyProps = { ...defaultProps, buddyAgents: [], - queues: [], - emptyMessage: 'Custom empty state message', + getQueues: async () => ({data: [], meta: {page: 0, totalPages: 0}}), }; let screen; await act(async () => { screen = render(); }); + const emptyState = screen.container.querySelector('.consult-empty-state'); + expect(emptyState).toBeInTheDocument(); + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); @@ -174,6 +217,8 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { screen = render(); }); + const singleAgentItem = screen.container.querySelector('[aria-label="Single Agent"]'); + expect(singleAgentItem).toBeInTheDocument(); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); @@ -182,13 +227,22 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { it('should render with single queue', async () => { const singleQueueProps = { ...defaultProps, - queues: [{id: 'queue1', name: 'Single Queue'} as ContactServiceQueue], + getQueues: async () => ({ + data: [buildQueue('queue1', 'Single Queue')], + meta: {page: 0, totalPages: 1}, + }), }; let screen; await act(async () => { screen = render(); }); + const queuesButton = Array.from(screen.container.querySelectorAll('button')).find((btn) => { + const el = btn as HTMLButtonElement; + return el && el.textContent === 'Queues'; + }) as HTMLButtonElement | undefined; + if (queuesButton) fireEvent.click(queuesButton); + await waitFor(() => expect(screen.container.querySelector('[aria-label="Single Queue"]')).toBeInTheDocument()); const container = screen.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); @@ -237,52 +291,62 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { describe('Interactions', () => { it('should render component after switching to queues tab', async () => { - let screen; + let renderResult; await act(async () => { - screen = render(); + renderResult = render(); }); - const queueTab = screen.container.querySelector('.queue-tab'); - fireEvent.click(queueTab); + const queuesButton1 = Array.from(renderResult.container.querySelectorAll('button')).find((btn) => { + const el = btn as HTMLButtonElement; + return el && el.textContent === 'Queues'; + }) as HTMLButtonElement | undefined; + if (queuesButton1) { + fireEvent.click(queuesButton1); + } - const container = screen.container.querySelector('.agent-popover-content'); + await waitFor(() => expect(renderResult.container.querySelector('[aria-label="Queue One"]')).toBeInTheDocument()); + const container = renderResult.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); it('should render component after agent selection', async () => { - let screen; + let renderResult; await act(async () => { - screen = render(); + renderResult = render(); }); - const agentItems = screen.container.querySelectorAll('.call-control-list-item'); - if (agentItems.length > 0) { - fireEvent.click(agentItems[0]); - } - - const container = screen.container.querySelector('.agent-popover-content'); + const firstAgentButton = renderResult.container.querySelectorAll('.call-control-list-item button')[0]; + if (firstAgentButton) fireEvent.click(firstAgentButton); + expect(mockOnAgentSelect).toHaveBeenCalled(); + const container = renderResult.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); it('should render component after queue selection', async () => { - let screen; + let renderResult; await act(async () => { - screen = render(); + renderResult = render(); }); // Switch to queues tab first - const queueTab = screen.container.querySelector('.queue-tab'); - fireEvent.click(queueTab); - - // Then click on a queue - const queueItems = screen.container.querySelectorAll('.call-control-list-item'); - if (queueItems.length > 0) { - fireEvent.click(queueItems[0]); + const queuesButton2 = Array.from(renderResult.container.querySelectorAll('button')).find((btn) => { + const el = btn as HTMLButtonElement; + return el && el.textContent === 'Queues'; + }) as HTMLButtonElement | undefined; + if (queuesButton2) { + fireEvent.click(queuesButton2); } - const container = screen.container.querySelector('.agent-popover-content'); + // Then click on a queue's action button + await waitFor(() => + expect(renderResult.container.querySelectorAll('.call-control-list-item button').length).toBeGreaterThan(0) + ); + const firstQueueButton = renderResult.container.querySelectorAll('.call-control-list-item button')[0]; + if (firstQueueButton) fireEvent.click(firstQueueButton); + expect(mockOnQueueSelect).toHaveBeenCalled(); + const container = renderResult.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); @@ -290,21 +354,22 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { describe('State Management', () => { it('should update when heading changes', async () => { - let screen; + let renderResult; await act(async () => { - screen = render(); + renderResult = render(); }); - screen.rerender(); - const container = screen.container.querySelector('.agent-popover-content'); + renderResult.rerender(); + expect(renderResult.getByText('New Heading')).toBeInTheDocument(); + const container = renderResult.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); it('should update when agents list changes', async () => { - let screen; + let renderResult; await act(async () => { - screen = render(); + renderResult = render(); }); const newAgents = [ @@ -317,21 +382,38 @@ describe('ConsultTransferPopoverComponent Snapshots', () => { siteId: 'site2', }, ]; - screen.rerender(); - const container = screen.container.querySelector('.agent-popover-content'); + renderResult.rerender(); + const updated = renderResult.container.querySelector('[aria-label="New Agent One"]'); + expect(updated).toBeInTheDocument(); + const container = renderResult.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); it('should update when queues list changes', async () => { - let screen; + let renderResult; await act(async () => { - screen = render(); + renderResult = render(); }); - const newQueues = [{id: 'newQueue1', name: 'New Queue One'} as ContactServiceQueue]; - screen.rerender(); - const container = screen.container.querySelector('.agent-popover-content'); + const newQueues = [buildQueue('newQueue1', 'New Queue One')]; + renderResult.rerender( + ({data: newQueues, meta: {page: 0, totalPages: 1}})} + /> + ); + const queuesButton = Array.from(renderResult.container.querySelectorAll('button')).find((btn) => { + const el = btn as HTMLButtonElement; + return el && el.textContent === 'Queues'; + }) as HTMLButtonElement | undefined; + if (queuesButton) fireEvent.click(queuesButton); + await waitFor(() => + expect(renderResult.container.querySelector('[aria-label="New Queue One"]').textContent).toBeDefined() + ); + const newQueue = renderResult.container.querySelector('[aria-label="New Queue One"]'); + expect(newQueue).toBeInTheDocument(); + const container = renderResult.container.querySelector('.agent-popover-content'); mockUIDProps(container); expect(container).toMatchSnapshot(); }); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index b8dab3d96..704c816f7 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import {render, fireEvent} from '@testing-library/react'; +import {render, fireEvent, waitFor, act} from '@testing-library/react'; import '@testing-library/jest-dom'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; import {ContactServiceQueue} from '@webex/cc-store'; -import * as utils from '../../../../..//src/components/task/CallControl/CallControlCustom/call-control-custom.utils'; +// hooks import no longer needed in this test +import * as utils from '../../../../../src/components/task/CallControl/CallControlCustom/call-control-custom.utils'; +import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; const loggerMock = { log: jest.fn(), @@ -13,12 +15,14 @@ const loggerMock = { error: jest.fn(), }; +let consoleErrorSpy: jest.SpyInstance; + beforeAll(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); afterAll(() => { - (console.error as jest.Mock).mockRestore(); + consoleErrorSpy.mockRestore(); }); // This test suite was previously skipped but is now enabled for 100% coverage @@ -46,16 +50,17 @@ describe('ConsultTransferPopoverComponent', () => { siteId: 'site1', }, ], - queues: [ - {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, - {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, - ], + getQueues: async () => ({ + data: [ + {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, + {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, + ], + meta: {page: 0, totalPages: 1}, + }), onAgentSelect: mockOnAgentSelect, onQueueSelect: mockOnQueueSelect, allowConsultToQueue: true, logger: loggerMock, - showTabs: true, - emptyMessage: 'No agents or queues available', }; beforeEach(() => { @@ -77,26 +82,20 @@ describe('ConsultTransferPopoverComponent', () => { expect(heading).toHaveAttribute('type', 'body-large-bold'); // Verify tabs container - const tabList = screen.container.querySelector('.agent-tablist'); - expect(tabList).toBeInTheDocument(); - expect(tabList).toHaveAttribute('role', 'tablist'); - expect(tabList).toHaveAttribute('aria-label', 'Tabs'); - expect(tabList).toHaveAttribute('data-orientation', 'horizontal'); + const buttons = Array.from(screen.container.querySelectorAll('button')).map( + (b) => (b as HTMLButtonElement).textContent + ); + expect(buttons).toEqual(expect.arrayContaining(['Agents', 'Queues', 'Dial Number', 'Entry Point'])); // Verify Agents tab (active by default) - const agentTab = screen.container.querySelector('.agent-tab'); - expect(agentTab).toBeInTheDocument(); - expect(agentTab).toHaveAttribute('role', 'tab'); - expect(agentTab).toHaveAttribute('data-active', 'true'); - expect(agentTab).toHaveTextContent('Agents'); + const agentsButton = screen.getByRole('button', {name: 'Agents'}); + expect(agentsButton).toBeInTheDocument(); + expect(agentsButton).toHaveTextContent('Agents'); // Verify Queues tab (inactive by default) - const queueTab = screen.container.querySelector('.queue-tab'); - expect(queueTab).toBeInTheDocument(); - expect(queueTab).toHaveAttribute('role', 'tab'); - expect(queueTab).toHaveAttribute('data-active', 'false'); - expect(queueTab).toHaveAttribute('tabindex', '-1'); - expect(queueTab).toHaveTextContent('Queues'); + const queuesButton = screen.getByRole('button', {name: 'Queues'}); + expect(queuesButton).toBeInTheDocument(); + expect(queuesButton).toHaveTextContent('Queues'); // Verify agent list const agentList = screen.container.querySelector('.agent-list'); @@ -108,8 +107,8 @@ describe('ConsultTransferPopoverComponent', () => { expect(listItems[0]).toHaveTextContent('Agent One'); expect(listItems[1]).toHaveTextContent('Agent Two'); - // Verify list item containers have correct styling - const listItemContainers = screen.container.querySelectorAll('[style*="cursor: pointer"]'); + // Verify list item wrappers render + const listItemContainers = screen.container.querySelectorAll('.consult-list-item-wrapper'); expect(listItemContainers).toHaveLength(2); }); @@ -122,72 +121,121 @@ describe('ConsultTransferPopoverComponent', () => { expect(mockOnAgentSelect).toHaveBeenCalledWith('agent1', 'Agent One'); // Test onMouseDown event handler (covers line 39) - just trigger the event - const listItemContainer = screen.container.querySelector('[style*="cursor: pointer"]'); + const listItemContainer = screen.container.querySelector('.consult-list-item-wrapper'); fireEvent.mouseDown(listItemContainer!); // Test tab switching - const queueTab = screen.container.querySelector('.queue-tab'); - fireEvent.click(queueTab!); + fireEvent.click(screen.getByText('Queues')); // Test queue selection after switching tabs - click on the button inside the first queue item + await waitFor(() => + expect(screen.container.querySelectorAll('.call-control-list-item button').length).toBeGreaterThan(0) + ); const firstQueueButton = screen.container.querySelectorAll('.call-control-list-item button')[0]; fireEvent.click(firstQueueButton); expect(mockOnQueueSelect).toHaveBeenCalledWith('queue1', 'Queue One'); }); - it('handles edge cases and conditional rendering', async () => { - // Test empty state when both lists are completely empty + it('hides Dial Number tab when consultTransferOptions.showDialNumberTab is false', async () => { + const screen = await render( + + ); + + const buttons = Array.from(screen.container.querySelectorAll('button')).map( + (b) => (b as HTMLButtonElement).textContent + ); + + expect(buttons).toEqual(expect.arrayContaining(['Agents', 'Queues'])); + expect(buttons).not.toEqual(expect.arrayContaining(['Dial Number'])); + }); + + it('hides Entry Point tab when consultTransferOptions.showEntryPointTab is false', async () => { + const screen = await render( + + ); + + const buttons = Array.from(screen.container.querySelectorAll('button')).map( + (b) => (b as HTMLButtonElement).textContent + ); + + expect(buttons).toEqual(expect.arrayContaining(['Agents', 'Queues', 'Dial Number'])); + expect(buttons).not.toEqual(expect.arrayContaining(['Entry Point'])); + }); + + it('hides both tabs when both flags are false and shows empty state if no other data', async () => { + const screen = await render( + ({data: [], meta: {page: 0, totalPages: 0}})} + consultTransferOptions={{showDialNumberTab: false, showEntryPointTab: false}} + /> + ); + + const buttons = Array.from(screen.container.querySelectorAll('button')).map( + (b) => (b as HTMLButtonElement).textContent + ); + + expect(buttons).toEqual(expect.arrayContaining(['Agents', 'Queues'])); + expect(buttons).not.toEqual(expect.arrayContaining(['Dial Number'])); + expect(buttons).not.toEqual(expect.arrayContaining(['Entry Point'])); + + // With no agents/queues and both tabs hidden, should show the empty state + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); + }); + + it('shows empty state when both lists are completely empty', async () => { const emptyProps = { ...baseProps, buddyAgents: [], - queues: [], + getQueues: async () => ({data: [], meta: {page: 0, totalPages: 0}}), }; - let screen = await render(); - expect(screen.container.querySelector('.agent-tablist')).toBeInTheDocument(); - expect(screen.container.querySelector('.consult-empty-state')).toBeInTheDocument(); - expect(screen.container.querySelector('.consult-empty-message')).toBeInTheDocument(); + const screen = await render(); + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); + }); - // Test empty agents tab with tabs showing (covers lines 94-96) + it('shows tabs and empty agents message when no agents are available', async () => { const emptyAgentsProps = { ...baseProps, buddyAgents: [], }; - screen = await render(); - expect(screen.container.querySelector('.agent-tablist')).toBeInTheDocument(); + const screen = await render(); + const buttons = Array.from(screen.container.querySelectorAll('button')).map( + (b) => (b as HTMLButtonElement).textContent + ); + expect(buttons).toEqual(expect.arrayContaining(['Agents', 'Queues', 'Dial Number', 'Entry Point'])); expect(screen.container.querySelector('.consult-empty-state')).toBeInTheDocument(); + expect(screen.getByText('No data available for consult transfer.')).toBeInTheDocument(); + }); - // Test empty queues tab when switched to queues (covers lines 98-100) + it('shows no items when queues are empty after switching to queues', async () => { const emptyQueuesProps = { ...baseProps, - queues: [], + getQueues: async () => ({data: [], meta: {page: 0, totalPages: 0}}), }; - screen = await render(); - - // Switch to queues tab - const queueTab = screen.container.querySelector('.queue-tab'); - fireEvent.click(queueTab!); - - // Should show empty state for queues (covers lines 98-100) - expect(screen.container.querySelector('.consult-empty-state')).toBeInTheDocument(); + const screen = await render(); + fireEvent.click(screen.getByRole('button', {name: 'Queues'})); + expect(screen.container.querySelector('.agent-list')).toBeNull(); + expect(screen.container.querySelectorAll('.call-control-list-item').length).toBe(0); + }); - // Test hidden queue tab when allowConsultToQueue is false + it('disables queue selection when allowConsultToQueue is false', async () => { const propsWithoutQueue = { ...baseProps, allowConsultToQueue: false, }; - screen = await render(); - const hiddenQueueTab = screen.container.querySelector('.queue-tab'); - expect(hiddenQueueTab).toHaveStyle('display: none'); + const screen = await render(); + const queuesButton = screen.getByRole('button', {name: 'Queues'}) as HTMLButtonElement; + expect(queuesButton.disabled).toBe(true); }); it('covers edge case for empty items in renderList (line 50)', async () => { // Mock isAgentsEmpty to return false even with empty array to force renderList call const mockIsAgentsEmpty = jest.spyOn(utils, 'isAgentsEmpty').mockReturnValue(false); - const mockShouldShowTabs = jest.spyOn(utils, 'shouldShowTabs').mockReturnValue(true); try { const propsWithEmptyAgents = { @@ -197,12 +245,74 @@ describe('ConsultTransferPopoverComponent', () => { const screen = await render(); - // Should render the "No agents found" text (line 50) + // Should render the "No agents found" text via empty list state expect(screen.getByText('No agents found')).toBeInTheDocument(); } finally { // Restore original functions mockIsAgentsEmpty.mockRestore(); - mockShouldShowTabs.mockRestore(); } }); + + describe('Search behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('debounces and triggers queue search on 2+ chars and on clear', async () => { + const getQueuesMock = jest.fn().mockResolvedValue({ + data: [ + {id: 'queue1', name: 'Queue One'} as ContactServiceQueue, + {id: 'queue2', name: 'Queue Two'} as ContactServiceQueue, + ], + meta: {page: 0, totalPages: 1}, + }); + + const screen = await render(); + + // Switch to Queues category + fireEvent.click(screen.getByText('Queues')); + + const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + + fireEvent.change(input, {target: {value: 'q'}}); + await act(async () => { + jest.advanceTimersByTime(500); + }); + expect(getQueuesMock).toHaveBeenCalledTimes(1); + + fireEvent.change(input, {target: {value: 'qu'}}); + await act(async () => { + jest.advanceTimersByTime(500); + }); + const afterTwoChars = getQueuesMock.mock.calls.length; + expect(getQueuesMock).toHaveBeenLastCalledWith( + expect.objectContaining({page: 0, pageSize: DEFAULT_PAGE_SIZE, search: 'qu'}) + ); + expect(afterTwoChars).toBe(2); + + fireEvent.change(input, {target: {value: ''}}); + await act(async () => { + jest.advanceTimersByTime(500); + }); + expect(getQueuesMock).toHaveBeenLastCalledWith(expect.objectContaining({page: 0, pageSize: DEFAULT_PAGE_SIZE})); + expect(getQueuesMock.mock.calls.length).toBe(afterTwoChars + 1); + }); + + it('does not trigger search when category is Agents', async () => { + const getQueuesMock = jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}); + const screen = await render(); + + // Default category is Agents + const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + fireEvent.change(input, {target: {value: 'ab'}}); + await act(async () => { + jest.advanceTimersByTime(500); + }); + expect(getQueuesMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 5907c47b4..db1c9a2ae 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -490,5 +490,68 @@ describe('CallControlComponent', () => { expect(consultButtonElement).toBeInTheDocument(); expect(consultButtonElement).toHaveClass('call-control-button'); }); + + it('passes consultTransferOptions to popover and hides tabs accordingly', async () => { + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id: 'consult', + icon: 'consult', + tooltip: 'Consult', + className: 'call-control-button', + disabled: false, + menuType: 'Consult', + isVisible: true, + dataTestId: 'consult-button', + }, + ]); + + const screen = await render( + + ); + + const consultButton = screen.getByLabelText('Consult'); + fireEvent.click(consultButton); + + await screen.findByRole('button', {name: 'Agents'}); + expect(screen.getByRole('button', {name: 'Queues'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Entry Point'})).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Dial Number'})).not.toBeInTheDocument(); + }); + + it('hides Dial Number and Entry Point tabs for non-telephony media', async () => { + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id: 'consult', + icon: 'consult', + tooltip: 'Consult', + className: 'call-control-button', + disabled: false, + menuType: 'Consult', + isVisible: true, + dataTestId: 'consult-button', + }, + ]); + + isTelephonyMediaTypeSpy.mockReturnValue(false); + + const screen = await render( + + ); + + const consultButton = screen.getByLabelText('Consult'); + fireEvent.click(consultButton); + + await screen.findByRole('button', {name: 'Agents'}); + expect(screen.getByRole('button', {name: 'Queues'})).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Dial Number'})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Entry Point'})).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/contact-center/cc-widgets/package.json b/packages/contact-center/cc-widgets/package.json index cad7c4514..0c867e8aa 100644 --- a/packages/contact-center/cc-widgets/package.json +++ b/packages/contact-center/cc-widgets/package.json @@ -95,4 +95,4 @@ "^.+\\.(css|less|scss)$": "babel-jest" } } -} \ No newline at end of file +} diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index 4368d46a2..95f6f738d 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -22,7 +22,7 @@ "test:styles": "eslint" }, "dependencies": { - "@webex/contact-center": "3.9.0-next.4", + "@webex/contact-center": "3.9.0-next.10", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index f85239ed1..484b413a1 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -8,6 +8,15 @@ import { BuddyAgentsResponse, StateChange, Logout, + EntryPointRecord, + EntryPointListResponse, + EntryPointSearchParams, + AddressBookEntry, + AddressBookEntriesResponse, + AddressBookEntrySearchParams, + ContactServiceQueuesResponse, + ContactServiceQueueSearchParams, + AddressBook, } from '@webex/contact-center'; import {DestinationType} from 'node_modules/@webex/contact-center/dist/types/services/task/types'; import { @@ -36,7 +45,9 @@ interface IContactCenter { getAllTasks: () => Record; }; getBuddyAgents(data: BuddyAgents): Promise; - getQueues(search?: string, filter?: string, page?: number, pageSize?: number): Promise; + getQueues(params?: ContactServiceQueueSearchParams): Promise; + getEntryPoints(params?: EntryPointSearchParams): Promise; + addressBook: AddressBook; agentConfig?: { regexUS: RegExp | string; agentId: string; @@ -119,6 +130,10 @@ interface IStoreWrapper extends IStore { onErrorCallback?: (widgetName: string, error: Error) => void; setCurrentTask(task: ITask): void; refreshTaskList(): void; + getBuddyAgents(mediaType?: string): Promise; + getQueues(mediaType?: string, params?: ContactServiceQueueSearchParams): Promise; + getEntryPoints(params?: EntryPointSearchParams): Promise; + getAddressBookEntries(params?: AddressBookEntrySearchParams): Promise; setDeviceType(option: string): void; setDialNumber(input: string): void; setCurrentState(state: string): void; @@ -211,11 +226,32 @@ type AgentLoginProfile = { }; }; +// Generic pagination params for list-fetching APIs +type PaginatedListParams = { + page: number; + pageSize: number; + search?: string; +}; + +// Generic fetch/transform helpers for paginated APIs +type FetchPaginatedList = ( + params: PaginatedListParams +) => Promise<{data: T[]; meta?: {page?: number; totalPages?: number}}>; + +// Generic transform function for paginated APIs +type TransformPaginatedData = (item: T, page: number, index: number) => U; + // Utility consts const DIALNUMBER: string = 'AGENT_DN'; const EXTENSION: string = 'EXTENSION'; const DESKTOP: string = 'BROWSER'; +// Common string constants used across the store wrapper +const MEDIA_TYPE_TELEPHONY_LOWER = 'telephony'; +const MEDIA_TYPE_TELEPHONY_UPPER = 'TELEPHONY'; +const DEVICE_TYPE_BROWSER = 'BROWSER'; +const AGENT_STATE_AVAILABLE = 'Available'; + const LoginOptions: {[key: string]: string} = { [DIALNUMBER]: 'Dial Number', [EXTENSION]: 'Extension', @@ -249,7 +285,18 @@ export type { BuddyDetails, ContactServiceQueue, AgentLoginProfile, + EntryPointRecord, + EntryPointListResponse, + EntryPointSearchParams, + AddressBookEntry, + AddressBookEntriesResponse, + AddressBookEntrySearchParams, + ContactServiceQueuesResponse, + ContactServiceQueueSearchParams, IWebex, + PaginatedListParams, + FetchPaginatedList, + TransformPaginatedData, }; export { @@ -260,6 +307,10 @@ export { DIALNUMBER, EXTENSION, DESKTOP, + MEDIA_TYPE_TELEPHONY_LOWER, + MEDIA_TYPE_TELEPHONY_UPPER, + DEVICE_TYPE_BROWSER, + AGENT_STATE_AVAILABLE, LoginOptions, ERROR_TRIGGERING_IDLE_CODES, }; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 19d3a03f8..41418f67d 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -14,11 +14,22 @@ import { ENGAGED_LABEL, ENGAGED_USERNAME, ContactServiceQueue, + ContactServiceQueueSearchParams, + EntryPointListResponse, + EntryPointSearchParams, + AddressBookEntriesResponse, + AddressBookEntrySearchParams, Profile, AgentLoginProfile, ERROR_TRIGGERING_IDLE_CODES, } from './store.types'; import Store from './store'; +import { + DEVICE_TYPE_BROWSER, + MEDIA_TYPE_TELEPHONY_LOWER, + MEDIA_TYPE_TELEPHONY_UPPER, + AGENT_STATE_AVAILABLE, +} from './store.types'; import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; @@ -400,7 +411,7 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.AGENT_OFFER_CONTACT, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_HOLD, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_UNHOLD, this.refreshTaskList); - if (this.deviceType === 'BROWSER') { + if (this.deviceType === DEVICE_TYPE_BROWSER) { taskToRemove.off(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); this.setCallControlAudio(null); } @@ -423,9 +434,9 @@ class StoreWrapper implements IStoreWrapper { }; handleTaskMuteState = (task: ITask): void => { - const isBrowser = this.deviceType === 'BROWSER'; + const isBrowser = this.deviceType === DEVICE_TYPE_BROWSER; const webRtcEnabled = this.featureFlags?.webRtcEnabled; - const isTelephony = task?.data?.interaction?.mediaType === 'telephony'; + const isTelephony = task?.data?.interaction?.mediaType === MEDIA_TYPE_TELEPHONY_LOWER; if (isBrowser && isTelephony && webRtcEnabled) { this.setIsMuted(false); @@ -508,7 +519,7 @@ class StoreWrapper implements IStoreWrapper { developerName: ENGAGED_LABEL, name: ENGAGED_USERNAME, }); - if (this.deviceType === 'BROWSER') { + if (this.deviceType === DEVICE_TYPE_BROWSER) { task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); } }); @@ -531,7 +542,7 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.AGENT_OFFER_CONTACT, this.refreshTaskList); task.on(TASK_EVENTS.AGENT_CONSULT_CREATED, this.handleConsultCreated); task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); - if (this.deviceType === 'BROWSER') { + if (this.deviceType === DEVICE_TYPE_BROWSER) { task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); } @@ -605,7 +616,7 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); task.on(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); - if (this.deviceType === 'BROWSER') { + if (this.deviceType === DEVICE_TYPE_BROWSER) { task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); } @@ -660,26 +671,58 @@ class StoreWrapper implements IStoreWrapper { try { const response = await this.store.cc.getBuddyAgents({ //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 - mediaType: mediaType ?? 'telephony', - state: 'Available', + mediaType: mediaType ?? MEDIA_TYPE_TELEPHONY_LOWER, + state: AGENT_STATE_AVAILABLE, }); return 'data' in response ? response.data.agentList : []; } catch (error) { - return Promise.reject(error); + this.store.logger.error('Error fetching buddy agents:', error); + throw error; } }; getQueues = async ( - mediaType: string = this.currentTask.data.interaction.mediaType ?? 'TELEPHONY' - ): Promise> => { + mediaType: string = this.currentTask.data.interaction.mediaType ?? MEDIA_TYPE_TELEPHONY_UPPER, + params?: ContactServiceQueueSearchParams + ): Promise<{ + data: ContactServiceQueue[]; + meta: {page: number; pageSize: number; total: number; totalPages: number}; + }> => { try { const upperMediaType = mediaType.toUpperCase(); - let queueList = await this.store.cc.getQueues(); - queueList = queueList.filter((queue) => queue.channelType === upperMediaType); - return queueList; + const response = await this.store.cc.getQueues(params); + const data = Array.isArray(response) ? response : response.data; + const filtered = data.filter((queue) => queue.channelType === upperMediaType); + const page = Array.isArray(response) ? 0 : (response.meta?.page ?? 0); + const totalPages = Array.isArray(response) ? 1 : (response.meta?.totalPages ?? 1); + const pageSize = Array.isArray(response) ? filtered.length : (response.meta?.pageSize ?? filtered.length); + const total = Array.isArray(response) + ? filtered.length + : ((response as {meta?: {total?: number}}).meta?.total ?? filtered.length); + return {data: filtered, meta: {page, pageSize, total, totalPages}}; + } catch (error) { + this.store.logger.error('Error fetching queues:', error); + throw error; + } + }; + + getEntryPoints = async (params?: EntryPointSearchParams): Promise => { + try { + const response: EntryPointListResponse = await this.store.cc.getEntryPoints(params); + return response; + } catch (error) { + this.store.logger.error('Error fetching entry points:', error); + throw error; + } + }; + + getAddressBookEntries = async (params?: AddressBookEntrySearchParams): Promise => { + try { + const response: AddressBookEntriesResponse = await this.store.cc.addressBook.getEntries(params ?? {}); + return response; } catch (error) { - console.error('Error fetching queues:', error); - return Promise.reject(error); + this.store.logger.error('Error fetching address book entries:', error); + throw error; } }; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 79d6c862f..a5c328a44 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -25,7 +25,13 @@ console.log = jest.fn(); import {CC_EVENTS, TASK_EVENTS} from '../src/store.types'; import storeWrapper from '../src/storeEventsWrapper'; import {ITask} from '@webex/contact-center'; -import {mockCC, mockTask as mockTaskFixture} from '@webex/test-fixtures'; +import { + mockCC, + mockTask as mockTaskFixture, + mockEntryPointsResponse, + mockAddressBookEntriesResponse, + mockQueueDetails, +} from '@webex/test-fixtures'; jest.mock('../src/store', () => ({ getInstance: jest.fn().mockReturnValue({ @@ -34,6 +40,9 @@ jest.mock('../src/store', () => ({ cc: { on: jest.fn(), off: jest.fn(), + addressBook: { + getEntries: jest.fn(), + }, taskManager: { getAllTasks: jest.fn().mockReturnValue({ interaction1: { @@ -806,7 +815,7 @@ describe('storeEventsWrapper', () => { const result = await storeWrapper.getQueues('telephony'); - expect(result).toEqual([ + expect(result.data).toEqual([ {id: 'queue1', name: 'Queue 1', channelType: 'TELEPHONY'}, {id: 'queue2', name: 'Queue 2', channelType: 'TELEPHONY'}, ]); @@ -819,6 +828,21 @@ describe('storeEventsWrapper', () => { await expect(storeWrapper.getQueues('telephony')).rejects.toThrow('queue error'); }); + it('should return contact service queues list when SDK returns paginated response', async () => { + const queueList = [ + {...mockQueueDetails[0], channelType: 'TELEPHONY'}, + {...mockQueueDetails[1], channelType: 'CHAT'}, + ]; + storeWrapper['store'].cc.getQueues = jest + .fn() + .mockResolvedValue({data: queueList, meta: {page: 1, pageSize: 50, total: 2, totalPages: 1}}); + + const result = await storeWrapper.getQueues('telephony'); + + expect(result.data).toEqual([{...mockQueueDetails[0], channelType: 'TELEPHONY'}]); + expect(storeWrapper['store'].cc.getQueues).toHaveBeenCalled(); + }); + it('should handle consultQueueCancelled event', () => { const consultInitiatedSpy = jest.spyOn(storeWrapper, 'setConsultInitiated'); const isQueueConsultInProgressSpy = jest.spyOn(storeWrapper, 'setIsQueueConsultInProgress'); @@ -832,6 +856,32 @@ describe('storeEventsWrapper', () => { expect(currentConsultQueueIdSpy).toHaveBeenCalledWith(null); expect(consultStartTimeStampSpy).toHaveBeenCalledWith(null); }); + + it('should fetch entry points successfully', async () => { + storeWrapper['store'].cc.getEntryPoints = jest.fn().mockResolvedValue(mockEntryPointsResponse); + + const result = await storeWrapper.getEntryPoints({page: 0, pageSize: 25}); + expect(storeWrapper['store'].cc.getEntryPoints).toHaveBeenCalledWith({page: 0, pageSize: 25}); + expect(result).toEqual(mockEntryPointsResponse); + }); + + it('should handle error while fetching entry points', async () => { + storeWrapper['store'].cc.getEntryPoints = jest.fn().mockRejectedValue(new Error('ep error')); + await expect(storeWrapper.getEntryPoints({page: 0, pageSize: 25})).rejects.toThrow('ep error'); + }); + + it('should fetch address book entries successfully', async () => { + jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockResolvedValue(mockAddressBookEntriesResponse); + + const result = await storeWrapper.getAddressBookEntries({page: 0, pageSize: 25}); + expect(storeWrapper['store'].cc.addressBook.getEntries).toHaveBeenCalledWith({page: 0, pageSize: 25}); + expect(result).toEqual(mockAddressBookEntriesResponse); + }); + + it('should handle error while fetching address book entries', async () => { + jest.spyOn(storeWrapper['store'].cc.addressBook, 'getEntries').mockRejectedValue(new Error('ab error')); + await expect(storeWrapper.getAddressBookEntries({page: 0, pageSize: 25})).rejects.toThrow('ab error'); + }); }); describe('storeEventsWrapper events reactions', () => { diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 03c8ddfa5..41412f7d5 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -8,7 +8,7 @@ import {CallControlProps} from '../task.types'; import {CallControlComponent} from '@webex/cc-components'; const CallControlInternal: React.FunctionComponent = observer( - ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute}) => { + ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute, consultTransferOptions}) => { const { logger, currentTask, @@ -48,6 +48,7 @@ const CallControlInternal: React.FunctionComponent = observer( isEndConsultEnabled, allowConsultToQueue, logger, + consultTransferOptions, }; return ; diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index d4ea6b8bb..caa9d736d 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -16,6 +16,7 @@ const CallControlCADInternal: React.FunctionComponent = observ onToggleMute, callControlClassName, callControlConsultClassName, + consultTransferOptions, }) => { const { logger, @@ -57,6 +58,7 @@ const CallControlCADInternal: React.FunctionComponent = observ isEndConsultEnabled, allowConsultToQueue, logger, + consultTransferOptions, }; return ; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index cf8c79238..22b26d571 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -1,7 +1,13 @@ import {useEffect, useCallback, useState, useRef, useMemo} from 'react'; import {ITask} from '@webex/contact-center'; import {useCallControlProps, UseTaskListProps, UseTaskProps, Participant, useOutdialCallProps} from './task.types'; -import store, {TASK_EVENTS, BuddyDetails, DestinationType, ContactServiceQueue} from '@webex/cc-store'; +import store, { + TASK_EVENTS, + BuddyDetails, + DestinationType, + ContactServiceQueue, + PaginatedListParams, +} from '@webex/cc-store'; import {findHoldTimestamp, getControlsVisibility} from './Utils/task-util'; const ENGAGED_LABEL = 'ENGAGED'; @@ -410,8 +416,8 @@ export const useCallControl = (props: useCallControlProps) => { const loadQueues = useCallback(async () => { try { - const queues = await store.getQueues(); - setQueues(queues); + const {data} = await store.getQueues(); + setQueues(data); } catch (error) { logger?.error(`CC-Widgets: Task: Error loading queues - ${error.message || error}`, { module: 'useCallControl', @@ -421,6 +427,52 @@ export const useCallControl = (props: useCallControlProps) => { } }, [logger]); + const getAddressBookEntries = useCallback( + async ({page, pageSize, search}: PaginatedListParams) => { + try { + return await store.getAddressBookEntries({page, pageSize, search}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error fetching address book entries - ${error.message || error}`, { + module: 'useCallControl', + method: 'getAddressBookEntries', + }); + return {data: [], meta: {page: 0, totalPages: 0}}; + } + }, + [logger] + ); + + const getEntryPoints = useCallback( + async ({page, pageSize, search}: PaginatedListParams) => { + try { + return await store.getEntryPoints({page, pageSize, search}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error fetching entry points - ${error.message || error}`, { + module: 'useCallControl', + method: 'getEntryPoints', + }); + return {data: [], meta: {page: 0, totalPages: 0}}; + } + }, + [logger] + ); + + const getQueuesFetcher = useCallback( + async ({page, pageSize, search}: PaginatedListParams) => { + try { + const mediaType = currentTask?.data?.interaction?.mediaType; + return await store.getQueues(mediaType, {page, pageSize, search}); + } catch (error) { + logger?.error(`CC-Widgets: Task: Error fetching queues (paginated) - ${error.message || error}`, { + module: 'useCallControl', + method: 'getQueuesFetcher', + }); + return {data: [], meta: {page: 0, totalPages: 0}}; + } + }, + [logger, currentTask] + ); + const holdCallback = () => { try { setIsHeld(true); @@ -791,7 +843,6 @@ export const useCallControl = (props: useCallControlProps) => { logger.error('CC-Widgets: CallControl: Error initializing auto wrap-up timer', { module: 'widget-cc-task#helper.ts', method: 'useCallControl#autoWrapupTimer', - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 error, }); } @@ -836,6 +887,9 @@ export const useCallControl = (props: useCallControlProps) => { controlVisibility, secondsUntilAutoWrapup, cancelAutoWrapup, + getAddressBookEntries, + getEntryPoints, + getQueuesFetcher, }; }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index a1b3d510b..04b79adf7 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -20,6 +20,7 @@ export type CallControlProps = Partial< | 'callControlClassName' | 'callControlConsultClassName' | 'onToggleMute' + | 'consultTransferOptions' > >; diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index afd21a7b1..03678a22e 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -67,6 +67,9 @@ describe('CallControl Component', () => { cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), isMuted: false, + getAddressBookEntries: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), + getEntryPoints: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), + getQueuesFetcher: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), }); render( diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 4d6d4d971..57a1a478d 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,7 +1,7 @@ import {renderHook, act, waitFor} from '@testing-library/react'; import {useIncomingTask, useTaskList, useCallControl, useOutdialCall} from '../src/helper'; import * as taskUtils from '../src/Utils/task-util'; -import {TASK_EVENTS} from '@webex/cc-store'; +import {AddressBookEntriesResponse, EntryPointListResponse, TASK_EVENTS, IContactCenter} from '@webex/cc-store'; import {mockAgents, mockCC, mockQueueDetails, mockTask} from '@webex/test-fixtures'; import store from '@webex/cc-store'; import React from 'react'; @@ -43,13 +43,13 @@ beforeAll(() => { webRtcEnabled: true, }; store.store.cc = { - ...store.store.cc, // Keep other properties if they exist + ...mockCC, taskManager: { getAllTasks: jest.fn().mockReturnValue({ [taskMock.data.interactionId]: taskMock, }), }, - }; + } as IContactCenter; }); describe('useIncomingTask Hook', () => { @@ -2168,7 +2168,11 @@ describe('useCallControl', () => { }); it('should load queues successfully', async () => { - const getQueuesSpy = jest.spyOn(store, 'getQueues').mockResolvedValue(mockQueueDetails); + const getQueuesResponse: Awaited> = { + data: mockQueueDetails, + meta: {page: 0, pageSize: mockQueueDetails.length, total: mockQueueDetails.length, totalPages: 1}, + }; + const getQueuesSpy = jest.spyOn(store, 'getQueues').mockResolvedValue(getQueuesResponse); const {result} = renderHook(() => useCallControl({ @@ -2189,6 +2193,66 @@ describe('useCallControl', () => { getQueuesSpy.mockRestore(); }); + it('should get address book entries via getAddressBookEntries', async () => { + const mockResponse = {data: [{id: '1', name: 'Alice', number: '123'}], meta: {page: 0, totalPages: 1}}; + jest.spyOn(store, 'getAddressBookEntries').mockResolvedValue(mockResponse as AddressBookEntriesResponse); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + consultInitiated: false, + isMuted: false, + }) + ); + + const res = await result.current.getAddressBookEntries({page: 0, pageSize: 25}); + expect(res).toEqual(mockResponse); + }); + + it('should get entry points via getEntryPoints', async () => { + const mockResponse = {data: [{id: 'ep1', name: 'Entry 1'}], meta: {page: 0, totalPages: 1}}; + jest.spyOn(store, 'getEntryPoints').mockResolvedValue(mockResponse as EntryPointListResponse); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + consultInitiated: false, + isMuted: false, + }) + ); + + const res = await result.current.getEntryPoints({page: 0, pageSize: 25}); + expect(res).toEqual(mockResponse); + }); + + it('should get queues via getQueuesFetcher (paginated)', async () => { + const mockResponse: Awaited> = { + data: [mockQueueDetails[0]], + meta: {page: 0, pageSize: 25, total: 1, totalPages: 1}, + }; + jest.spyOn(store, 'getQueues').mockResolvedValue(mockResponse); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + consultInitiated: false, + isMuted: false, + }) + ); + + const res = await result.current.getQueuesFetcher({page: 0, pageSize: 25}); + expect(res).toEqual(mockResponse); + }); + it('should call cancelAutoWrapup successfully', async () => { const mockOnRecordingToggle = jest.fn(); diff --git a/packages/contact-center/test-fixtures/package.json b/packages/contact-center/test-fixtures/package.json index 13918a997..09ee00cc4 100644 --- a/packages/contact-center/test-fixtures/package.json +++ b/packages/contact-center/test-fixtures/package.json @@ -47,4 +47,4 @@ "webpack-cli": "5.1.4", "webpack-merge": "6.0.1" } -} +} \ No newline at end of file diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index 4404fa420..2b786e9f7 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -1,4 +1,13 @@ -import {ITask, Profile, TaskData, TaskResponse} from '@webex/contact-center'; +import type { + ITask, + Profile, + TaskData, + TaskResponse, + AddressBook, + EntryPointListResponse, + AddressBookEntriesResponse, + ContactServiceQueuesResponse, +} from '@webex/contact-center'; import {IContactCenter} from '@webex/cc-store'; const mockProfile: Profile = { @@ -64,29 +73,43 @@ const mockProfile: Profile = { lastIdleCodeChangeTimestamp: 123456789, }; -const mockCC: IContactCenter = { - on: jest.fn(), - off: jest.fn(), - updateAgentProfile: jest.fn(), - stationLogin: jest.fn(), - deregister: jest.fn(), - stationLogout: jest.fn(), - LoggerProxy: { - log: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - trace: jest.fn(), - }, - register: jest.fn(), - taskManager: { - getAllTasks: jest.fn().mockReturnValue({}), - }, - getBuddyAgents: jest.fn().mockResolvedValue([]), - getQueues: jest.fn().mockResolvedValue([]), - setAgentState: jest.fn().mockResolvedValue({}), +const mockEntryPointsResponse: EntryPointListResponse = { + data: [ + { + id: 'ep1', + name: 'Entry 1', + type: 'Voice', + isActive: true, + orgId: 'org1', + }, + ], + meta: {page: 0, pageSize: 25, totalPages: 1}, +}; + +const mockAddressBookEntriesResponse: AddressBookEntriesResponse = { + data: [ + { + id: 'ab1', + name: 'Alice', + number: '123', + organizationId: 'org1', + version: 1, + createdTime: 1704067200000, + lastUpdatedTime: 1704067200000, + }, + ], + meta: {page: 0, pageSize: 25, totalPages: 1}, }; +const makeMockAddressBook = (getEntriesMock?: AddressBook['getEntries']): AddressBook => + ({ + getEntries: getEntriesMock || jest.fn().mockResolvedValue(mockAddressBookEntriesResponse), + // Need to add this only for AddressBook type + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + }) as {} as AddressBook; + +const mockAddressBook = makeMockAddressBook(); + const mockTask: ITask = { data: { interaction: { @@ -139,9 +162,9 @@ const mockQueueDetails = [ id: 'q1', name: 'Queue1', description: 'Test Queue 1', - queueType: 'inbound', + queueType: 'INBOUND' as const, checkAgentAvailability: true, - channelType: 'telephony', + channelType: 'TELEPHONY' as const, skillProfileId: 'skill1', siteId: 'site1', version: 1, @@ -219,8 +242,8 @@ const mockQueueDetails = [ queueStatisticsEnabled: true, controlFlowScriptUrl: 'https://example.com/script1.js', ivrRequeueUrl: 'https://example.com/requeue1', - routingType: 'skill_based', - queueRoutingType: 'priority', + routingType: 'SKILLS_BASED' as const, + queueRoutingType: 'SKILL_BASED' as const, callParkingUrl: 'https://example.com/parking1', overflowQueueId: 'overflow1', queueContactLimit: 100, @@ -247,9 +270,9 @@ const mockQueueDetails = [ id: 'q2', name: 'Queue2', description: 'Test Queue 2', - queueType: 'inbound', + queueType: 'INBOUND' as const, checkAgentAvailability: true, - channelType: 'telephony', + channelType: 'TELEPHONY' as const, skillProfileId: 'skill2', siteId: 'site1', version: 1, @@ -327,8 +350,8 @@ const mockQueueDetails = [ queueStatisticsEnabled: true, controlFlowScriptUrl: 'https://example.com/script2.js', ivrRequeueUrl: 'https://example.com/requeue2', - routingType: 'skill_based', - queueRoutingType: 'priority', + routingType: 'SKILLS_BASED' as const, + queueRoutingType: 'SKILL_BASED' as const, callParkingUrl: 'https://example.com/parking2', overflowQueueId: 'overflow2', queueContactLimit: 100, @@ -380,4 +403,79 @@ const mockAgents = [ }, ]; -export {mockProfile, mockCC, mockTask, mockQueueDetails, mockAgents}; +const mockQueuesResponse: ContactServiceQueuesResponse = { + data: mockQueueDetails.map((q) => ({ + id: q.id, + version: q.version, + name: q.name, + description: q.description, + queueType: q.queueType, + checkAgentAvailability: q.checkAgentAvailability, + channelType: q.channelType, + serviceLevelThreshold: q.serviceLevelThreshold, + maxActiveContacts: q.maxActiveContacts, + maxTimeInQueue: q.maxTimeInQueue, + defaultMusicInQueueMediaFileId: q.defaultMusicInQueueMediaFileId, + timezone: q.timezone, + active: q.active, + outdialCampaignEnabled: q.outdialCampaignEnabled, + monitoringPermitted: q.monitoringPermitted, + parkingPermitted: q.parkingPermitted, + recordingPermitted: q.recordingPermitted, + recordingAllCallsPermitted: q.recordingAllCallsPermitted, + pauseRecordingPermitted: q.pauseRecordingPermitted, + recordingPauseDuration: q.recordingPauseDuration, + controlFlowScriptUrl: q.controlFlowScriptUrl, + ivrRequeueUrl: q.ivrRequeueUrl, + overflowNumber: q.overflowQueueId, + routingType: q.routingType, + queueRoutingType: q.queueRoutingType, + queueSkillRequirements: q.queueSkillRequirements?.map((s) => ({ + skillId: s.id, + skillName: s.name, + condition: 'EQUALS', + skillValue: '1', + })), + agents: q.agents?.map((a) => ({id: a.id})), + callDistributionGroups: q.callDistributionGroups, + createdTime: typeof q.createdTime === 'number' ? q.createdTime : Date.parse(String(q.createdTime)), + lastUpdatedTime: typeof q.lastUpdatedTime === 'number' ? q.lastUpdatedTime : Date.parse(String(q.lastUpdatedTime)), + })), + meta: {page: 0, pageSize: 25, totalPages: 1}, +}; + +const mockCC: IContactCenter = { + on: jest.fn(), + off: jest.fn(), + updateAgentProfile: jest.fn(), + stationLogin: jest.fn(), + deregister: jest.fn(), + stationLogout: jest.fn(), + LoggerProxy: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + }, + register: jest.fn(), + taskManager: { + getAllTasks: jest.fn().mockReturnValue({}), + }, + getBuddyAgents: jest.fn().mockResolvedValue(mockAgents), + getQueues: jest.fn().mockResolvedValue(mockQueuesResponse), + getEntryPoints: jest.fn().mockResolvedValue(mockEntryPointsResponse), + addressBook: mockAddressBook, + setAgentState: jest.fn().mockResolvedValue({}), +}; + +export { + mockProfile, + mockCC, + mockTask, + mockQueueDetails, + mockAgents, + mockEntryPointsResponse, + mockAddressBookEntriesResponse, + makeMockAddressBook, +}; diff --git a/packages/contact-center/ui-logging/package.json b/packages/contact-center/ui-logging/package.json index dc1f6cb70..7f54518af 100644 --- a/packages/contact-center/ui-logging/package.json +++ b/packages/contact-center/ui-logging/package.json @@ -36,4 +36,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/packages/contact-center/user-state/package.json b/packages/contact-center/user-state/package.json index 4f126d4f4..96c53eed8 100644 --- a/packages/contact-center/user-state/package.json +++ b/packages/contact-center/user-state/package.json @@ -64,4 +64,4 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1" } -} \ No newline at end of file +} diff --git a/playwright/Utils/advancedTaskControlUtils.ts b/playwright/Utils/advancedTaskControlUtils.ts index 127fc1373..69bb206ef 100644 --- a/playwright/Utils/advancedTaskControlUtils.ts +++ b/playwright/Utils/advancedTaskControlUtils.ts @@ -1,4 +1,5 @@ -import {Page} from '@playwright/test'; +import {Page, expect} from '@playwright/test'; +import {dismissOverlays} from './helperUtils'; import {AWAIT_TIMEOUT, FORM_FIELD_TIMEOUT} from '../constants'; /** @@ -112,12 +113,33 @@ export function verifyConsultTransferredLogs(): void { */ export async function consultOrTransfer( page: Page, - type: 'agent' | 'queue' | 'dialNumber', + type: 'agent' | 'queue' | 'dialNumber' | 'entryPoint', action: 'consult' | 'transfer', value: string ): Promise { - // Determine which button to click for consult or transfer + await openConsultOrTransferMenu(page, action); + const popover = await getPopover(page); + + if (type === 'agent') { + await performAgentSelection(page, popover, value); + } else if (type === 'queue') { + await performQueueSelection(page, popover, value); + } else if (type === 'dialNumber') { + await performDialNumberSelection(page, popover, value); + } else if (type === 'entryPoint') { + await performEntryPointSelection(page, popover, value); + } + + await page.waitForTimeout(2000); if (action === 'consult') { + await expect(page.getByTestId('cancel-consult-btn')).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); + } +} + +// ===== Internal helper functions ===== +async function openConsultOrTransferMenu(page: Page, action: 'consult' | 'transfer'): Promise { + if (action === 'consult') { + await dismissOverlays(page); await page.getByTestId('call-control:consult').nth(1).click({timeout: AWAIT_TIMEOUT}); } else { await page @@ -125,48 +147,109 @@ export async function consultOrTransfer( .getByLabel('Transfer Call') .click({timeout: AWAIT_TIMEOUT}); } +} - // Navigate to the correct tab and perform the action - if (type === 'agent' || type === 'queue') { - const tabName = type === 'agent' ? 'Agents' : 'Queues'; - await page.getByRole('tab', {name: tabName}).click({timeout: AWAIT_TIMEOUT}); - const listItem = page.getByRole('listitem', {name: value, exact: true}); - await listItem.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); - await listItem.scrollIntoViewIfNeeded(); - await page.waitForTimeout(300); - const button = listItem.getByRole('button'); - await button.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); - await button.scrollIntoViewIfNeeded(); - await button.evaluate((el) => { - if (el.hasAttribute('disabled')) { - throw new Error(`${tabName.slice(0, -1)} button is disabled`); - } - }); - let lastError; - for (let i = 0; i < 3; i++) { - try { - await button.click({timeout: AWAIT_TIMEOUT, force: true}); - lastError = undefined; - break; - } catch (e) { - lastError = e; - await page.waitForTimeout(400); - } +async function getPopover(page: Page) { + const popover = page.locator('.agent-popover-content'); + await expect(popover.locator('#consult-search')).toBeVisible({timeout: FORM_FIELD_TIMEOUT}); + return popover; +} + +async function clickCategory( + page: Page, + popover: ReturnType, + name: 'Agents' | 'Queues' | 'Dial Number' | 'Entry Point' +): Promise { + const button = popover.getByRole('button', {name}); + await button.click({timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(200); +} + +async function clickListItemPrimaryButton( + page: Page, + popover: ReturnType, + value: string, + categoryLabel: string +): Promise { + const listItem = popover.locator(`[role="listitem"][aria-label="${value}"]`).first(); + await listItem.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await listItem.hover(); + await listItem.scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + const primaryButton = listItem.getByRole('button'); + await primaryButton.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); + await primaryButton.scrollIntoViewIfNeeded(); + await primaryButton.evaluate((el) => { + if (el.hasAttribute('disabled')) { + throw new Error(`${categoryLabel} button is disabled`); } - if (lastError) { - throw lastError; + }); + let lastError; + for (let i = 0; i < 3; i++) { + try { + await primaryButton.click({timeout: AWAIT_TIMEOUT, force: true}); + lastError = undefined; + break; + } catch (err) { + lastError = err; + await page.waitForTimeout(300); } - } else if (type === 'dialNumber') { - await page.getByRole('tab', {name: 'Dial Number'}).click({timeout: AWAIT_TIMEOUT}); - const inputLocator = page.getByTestId('consult-transfer-dial-number-input').locator('input'); - await inputLocator.waitFor({state: 'visible', timeout: AWAIT_TIMEOUT}); - await inputLocator.click({timeout: AWAIT_TIMEOUT}); - await inputLocator.fill(value, {timeout: AWAIT_TIMEOUT}); - await page.getByTestId('dial-number-btn').click({timeout: AWAIT_TIMEOUT}); } + if (lastError) { + throw lastError; + } + await page + .locator('.md-popover-backdrop') + .waitFor({state: 'hidden', timeout: 3000}) + .catch(() => {}); +} - // Wait a moment for the action to be processed - await page.waitForTimeout(2000); +async function performAgentSelection(page: Page, popover: ReturnType, value: string): Promise { + await clickCategory(page, popover, 'Agents'); + await clickListItemPrimaryButton(page, popover, value, 'Agent'); +} + +async function performQueueSelection(page: Page, popover: ReturnType, value: string): Promise { + await clickCategory(page, popover, 'Queues'); + await clickListItemPrimaryButton(page, popover, value, 'Queue'); +} + +async function performDialNumberSelection( + page: Page, + popover: ReturnType, + value: string +): Promise { + if (!value || value.trim() === '') { + throw new Error( + 'PW_DIAL_NUMBER_NAME is not set. Please provide the Dial Number list item name (e.g., cypher_pstn).' + ); + } + await clickCategory(page, popover, 'Dial Number'); + const search = popover.locator('#consult-search'); + if (await search.isVisible({timeout: 500}).catch(() => false)) { + await search.fill(value, {timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(300); + } + await popover + .getByRole('listitem', {name: value, exact: true}) + .waitFor({state: 'visible', timeout: FORM_FIELD_TIMEOUT}); + await clickListItemPrimaryButton(page, popover, value, 'Dial Number'); +} + +async function performEntryPointSelection( + page: Page, + popover: ReturnType, + value: string +): Promise { + await clickCategory(page, popover, 'Entry Point'); + if (value) { + const search = popover.locator('#consult-search'); + if (await search.isVisible({timeout: 500}).catch(() => false)) { + await search.fill(value, {timeout: AWAIT_TIMEOUT}); + await page.waitForTimeout(300); + } + } + await clickListItemPrimaryButton(page, popover, value, 'Entry Point'); } /** diff --git a/playwright/Utils/helperUtils.ts b/playwright/Utils/helperUtils.ts index e8ecd1c8f..047fe3462 100644 --- a/playwright/Utils/helperUtils.ts +++ b/playwright/Utils/helperUtils.ts @@ -505,3 +505,33 @@ export const pageSetup = async ( await page.getByTestId('state-select').waitFor({state: 'visible', timeout: 30000}); }; + +/** + * Dismisses any visible popover/tooltips/backdrops that might intercept pointer events. + * Attempts ESC presses and quick background clicks. + */ +export async function dismissOverlays(page: Page): Promise { + const isVisibleWithin = async (locator: any, timeoutMs: number = 500): Promise => { + try { + await locator.waitFor({state: 'visible', timeout: timeoutMs}); + return true; + } catch { + return false; + } + }; + + for (let i = 0; i < 3; i++) { + // If a Momentum popover backdrop is visible, try ESC to close (with bounded timeout) + const backdropVisible = await isVisibleWithin(page.locator('.md-popover-backdrop'), 500); + const tippyVisible = await isVisibleWithin(page.locator('[id^="tippy-"]').first(), 500); + if (!backdropVisible && !tippyVisible) return; + try { + await page.keyboard.press('Escape'); + } catch {} + // Small click near top-left to blur active elements + try { + await page.mouse.click(5, 5); + } catch {} + await page.waitForTimeout(200); + } +} diff --git a/playwright/tests/advanced-task-controls-test.spec.ts b/playwright/tests/advanced-task-controls-test.spec.ts index 7eaa55f32..e43d4909d 100644 --- a/playwright/tests/advanced-task-controls-test.spec.ts +++ b/playwright/tests/advanced-task-controls-test.spec.ts @@ -139,8 +139,7 @@ export default function createAdvancedTaskControlsTests() { }); test('Call Blind Transferred to DialNumber', async () => { - // First transfer from Agent 1 to Agent 2 - await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'transfer', process.env.PW_DIAL_NUMBER!); + await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'transfer', process.env.PW_DIAL_NUMBER_NAME); //DialNumber accepts the transfer await acceptExtensionCall(testManager.dialNumberPage); @@ -178,6 +177,27 @@ export default function createAdvancedTaskControlsTests() { // ============================================================================= describe('Consult and Consult Transfer Scenarios', () => { + test('Entry Point Consult: visible and functional only for supported users (no blind transfer)', async ({}, testInfo) => { + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await createCallTask(testManager.callerPage!, process.env[`${testManager.projectName}_ENTRY_POINT`]!); + const incomingTaskDiv = testManager.agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({state: 'visible', timeout: 80000}); + await acceptExtensionCall(testManager.agent1ExtensionPage); + await testManager.agent1Page.waitForTimeout(3000); + await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); + + // Ensure consult UI and Entry Point tab exists + await testManager.agent1Page.getByTestId('call-control:consult').nth(1).click(); + await expect(testManager.agent1Page.locator('#consult-search')).toBeVisible(); + await testManager.agent1Page.getByRole('button', {name: 'Entry Point'}).click(); + + await consultOrTransfer(testManager.agent1Page, 'entryPoint', 'consult', process.env.PW_ENTRYPOINT_NAME!); + await expect(testManager.agent1Page.getByTestId('cancel-consult-btn')).toBeVisible(); + await testManager.agent1Page.waitForTimeout(1000); + await cancelConsult(testManager.agent1Page); + await testManager.agent1Page.waitForTimeout(1000); + await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); + }); test('Agent Consult Transfer: cancel, decline, timeout, and transfer scenarios are handled correctly in sequence', async () => { // ...existing code for Agent Consult Transfer test... await changeUserState(testManager.agent2Page, USER_STATES.MEETING); @@ -380,7 +400,6 @@ export default function createAdvancedTaskControlsTests() { }); test('Dial Number Consult: cancel, decline, accept/end, and transfer scenarios are handled correctly in sequence', async () => { - // ...existing code for Dial Number Consult test... await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); // Setup: create call and get to engaged state @@ -396,7 +415,7 @@ export default function createAdvancedTaskControlsTests() { // 1. Cancel consult clearAdvancedCapturedLogs(); - await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER!); + await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER_NAME); await expect(testManager.agent1Page.getByTestId('cancel-consult-btn')).toBeVisible(); await testManager.agent1Page.waitForTimeout(2000); await cancelConsult(testManager.agent1Page); @@ -405,7 +424,7 @@ export default function createAdvancedTaskControlsTests() { // 2. Decline consult clearAdvancedCapturedLogs(); - await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER!); + await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER_NAME); await declineExtensionCall(testManager.dialNumberPage); await testManager.agent1Page.waitForTimeout(2000); await cancelConsult(testManager.agent1Page); // still needs to cancel even if declined @@ -418,7 +437,7 @@ export default function createAdvancedTaskControlsTests() { // 3. Accept consult and end clearAdvancedCapturedLogs(); - await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER!); + await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER_NAME); await testManager.agent1Page.waitForTimeout(2000); verifyConsultStartSuccessLogs(); await acceptExtensionCall(testManager.dialNumberPage); @@ -432,7 +451,7 @@ export default function createAdvancedTaskControlsTests() { // 4. Consult transfer clearAdvancedCapturedLogs(); - await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER!); + await consultOrTransfer(testManager.agent1Page, 'dialNumber', 'consult', process.env.PW_DIAL_NUMBER_NAME); await acceptExtensionCall(testManager.dialNumberPage); await testManager.agent1Page.waitForTimeout(3000); await testManager.agent1Page.getByTestId('transfer-consult-btn').click(); @@ -443,5 +462,53 @@ export default function createAdvancedTaskControlsTests() { verifyConsultTransferredLogs(); await endCallTask(testManager.dialNumberPage); }); + + test('Dial Number search filters list to the matching entry (local search)', async () => { + if (testManager.projectName !== 'SET_5') { + test.skip(true, 'Dial Number search validation runs only for SET_5 (user23/user24).'); + } + + const searchTerm = process.env.PW_DIAL_NUMBER_NAME!; + + // Setup: create call and get to engaged state + await changeUserState(testManager.agent1Page, USER_STATES.AVAILABLE); + await changeUserState(testManager.agent2Page, USER_STATES.MEETING); + await createCallTask(testManager.callerPage!, process.env[`${testManager.projectName}_ENTRY_POINT`]!); + const incomingTaskDiv = testManager.agent1Page.getByTestId('samples:incoming-task-telephony').first(); + await incomingTaskDiv.waitFor({state: 'visible', timeout: 80000}); + await acceptExtensionCall(testManager.agent1ExtensionPage); + await testManager.agent1Page.waitForTimeout(3000); + await verifyCurrentState(testManager.agent1Page, USER_STATES.ENGAGED); + + // Open consult popover and switch to Dial Number + await testManager.agent1Page.getByTestId('call-control:consult').nth(1).click(); + const popover = testManager.agent1Page.locator('.agent-popover-content'); + await expect(popover).toBeVisible({timeout: 10000}); + await popover.getByRole('button', {name: 'Dial Number'}).click(); + + // Perform search and wait for local filtering to reflect + await popover.locator('#consult-search').fill(searchTerm); + await testManager.agent1Page.waitForTimeout(4000); + + // Read visible list item titles (aria-labels) and validate only the searched item remains + const labels = await popover + .locator('[role="listitem"]') + .evaluateAll((nodes) => nodes.map((n) => n.getAttribute('aria-label'))); + expect(labels).toContain(searchTerm); + expect(labels.filter(Boolean).length).toBe(1); + + // Close the popover to avoid overlay blocking further actions + await testManager.agent1Page.keyboard.press('Escape'); + await testManager.agent1Page + .locator('.md-popover-backdrop') + .waitFor({state: 'hidden', timeout: 3000}) + .catch(() => {}); + + // End call and complete wrapup to clean up for next tests + await endTask(testManager.agent1Page); + await testManager.agent1Page.waitForTimeout(2000); + await submitWrapup(testManager.agent1Page, WRAPUP_REASONS.SALE); + await testManager.agent1Page.waitForTimeout(1000); + }); }); } diff --git a/yarn.lock b/yarn.lock index f47418a9f..e082b1721 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9374,13 +9374,13 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.9.0-next.2": - version: 3.9.0-next.2 - resolution: "@webex/calling@npm:3.9.0-next.2" +"@webex/calling@npm:3.9.0-next.5": + version: 3.9.0-next.5 + resolution: "@webex/calling@npm:3.9.0-next.5" dependencies: "@types/platform": "npm:1.3.4" "@webex/internal-media-core": "npm:2.18.5" - "@webex/internal-plugin-metrics": "npm:3.9.0-next.1" + "@webex/internal-plugin-metrics": "npm:3.9.0-next.3" "@webex/media-helpers": "npm:3.8.1-next.16" async-mutex: "npm:0.4.0" buffer: "npm:6.0.3" @@ -9388,7 +9388,7 @@ __metadata: platform: "npm:1.3.6" uuid: "npm:8.3.2" xstate: "npm:4.30.6" - checksum: 10c0/d8f0c18cdb9cdf0f580cd0fca38fd0513cd4007053ba885470ffa5b6dd90a25b648c7668c5bba093c1f37849b3b6154f23dd225fea3344f60713f8e7acec8dd5 + checksum: 10c0/2326aeea8441907bfb778a2f27148e6777d545226a241d165552ad44ec041275b62a5ecfb12d26ecdaf1eb4839424bb1a6021b87a5225354211eff4a29ce8329 languageName: node linkType: hard @@ -9500,7 +9500,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.9.0-next.4" + "@webex/contact-center": "npm:3.9.0-next.10" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" babel-loader: "npm:9.2.1" @@ -9839,21 +9839,21 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.9.0-next.4": - version: 3.9.0-next.4 - resolution: "@webex/contact-center@npm:3.9.0-next.4" +"@webex/contact-center@npm:3.9.0-next.10": + version: 3.9.0-next.10 + resolution: "@webex/contact-center@npm:3.9.0-next.10" dependencies: "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.9.0-next.2" - "@webex/internal-plugin-mercury": "npm:3.9.0-next.1" - "@webex/internal-plugin-metrics": "npm:3.9.0-next.1" - "@webex/internal-plugin-support": "npm:3.9.0-next.1" - "@webex/plugin-authorization": "npm:3.9.0-next.1" - "@webex/plugin-logger": "npm:3.9.0-next.1" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/calling": "npm:3.9.0-next.5" + "@webex/internal-plugin-mercury": "npm:3.9.0-next.3" + "@webex/internal-plugin-metrics": "npm:3.9.0-next.3" + "@webex/internal-plugin-support": "npm:3.9.0-next.4" + "@webex/plugin-authorization": "npm:3.9.0-next.3" + "@webex/plugin-logger": "npm:3.9.0-next.3" + "@webex/webex-core": "npm:3.9.0-next.3" jest-html-reporters: "npm:3.0.11" lodash: "npm:^4.17.21" - checksum: 10c0/3039bd5f0e78cf4642cef7b824e38fd49c76f6105621ae00e737d3900843189ce52031c9bc1e4884e0383f525eab53612d2445b44460084f28aa5975fa4b4f40 + checksum: 10c0/2fcc6b4d5c68ff130759c41517021d188c5cc1ed3f4629d5ce14024ea15a7ef772eb5213b45e9dba7c6b0932c9499b3c190c441fa12a50e70035ee44aae4d8ab languageName: node linkType: hard @@ -9870,16 +9870,16 @@ __metadata: languageName: node linkType: hard -"@webex/event-dictionary-ts@npm:^1.0.1819": - version: 1.0.1896 - resolution: "@webex/event-dictionary-ts@npm:1.0.1896" +"@webex/event-dictionary-ts@npm:^1.0.1930": + version: 1.0.1941 + resolution: "@webex/event-dictionary-ts@npm:1.0.1941" dependencies: amf-client-js: "npm:^5.2.6" json-schema-to-typescript: "npm:^12.0.0" minimist: "npm:^1.2.8" shelljs: "npm:^0.8.5" webapi-parser: "npm:^0.5.0" - checksum: 10c0/cfa5bd5854376e15fac4324a222d028b2221b223a69b91aaf120f6044301302f410ad2cc6a07961d001f89d9b598d9778ad8daf449c226a8cb873d0d51da6dd4 + checksum: 10c0/cb7a34a7fcf760a2ea68e8d786f8fec4a0cfd498aa9ce7fc37d5151c05fa8bd879408bf3c6682b28feeef0e6e51e326bb21ff4073f860366848fec3c8cb4e27d languageName: node linkType: hard @@ -10241,21 +10241,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-conversation@npm:3.9.0-next.1" +"@webex/internal-plugin-conversation@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-conversation@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/helper-html": "npm:3.8.1-next.11" "@webex/helper-image": "npm:3.8.1-next.11" - "@webex/internal-plugin-encryption": "npm:3.9.0-next.1" - "@webex/internal-plugin-user": "npm:3.9.0-next.1" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/internal-plugin-encryption": "npm:3.9.0-next.3" + "@webex/internal-plugin-user": "npm:3.9.0-next.3" + "@webex/webex-core": "npm:3.9.0-next.3" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/0ec59ce9403e2ebf7d939d3cc4338e7444eeb0ce73005eb87bd1286c8d6bb319946962569863648b791d78be1785e4afbb56a2e3dc3d07a778d2db592ff48a0d + checksum: 10c0/be33259eb95ada91c9ea41970a215bc6287ae78bc9f724adc2c319b65e13d0322027d9196abb478f511d464fde59ca5657ebbe97de8c9a8f4531dd08ca5151c3 languageName: node linkType: hard @@ -10308,20 +10308,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-device@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-device@npm:3.9.0-next.1" +"@webex/internal-plugin-device@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-device@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/common-timers": "npm:3.8.1-next.11" "@webex/http-core": "npm:3.8.1-next.11" - "@webex/internal-plugin-metrics": "npm:3.9.0-next.1" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/internal-plugin-metrics": "npm:3.9.0-next.3" + "@webex/webex-core": "npm:3.9.0-next.3" ampersand-collection: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/4abd1341141000f41b6ea0723c83eee73582e0ba7ab331cce9d23371daeda1c0ad105577070bcf2826f42026345bca74169ff8966312efd8cb6f28ac06c9177b + checksum: 10c0/7383d13dc95c2e9fcdec101e80838a625862fc834b741798a2594c61919fccc6c19e153a61fafb614a6e50b8fa30dc61a1a39b08786e609e56c9bfecfc6a565d languageName: node linkType: hard @@ -10403,17 +10403,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-encryption@npm:3.9.0-next.1" +"@webex/internal-plugin-encryption@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-encryption@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/common-timers": "npm:3.8.1-next.11" "@webex/http-core": "npm:3.8.1-next.11" - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/internal-plugin-mercury": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/internal-plugin-mercury": "npm:3.9.0-next.3" "@webex/test-helper-file": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" asn1js: "npm:^2.0.26" debug: "npm:^4.3.4" isomorphic-webcrypto: "npm:^2.3.8" @@ -10425,7 +10425,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/0fd1b8875ec8dfce221ec246429c079bfc34eb70d063efbc26e1728d94542513c9c3c0fcae8dbd9764294d340ba29a3f70ee14393381eefec4f42a425d1d4f3a + checksum: 10c0/b594ac9ce995208c02ccbf91c6f0b0cafe43dedf7a956c374b9f37f1b3a5b55695e21bff159e893ad23e32e3e0546d9cdc4afbc813ca66a1d864cb6917c4379a languageName: node linkType: hard @@ -10464,14 +10464,14 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-feature@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-feature@npm:3.9.0-next.1" +"@webex/internal-plugin-feature@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-feature@npm:3.9.0-next.3" dependencies: - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/webex-core": "npm:3.9.0-next.3" lodash: "npm:^4.17.21" - checksum: 10c0/111ba44e51cf6222567b4682adbfcdca1a7523f59165dcb671217416b1ca8b89d079b99641570f4d895b95bb4c1f34d8754420eff272a6dacc81d0f1ab35df94 + checksum: 10c0/dd5a6a124b5e5f2b56cabc567b64f78aaf8c87a2a9e6c8c9f4e735dbebab1ee4eb63a47fdb4536486f60eb7c4fcd4ec0d39df523a1999b78df176cf818a2f243 languageName: node linkType: hard @@ -10649,27 +10649,27 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-mercury@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-mercury@npm:3.9.0-next.1" +"@webex/internal-plugin-mercury@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-mercury@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/common-timers": "npm:3.8.1-next.11" - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/internal-plugin-feature": "npm:3.9.0-next.1" - "@webex/internal-plugin-metrics": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/internal-plugin-feature": "npm:3.9.0-next.3" + "@webex/internal-plugin-metrics": "npm:3.9.0-next.3" "@webex/test-helper-chai": "npm:3.8.1-next.11" "@webex/test-helper-mocha": "npm:3.8.1-next.11" "@webex/test-helper-mock-web-socket": "npm:3.8.1-next.11" "@webex/test-helper-mock-webex": "npm:3.8.1-next.11" "@webex/test-helper-refresh-callback": "npm:3.8.1-next.11" "@webex/test-helper-test-users": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" backoff: "npm:^2.5.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" ws: "npm:^8.17.1" - checksum: 10c0/48af16b7b619df7a287cf76460978142a070633932df1888b38f204f01da1ad128635a83b610acce0d9d05f24b812d059ac64306f66e66c4e218bd8b7440bd58 + checksum: 10c0/2c712e2e635a5b7e5f32c131502161f40fd7fb438318f95573005a07a2388230ebeae69079f004e7cff3814ed153c86108ae722fad0cd4f60903f834434edaee languageName: node linkType: hard @@ -10717,20 +10717,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-metrics@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-metrics@npm:3.9.0-next.1" +"@webex/internal-plugin-metrics@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-metrics@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/common-timers": "npm:3.8.1-next.11" - "@webex/event-dictionary-ts": "npm:^1.0.1819" + "@webex/event-dictionary-ts": "npm:^1.0.1930" "@webex/test-helper-chai": "npm:3.8.1-next.11" "@webex/test-helper-mock-webex": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" ip-anonymize: "npm:^0.1.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/cf7e845c5928c2c22f672ef80ebd269a072d4b4522cdd139319243eaa19503864fd5d70e9532da0427719952e283d84536556730d912bcb0575ce4b6fb9e1d40 + checksum: 10c0/ad7fe8f9daf8e891e5f2ed9d3fc1044a9c9ddcfe9a86c3edd28d2e59be934c82cbc99ec2f4f78ab231a7b1f95efa2b17383a9bebc9c37f21aaa7d2232c4c5a65 languageName: node linkType: hard @@ -10827,18 +10827,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-search@npm:3.9.0-next.1" +"@webex/internal-plugin-search@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-search@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" - "@webex/internal-plugin-conversation": "npm:3.9.0-next.1" - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/internal-plugin-encryption": "npm:3.9.0-next.1" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/internal-plugin-conversation": "npm:3.9.0-next.3" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/internal-plugin-encryption": "npm:3.9.0-next.3" + "@webex/webex-core": "npm:3.9.0-next.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/64c478bfca7dd323544fcb76f5f27ddab78ca9b6b58aeb3a25d0c24ed21eb0c151944e4e7ba3926c24bfedfeb3a4e383856b64b103232779d8b075038fffb06f + checksum: 10c0/f3951822334b32cf48c0801fc1e514f0765ad5af8eb4418b6d09e832f2870827f6a9ffaddd0fbe5202b6ab040e72f364f6d927c7f71393115ef270e71279d459 languageName: node linkType: hard @@ -10893,20 +10893,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-support@npm:3.9.0-next.1" +"@webex/internal-plugin-support@npm:3.9.0-next.4": + version: 3.9.0-next.4 + resolution: "@webex/internal-plugin-support@npm:3.9.0-next.4" dependencies: - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/internal-plugin-search": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/internal-plugin-search": "npm:3.9.0-next.3" "@webex/test-helper-chai": "npm:3.8.1-next.11" "@webex/test-helper-file": "npm:3.8.1-next.11" "@webex/test-helper-mock-webex": "npm:3.8.1-next.11" "@webex/test-helper-test-users": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e06e5bcf48fef1c416317993903dccc2bbef2c084e1b7e39df2aa3e691465043a4d80de49df76a28acd19ed7aeb239225ff8bd3efed495f80bcdc886b0c5048c + checksum: 10c0/85e2dd86bb15df963e8621ebd7b542405bf0b306344e8ddcf227aa140e6c4eea4e1a4b3ca83d79e8623665055811bcb37dfefc891e0c7b10855271aeda76128e languageName: node linkType: hard @@ -10958,19 +10958,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-user@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/internal-plugin-user@npm:3.9.0-next.1" +"@webex/internal-plugin-user@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/internal-plugin-user@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" - "@webex/internal-plugin-device": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" "@webex/test-helper-chai": "npm:3.8.1-next.11" "@webex/test-helper-mock-webex": "npm:3.8.1-next.11" "@webex/test-helper-test-users": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/bc52b599464d3e25c700deac6c28b867adabfcefce950e754ad314a577a47056d7459ee417c2c967b65b300b8552861e73a6717d5598dcdb43397aeff74eb604 + checksum: 10c0/fd7af0ad43ebea809caf7e6baa15fff31103b2d770df6b1ec8f68d9a33f6e071f5fdcf23782a76abd327e3c6da460e83486582c2c8d9f032182b37c205e743a1 languageName: node linkType: hard @@ -11165,20 +11165,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-browser@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/plugin-authorization-browser@npm:3.9.0-next.1" +"@webex/plugin-authorization-browser@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/plugin-authorization-browser@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/plugin-authorization-node": "npm:3.9.0-next.1" - "@webex/storage-adapter-local-storage": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/plugin-authorization-node": "npm:3.9.0-next.3" + "@webex/storage-adapter-local-storage": "npm:3.9.0-next.3" "@webex/storage-adapter-spec": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/9e21845d5ff3af1b6e42a149e04b3b7deefc69c53f81d205717b4bbb98d163d034aff733e2bfe9f087094b42bb736499d751cc90dcbbef29a3ebf1e0af8975a9 + checksum: 10c0/843fc3457fb284e7a904d05c1426f2f2fd39a25832a83fb0eea8143cb736785fd0156bcdb49af9cabae08fb33cf45b0be274706ccbcb2267c6c6d6de1230969d languageName: node linkType: hard @@ -11221,16 +11221,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-node@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/plugin-authorization-node@npm:3.9.0-next.1" +"@webex/plugin-authorization-node@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/plugin-authorization-node@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" - "@webex/internal-plugin-device": "npm:3.9.0-next.1" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/internal-plugin-device": "npm:3.9.0-next.3" + "@webex/webex-core": "npm:3.9.0-next.3" jsonwebtoken: "npm:^9.0.0" uuid: "npm:^3.3.2" - checksum: 10c0/b52a593e11a4034ad4580ef040da8eff0f804142404cc052aeaeeb245a9d9537b20b65537616ac852e547b1a13c149f9c079b675599578bd2b4895b0154cfac0 + checksum: 10c0/057440bf0c5ccaee6c7803faebcd1b81eaa6a72505bf61a6e04a94d76cfe260487ec0127b754aaca372c4831ae54673fd1f51e0c13b38e038ac174a72e332d34 languageName: node linkType: hard @@ -11264,13 +11264,13 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/plugin-authorization@npm:3.9.0-next.1" +"@webex/plugin-authorization@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/plugin-authorization@npm:3.9.0-next.3" dependencies: - "@webex/plugin-authorization-browser": "npm:3.9.0-next.1" - "@webex/plugin-authorization-node": "npm:3.9.0-next.1" - checksum: 10c0/ead3a65be83d824bd51f3625466b07207df06ecdbe79f08ff675dfdd08d23a60dc95f3745056cdccb892d8e0f9ce4f96125df04ed1c97dda34c40cb24bc253db + "@webex/plugin-authorization-browser": "npm:3.9.0-next.3" + "@webex/plugin-authorization-node": "npm:3.9.0-next.3" + checksum: 10c0/b71184ee1b5cf4ef895ea14632803a5b3701f120a705b6421e94914f472d49d70da439484a9ab311f603c8f6b4a2adbd6039fd70fb3b6b8d80f93edfadc6a080 languageName: node linkType: hard @@ -11398,17 +11398,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-logger@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/plugin-logger@npm:3.9.0-next.1" +"@webex/plugin-logger@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/plugin-logger@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/test-helper-chai": "npm:3.8.1-next.11" "@webex/test-helper-mocha": "npm:3.8.1-next.11" "@webex/test-helper-mock-webex": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" + "@webex/webex-core": "npm:3.9.0-next.3" lodash: "npm:^4.17.21" - checksum: 10c0/5937a116e57d7d63aacec25747c119a599cefec4ca3327165e3a9f23d79b12467501c93d6c92b50590614d6d80b75135a6e4e21a7f1d158342e50b5543b7027b + checksum: 10c0/81ea37b4ade7fe5b25eeafd7b93beec1a66d5e88d38e724f5fe7256b5520973799655247a61ce74f78aa03ad37f8a46d9d7649eb78edabaf8c8ebc8d6f6259fb languageName: node linkType: hard @@ -11888,14 +11888,14 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-local-storage@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/storage-adapter-local-storage@npm:3.9.0-next.1" +"@webex/storage-adapter-local-storage@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/storage-adapter-local-storage@npm:3.9.0-next.3" dependencies: "@webex/storage-adapter-spec": "npm:3.8.1-next.11" "@webex/test-helper-mocha": "npm:3.8.1-next.11" - "@webex/webex-core": "npm:3.9.0-next.1" - checksum: 10c0/22c19a4d768841ab08db9d3e0ec8b6063f9614dca1ed77c352b6b6e47a15f3f2b600ffa96b59c48128fb622cfa57d9389be9326c4d153210926c688b855830bb + "@webex/webex-core": "npm:3.9.0-next.3" + checksum: 10c0/024963fd645e62236823b0437e504c22b566ce31f9712dee3def054c0b3a70cdd4a285f9b247d75515d5443847f77bb8172a844e6867a80dc0dfd37df4d83a51 languageName: node linkType: hard @@ -12606,9 +12606,9 @@ __metadata: languageName: node linkType: hard -"@webex/webex-core@npm:3.9.0-next.1": - version: 3.9.0-next.1 - resolution: "@webex/webex-core@npm:3.9.0-next.1" +"@webex/webex-core@npm:3.9.0-next.3": + version: 3.9.0-next.3 + resolution: "@webex/webex-core@npm:3.9.0-next.3" dependencies: "@webex/common": "npm:3.8.1-next.11" "@webex/common-timers": "npm:3.8.1-next.11" @@ -12622,7 +12622,7 @@ __metadata: jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/ee499fa7cbf5d6ded9284b5d982ac8a70dfa6bd9c634b24b206a1e33e8717db4c7c5ed53850fc59787a331c98211db4efb2e6da84aa5c156220e919786277bb7 + checksum: 10c0/bbe29938dd464232278a7edbb6bb8d7d826f0df0f5795db767faeb69330865b2c7a3c2aa69b8a36923be0d8df117dbd9d452084016b0ce2746e9d1d3a1916977 languageName: node linkType: hard