diff --git a/src/components/HelpMenu/businessHours.ts b/src/components/HelpMenu/businessHours.ts new file mode 100644 index 000000000..0d5b2f695 --- /dev/null +++ b/src/components/HelpMenu/businessHours.ts @@ -0,0 +1,174 @@ +import React from "react"; + +/** + * Business Hours Management + * + * This module handles business hours calculation, tracking, and formatting + * for the help/support chat system. It determines when chat support is + * available and formats the hours for display to users. + */ + +export interface BusinessHours { + startTime: number; // Unix timestamp in milliseconds + endTime: number; // Unix timestamp in milliseconds +} + +export interface BusinessHoursResponse { + businessHoursInfo: { + businessHours: BusinessHours[]; + }; + timestamp?: number; +} + +/** + * Finds the currently active business hours window, if any. + * + * The grace period extends the hours window slightly before/after to handle + * edge cases and provide a better user experience. + * + * @param hoursResponse - Business hours data from the server + * @param gracePeriod - Milliseconds to extend the hours window (default: 5 seconds) + * @returns The current business hours window, or undefined if outside hours + */ +const findCurrentBusinessHours = ( + hoursResponse: BusinessHoursResponse | undefined, + gracePeriod: number, +): BusinessHours | undefined => { + if (!hoursResponse) return undefined; + + const now = Date.now(); + const { businessHoursInfo: { businessHours } } = hoursResponse; + + // Find a hours window that encompasses the current time (with grace period) + return businessHours.find( + (h) => h.startTime - gracePeriod <= now && now < h.endTime + gracePeriod, + ); +}; + +/** + * React hook that tracks current business hours and automatically updates + * when the hours window ends. + * + * This hook: + * 1. Determines if we're currently within business hours + * 2. Sets a timeout to clear the hours when they end + * 3. Uses smart comparison to avoid unnecessary re-renders + * + * @param hoursResponse - Business hours data from the server + * @param gracePeriod - Milliseconds to extend the hours window (default: 5 seconds) + * @returns The current business hours window, or undefined if outside hours + */ +export const useBusinessHours = ( + hoursResponse: BusinessHoursResponse | undefined, + gracePeriod = 5_000, +): BusinessHours | undefined => { + const timeoutRef = React.useRef(); + const [hours, setHours] = React.useState(); + + React.useEffect(() => { + const nextState = findCurrentBusinessHours(hoursResponse, gracePeriod); + + // Clear any existing timeout + clearTimeout(timeoutRef.current); + + // If we're in business hours, set a timeout to unset them when they end + if (nextState !== undefined) { + // Schedule the update for end time, or at least 1 second in the future + const dT = Math.max(nextState.endTime - Date.now(), 1000); + timeoutRef.current = setTimeout(() => { + setHours(undefined); + }, dT); + } + + // Only update state if the hours actually changed + // This prevents unnecessary re-renders when the effect runs + setHours((prev) => + prev !== undefined && + nextState !== undefined && + prev.startTime === nextState.startTime && + prev.endTime === nextState.endTime + ? prev // Keep the same object reference if times haven't changed + : nextState, + ); + + // Cleanup: clear timeout when component unmounts or effect re-runs + return () => { + clearTimeout(timeoutRef.current); + }; + }, [hoursResponse, gracePeriod]); + + return hours; +}; + +/** + * Formats a business hours time range for display to users. + * + * Uses the Intl.DateTimeFormat API for proper localization. + * Falls back to simple hour display if Intl is not available. + * + * @param startTime - Unix timestamp in milliseconds + * @param endTime - Unix timestamp in milliseconds + * @returns Formatted string like "9 AM - 5 PM CDT" or empty string if invalid + * + * @example + * formatBusinessHoursRange(1609502400000, 1609531200000) + * // Returns: "9 AM - 5 PM CST" + */ +export const formatBusinessHoursRange = (startTime: number, endTime: number): string => { + const startDate = new Date(startTime); + const endDate = new Date(endTime); + + // Validate that we have real timestamps + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return ""; + } + + try { + // Use Intl.DateTimeFormat for proper localized formatting + const baseOptions: Intl.DateTimeFormatOptions = { + hour: "numeric", + hour12: true, + }; + + const start = new Intl.DateTimeFormat(undefined, baseOptions).format(startDate); + const end = new Intl.DateTimeFormat(undefined, { + ...baseOptions, + timeZoneName: "short", // Include timezone in end time + }).format(endDate); + + // Example output: "9 AM - 5 PM CDT" + return `${start} - ${end}`; + } catch (e) { + // Fallback for environments without Intl support + console.warn("Intl.DateTimeFormat not available, falling back to simple hours.", e); + // Example output: "9 - 17" + return `${startDate.getHours()} - ${endDate.getHours()}`; + } +}; + +/** + * React hook that provides a formatted hours range string. + * + * Combines useBusinessHours with formatBusinessHoursRange to provide + * a ready-to-display string for the UI. + * + * @param businessHours - Business hours data from the server + * @param gracePeriod - Optional grace period for hours window + * @returns Formatted hours string, or undefined if outside business hours + * + * @example + * const hoursDisplay = useHoursRange(businessHoursData); + * // Returns: "9 AM - 5 PM CDT" or undefined + */ +export const useHoursRange = ( + businessHours: BusinessHoursResponse | undefined, + gracePeriod?: number, +): string | undefined => { + const hours = useBusinessHours(businessHours, gracePeriod); + + // Memoize the formatted string to avoid recalculating on every render + return React.useMemo( + () => (hours ? formatBusinessHoursRange(hours.startTime, hours.endTime) : undefined), + [hours], + ); +}; diff --git a/src/components/HelpMenu/chatController.ts b/src/components/HelpMenu/chatController.ts new file mode 100644 index 000000000..c852e0807 --- /dev/null +++ b/src/components/HelpMenu/chatController.ts @@ -0,0 +1,198 @@ +import React from "react"; +import { getPreChatFields } from "./fieldMapping"; + +/** + * Chat Controller + * + * This module manages the popup window for the chat system and handles + * communication between the parent window and the chat popup using postMessage. + * + * The chat system opens in a popup window positioned at the bottom-right of + * the screen, similar to common chat widgets. + */ + +/** + * Configuration for the chat popup window dimensions and positioning. + */ +const POPUP_CONFIG = { + width: 500, + height: 800, +} as const; + +/** + * Calculates the position for a popup window at the bottom-right of the screen. + * + * @returns Object with top and left pixel coordinates + */ +const calculateBottomRightPosition = () => { + // Get the current window's position and size + // screenX/screenY are more reliable than screenLeft/screenTop + const rightX = (window.screenX || window.screenLeft) + window.outerWidth; + const bottomY = (window.screenY || window.screenTop) + window.outerHeight; + + // Position popup at bottom-right + const top = bottomY - POPUP_CONFIG.height; + const left = rightX - POPUP_CONFIG.width; + + return { top, left }; +}; + +/** + * Generates the options string for window.open() to create a popup. + * + * @returns Formatted options string like "popup=true,width=500,height=800,top=100,left=200" + */ +const createPopupOptions = (): string => { + const position = calculateBottomRightPosition(); + + const options = { + popup: true, + width: POPUP_CONFIG.width, + height: POPUP_CONFIG.height, + ...position, + }; + + return Object.entries(options) + .map(([k, v]) => `${k}=${v}`) + .join(","); +}; + +/** + * Hook that manages postMessage communication with the chat popup. + * + * Provides a safe way to send messages to the popup window with + * origin validation. + * + * @param popup - Ref to the popup window + * @param path - Full URL of the chat embed (used to determine origin) + * @returns Function to send messages to the popup + */ +const usePostMessageChannel = ( + popup: React.MutableRefObject, + path: string | undefined, +) => { + // Extract and memoize the origin from the chat embed path + // This is used to validate messages and restrict postMessage target + const popupOrigin = React.useMemo( + () => (path ? new URL(path).origin : undefined), + [path], + ); + + // Create a memoized function to send messages to the popup + const sendMessage = React.useCallback( + (message: { type: string; data?: T }) => { + // Safety checks: popup must exist and be open, and we must have an origin + if (!popup.current || !popupOrigin) return; + + // Send the message with origin restriction for security + popup.current.postMessage(message, popupOrigin); + }, + [popup, popupOrigin], + ); + + return { sendMessage, popupOrigin }; +}; + +/** + * Hook that manages the chat popup window lifecycle and communication. + * + * Responsibilities: + * - Opens the popup window + * - Manages the popup lifecycle (creation, messaging, cleanup) + * - Handles bidirectional communication via postMessage + * - Sends pre-chat fields when the popup is ready + * - Polls for popup closure and cleans up event listeners + * + * @param path - URL to the chat embed page + * @param preChatFields - Pre-populated form fields to send to the chat + * @returns Object with openChat function, or empty object if path is not provided + */ +export const useChatController = ( + path: string | undefined, + preChatFields: ReturnType, +) => { + // Store reference to the popup window + const popup = React.useRef(null); + + const { sendMessage } = usePostMessageChannel(popup, path); + + /** + * Sends the pre-chat fields to the popup. + * This is called when the popup signals it's ready. + */ + const sendPreChatFields = React.useCallback(() => { + sendMessage({ type: "preChatFields", data: preChatFields }); + }, [sendMessage, preChatFields]); + + /** + * Initializes the chat popup with fields and opens the chat interface. + * Called when the popup sends a "ready" message. + */ + const init = React.useCallback(() => { + sendPreChatFields(); + sendMessage({ type: "open" }); + }, [sendMessage, sendPreChatFields]); + + /** + * Opens the chat popup window. + * + * This function: + * 1. Checks if a popup is already open (prevents duplicates) + * 2. Creates a new popup window positioned at bottom-right + * 3. Sets up message listener for popup communication + * 4. Polls for popup closure to clean up + */ + const openChat = React.useCallback(() => { + // Prevent opening multiple popups or opening without a path + if (popup.current || !path) return; + + // Open the popup window with calculated position + const options = createPopupOptions(); + popup.current = window.open(path, "_blank", options); + + // If popup was blocked by browser, bail out + if (!popup.current) return; + + /** + * Handles messages from the popup window. + * Currently listens for "ready" message to initialize the chat. + */ + const handleMessage = (e: MessageEvent) => { + const { source, data: { type } } = e; + + // Security: only process messages from our popup + if (source !== popup.current) return; + + // Initialize chat when popup signals ready + if (type === "ready") init(); + }; + + /** + * Polls to detect when the popup is closed by the user. + * Cleans up event listeners when closed. + */ + const checkClosed = setInterval(() => { + if ((popup.current as Window).closed) { + // Cleanup: remove message listener + window.removeEventListener("message", handleMessage, false); + popup.current = null; + clearInterval(checkClosed); + } + }, 500); // Check every 500ms + + // Set up the message listener + window.addEventListener("message", handleMessage, false); + }, [path, init]); + + /** + * Effect: Re-send pre-chat fields if they change while popup is open. + * This ensures the popup always has the latest field values. + */ + React.useEffect(() => { + sendPreChatFields(); + }, [sendPreChatFields]); + + // Only return the openChat function if we have a valid path + // This makes it easy for consumers to check if chat is available + return path ? { openChat } : {}; +}; diff --git a/src/components/HelpMenu/fieldMapping.ts b/src/components/HelpMenu/fieldMapping.ts new file mode 100644 index 000000000..7dfa80df2 --- /dev/null +++ b/src/components/HelpMenu/fieldMapping.ts @@ -0,0 +1,133 @@ +/** + * Field Mapping Utilities + * + * This module handles the mapping between application field names and + * Salesforce field names for the help/support system pre-chat forms. + * + * The mapping is designed to work with data from multiple sources: + * - Assignable platform (context, assignment, deployment IDs) + * - OpenStax Accounts (user information) + */ + +/** + * Mapping configuration from application field names to Salesforce field names. + * These field mappings are synchronized with: + * assignments/packages/frontend/src/components/SupportInfo.tsx + */ +const HIDDEN_FIELDS_MAPPING = [ + ["assignmentId", "Assignment_Id"], + ["contextId", "Context_Id"], + ["deploymentId", "Deployment_Id"], + ["platformId", "Platform_Id"], + ["registration", "Registration_Id"], + ["organizationName", "School"], + ["userEmail", "Email"], + ["userFirstName", "First_Name"], + ["userId", "OpenStax_UUID"], + ["userLastName", "Last_Name"], +] as const; + +/** + * Maps application field names to Salesforce hidden fields. + * Hidden fields are pre-populated but not shown to the end user. + * + * @param supportInfoMapping - Object containing application field values + * @returns Object with Salesforce field names as keys and their values + */ +export const mapHiddenFields = (supportInfoMapping: { [key: string]: string }) => + Object.fromEntries( + HIDDEN_FIELDS_MAPPING + .map(([fromKey, toKey]) => [toKey, supportInfoMapping[fromKey]]) + // Filter out entries where either key or value is missing + .filter( + (tuple): tuple is [string, string] => + typeof tuple[0] === "string" && typeof tuple[1] === "string", + ), + ); + +/** + * Checks if a value is a non-empty string. + * Used to determine if form fields should be editable. + */ +const isValidString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +/** + * Parses a full name into first and last name components. + * Falls back to splitting on spaces if discrete firstName/lastName not available. + * + * @param userName - Full name string (e.g., "John Doe") + * @param userFirstName - Optional discrete first name from accounts + * @param userLastName - Optional discrete last name from accounts + * @returns Tuple of [firstName, lastName] + */ +const parseName = ( + userName: string | undefined, + userFirstName: string | undefined, + userLastName: string | undefined, +): [string, string] => { + const nameParts = userName?.split(" ") ?? []; + + // If we have discrete first/last names from accounts, use those + // Otherwise, parse from userName: everything except last word is first name + const firstName = userFirstName ?? nameParts.slice(0, -1).join(" "); + // Last word is assumed to be last name (no middle name handling) + const lastName = userLastName ?? nameParts.slice(-1).join(""); + + return [firstName, lastName]; +}; + +/** + * Maps application field names to Salesforce visible fields. + * Visible fields are shown in the pre-chat form and may be editable. + * + * Fields prefixed with '_' are standard Salesforce fields (non-custom). + * Fields without prefix are custom Salesforce fields. + * + * Editability logic: + * - If we receive user data from Accounts, fields are read-only + * - If data is missing, fields are editable so user can provide it + * - School field is always editable + * + * @param supportInfoMapping - Object containing application field values + * @returns Object with field configurations including value and editability + */ +export const mapVisibleFields = (supportInfoMapping: { [key: string]: string }) => { + const { userName, userFirstName, userLastName, userEmail, organizationName } = supportInfoMapping; + + const [firstName, lastName] = parseName(userName, userFirstName, userLastName); + + // Define visible fields with their values and editability + const visibleEntries: [string, string, boolean][] = [ + ["_firstName", firstName, !isValidString(userFirstName)], + ["_lastName", lastName, !isValidString(userLastName)], + ["_email", userEmail ?? "", !isValidString(userEmail)], + ["School", organizationName ?? "", true], // School is always editable + ]; + + return Object.fromEntries( + visibleEntries.map(([key, value, isEditableByEndUser]) => [ + key, + { value, isEditableByEndUser }, + ]), + ); +}; + +/** + * Transforms contact form parameters into pre-chat field configuration. + * This is the main entry point for preparing form data to send to Salesforce. + * + * @param contactFormParams - Array of key-value pairs from the contact form + * @returns Object containing both visible and hidden fields for the chat system + */ +export const getPreChatFields = (contactFormParams: { key: string; value: string }[]) => { + // Convert array of {key, value} pairs to a lookup object + const supportInfoMapping = Object.fromEntries( + contactFormParams.map(({ key, value }) => [key, value]), + ); + + return { + visibleFields: mapVisibleFields(supportInfoMapping), + hiddenFields: mapHiddenFields(supportInfoMapping), + }; +}; diff --git a/src/components/HelpMenu/hooks.spec.tsx b/src/components/HelpMenu/hooks.spec.tsx index c71ddc283..3c3d91b2c 100644 --- a/src/components/HelpMenu/hooks.spec.tsx +++ b/src/components/HelpMenu/hooks.spec.tsx @@ -110,14 +110,80 @@ describe('useBusinessHours', () => { const response = makeResponse({ hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end)) }); - + const { unmount } = renderHook(() => useBusinessHours(response, 5000) ); - + unmount(); expect(clearTimeoutSpy).toHaveBeenCalled(); // ensure the cleanup cleared the timer }); + + it('does not set a timeout when nextState is undefined (not in business hours)', () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const start = Date.now() + 10000; + const end = Date.now() + 20000; + const response = makeResponse({ + hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end)) + }); + + renderHook(() => + useBusinessHours(response, 0) + ); + + // When not in business hours (nextState is undefined), no timeout should be set + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('updates state only when hours actually change (reuses same object reference)', () => { + const start = Date.now() - 1000; + const end = Date.now() + 1000; + const response = makeResponse({ + hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end)) + }); + + const { result, rerender } = renderHook(() => + useBusinessHours(response, 0) + ); + + const firstResult = result.current; + expect(firstResult).toBeDefined(); + + // Rerender with the same response + rerender(); + + // Should return the same object reference (not a new object) + expect(result.current).toBe(firstResult); + }); + + it('returns new object reference when hours don\'t change (line 90 else branch)', () => { + const now = Date.now(); + const start1 = now - 1000; + const end1 = now + 1000; + const response1 = makeResponse({ + hours: makeBusinessHoursResponse(now, makeBusinessHours(start1, end1)) + }); + + const { result, rerender } = renderHook( + ({ response }) => useBusinessHours(response, 0), + { initialProps: { response: response1 } } + ); + + const firstResult = result.current; + expect(firstResult).toBeDefined(); + expect(firstResult).toEqual({ startTime: start1, endTime: end1 }); + + // Same times should return the same object + const response2 = makeResponse({ + hours: makeBusinessHoursResponse(now, makeBusinessHours(start1, end1)) + }); + + // Rerender with the new response + rerender({ response: response2 }); + + // Should return a new object reference because the hours changed + expect(result.current).toBe(firstResult); + }); }); describe('formatBusinessHoursRange', () => { @@ -571,4 +637,96 @@ describe('useChatController', () => { expect(firstPopup.postMessage).toHaveBeenCalledTimes(0); }); + + /** 2.9. `openChat` handles popup blocking gracefully (line 154) */ + it('handles popup blocking gracefully when window.open returns null', () => { + // Mock window.open to return null (simulating popup blocker) + mockOpen.mockReturnValue(null); + + const { result } = renderHook(() => useChatController(path, preChatFields)); + + // Clear any existing calls (from other code) + mockAddEventListener.mockClear(); + mockSetInterval.mockClear(); + + // This should not throw an error + act(() => { + result.current.openChat?.(); + }); + + // Verify that no message listeners were added since popup failed + const messageListenerCalls = mockAddEventListener.mock.calls.filter( + (call) => call[0] === 'message' + ); + expect(messageListenerCalls).toHaveLength(0); + + // Verify that no interval was set since popup failed + expect(mockSetInterval).not.toHaveBeenCalled(); + }); + + /** 2.10. `handleMessage` ignores messages from sources other than the popup (line 164) */ + it('ignores messages from sources other than the opened popup window', () => { + const mockPopup = createMockPopup(); + mockOpen.mockReturnValue(mockPopup); + + const { result } = renderHook(() => useChatController(path, preChatFields)); + + act(() => { + result.current.openChat?.(); + }); + + // Verify message listener was added + expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function), false); + + // Create a message event from a different source (not our popup) + const differentSource = createMockPopup(); + const event: MessageEvent = { + source: differentSource, // Different source than mockPopup + data: { type: 'ready' } as any, + } as any; + + // Get the handleMessage callback + const handleMessage = mockAddEventListener.mock.calls.find( + (args) => args[0] === 'message' + )?.[1]; + expect(handleMessage).toBeDefined(); + + act(() => { + handleMessage(event); + }); + + // Verify that postMessage was NOT called on our popup + // because the message came from a different source + expect(mockPopup.postMessage).not.toHaveBeenCalled(); + }); + + /** 2.11. Tests the else branch of line 175 - when popup is still open (not closed) */ + it('continues polling when popup is still open (line 175 else branch)', () => { + const mockPopup = createMockPopup(); + mockOpen.mockReturnValue(mockPopup); + + const { result } = renderHook(() => useChatController(path, preChatFields)); + + act(() => { + result.current.openChat?.(); + }); + + // Get the checkClosed callback from setInterval + const checkClosed = mockSetInterval.mock.calls[0][0]; + + // Simulate the interval running while popup is still open (closed = false) + mockPopup.closed = false; + + // Clear mocks to verify what happens in this tick + mockRemoveEventListener.mockClear(); + mockClearInterval.mockClear(); + + act(() => { + checkClosed(); + }); + + // When popup is NOT closed, cleanup should NOT happen + expect(mockRemoveEventListener).not.toHaveBeenCalled(); + expect(mockClearInterval).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/HelpMenu/hooks.ts b/src/components/HelpMenu/hooks.ts index 9ecd1d8c4..371b2eb62 100644 --- a/src/components/HelpMenu/hooks.ts +++ b/src/components/HelpMenu/hooks.ts @@ -1,230 +1,36 @@ -import React from "react"; - +/** + * HelpMenu Hooks - Main Export File + * + * This file serves as the main export point for all HelpMenu-related + * functionality, maintaining backward compatibility while organizing + * code into focused, single-responsibility modules. + * + * The functionality is split into three main areas: + * 1. Field Mapping (fieldMapping.ts) - Transforms application data to Salesforce fields + * 2. Business Hours (businessHours.ts) - Manages and formats support hours + * 3. Chat Controller (chatController.ts) - Manages the chat popup window + */ + +// Re-export all types and functions to maintain backward compatibility +export type { BusinessHours, BusinessHoursResponse } from "./businessHours"; +export { + useBusinessHours, + formatBusinessHoursRange, + useHoursRange, +} from "./businessHours"; + +export { getPreChatFields } from "./fieldMapping"; + +export { useChatController } from "./chatController"; + +// Legacy interface exports for backward compatibility export interface ApiError { type: string; detail: string; } -export interface BusinessHours { - startTime: number; - endTime: number; -} - -export interface BusinessHoursResponse { - businessHoursInfo: { - businessHours: BusinessHours[]; - }; - timestamp?: number; -} - export interface ChatConfiguration { chatEmbedPath: string; - businessHours?: BusinessHoursResponse; + businessHours?: import("./businessHours").BusinessHoursResponse; err?: ApiError; } - -// map assignable field name to Salesforce field name -// These are currently defined in: -// assignments/packages/frontend/src/components/SupportInfo.tsx -const hiddenFieldsMapping = [ - ["assignmentId", "Assignment_Id"], - ["contextId", "Context_Id"], - ["deploymentId", "Deployment_Id"], - ["platformId", "Platform_Id"], - ["registration", "Registration_Id"], - ["organizationName", "School"], - ["userEmail", "Email"], - ["userFirstName", "First_Name"], - ["userId", "OpenStax_UUID"], - ["userLastName", "Last_Name"], -]; - -const mapHiddenFields = (supportInfoMapping: { [key: string]: string }) => - Object.fromEntries( - hiddenFieldsMapping - .map(([fromKey, toKey]) => [toKey, supportInfoMapping[fromKey]]) - .filter( - (tuple): tuple is [string, string] => - typeof tuple[0] === "string" && typeof tuple[1] === "string", - ), - ); - -const mapVisibleFields = (supportInfoMapping: { [key: string]: string }) => { - // userFirstName, userLastName are from accounts - const { userName, userFirstName, userLastName, userEmail, organizationName } = supportInfoMapping; - const nameParts = userName?.split(" ") ?? []; - // Multiple first names? - const firstName = userFirstName ?? nameParts.slice(0, -1).join(" "); - // Hopefully no middle name - const lastName = userLastName ?? nameParts.slice(-1).join(""); - // Fields that start with '_' are standard, non-custom fields - // If we don't get the info from accounts, then the field should be editable - const isValid = (s: unknown) => typeof s === 'string' && s.length > 0; - const visibleEntries: [string, string, boolean][] = [ - ["_firstName", firstName, !isValid(userFirstName)], - ["_lastName", lastName, !isValid(userLastName)], - ["_email", userEmail ?? "", !isValid(userEmail)], - ["School", organizationName ?? "", true], - ]; - return Object.fromEntries( - visibleEntries.map(([key, value, isEditableByEndUser]) => [ - key, - { value, isEditableByEndUser }, - ]), - ); -}; - -export const getPreChatFields = (contactFormParams: { key: string; value: string }[]) => { - const supportInfoMapping = Object.fromEntries( - contactFormParams.map(({ key, value }) => [key, value]), - ); - return { - visibleFields: mapVisibleFields(supportInfoMapping), - hiddenFields: mapHiddenFields(supportInfoMapping), - }; -}; - -export const useBusinessHours = ( - hoursResponse: ChatConfiguration["businessHours"] | undefined, - gracePeriod = 5_000, -) => { - const timeoutRef = React.useRef(); - const [hours, setHours] = React.useState(); - - React.useEffect(() => { - let nextState: BusinessHours | undefined; - if (hoursResponse !== undefined) { - const now = Date.now(); - const { businessHoursInfo: { businessHours } } = hoursResponse; - nextState = businessHours.find( - (h) => h.startTime - gracePeriod <= now && now < h.endTime + gracePeriod, - ); - } - clearTimeout(timeoutRef.current); - if (nextState !== undefined) { - const dT = Math.max(nextState.endTime - Date.now(), 1000); - // Unset business hours at the end time - timeoutRef.current = setTimeout(() => { - setHours(undefined); - }, dT); - } - setHours((prev) => - prev !== undefined && - prev.startTime === nextState?.startTime && - prev.endTime === nextState?.endTime - ? prev - : nextState, - ); - return () => { - clearTimeout(timeoutRef.current); - }; - }, [hoursResponse, gracePeriod]); - - return hours; -}; - -export const formatBusinessHoursRange = (startTime: number, endTime: number) => { - // Ensure we are working with a real Date instance - const startDate = new Date(startTime); - const endDate = new Date(endTime); - - // Bail if the timestamps are not valid numbers - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return ""; - - try { - const baseOptions: Parameters[1] = { - hour: "numeric", - hour12: true, - }; - const start = new Intl.DateTimeFormat(undefined, baseOptions).format(startDate); - const end = new Intl.DateTimeFormat(undefined, { - ...baseOptions, - timeZoneName: "short", - }).format(endDate); - // Ex: 9 AM - 5 PM CDT - return `${start} - ${end}`; - } catch (e) { - console.warn("Intl.DateTimeFormat not available, falling back to simple hours.", e); - // Ex: 9 - 17 - return `${startDate.getHours()} - ${endDate.getHours()}`; - } -}; - -export const useHoursRange = ( - businessHours: ChatConfiguration["businessHours"], - gracePeriod?: number, -) => { - const hours = useBusinessHours(businessHours, gracePeriod); - return React.useMemo(() => ( - hours ? formatBusinessHoursRange(hours.startTime, hours.endTime) : undefined - ), [hours]); -}; - -export const useChatController = ( - path: string | undefined, - preChatFields: ReturnType, -) => { - const popup = React.useRef(null); - const popupOrigin = React.useMemo(() => ( - path ? new URL(path).origin : undefined - ), [path]); - - const sendMessage = React.useCallback( - (message: { type: string; data?: T }) => { - if (!popup.current || !popupOrigin) return; - popup.current.postMessage(message, popupOrigin); - }, - [popupOrigin], - ); - - const sendPreChatFields = React.useCallback(() => { - sendMessage({ type: "preChatFields", data: preChatFields }); - }, [sendMessage, preChatFields]); - - const init = React.useCallback(() => { - sendPreChatFields(); - sendMessage({ type: "open" }); - }, [sendMessage, sendPreChatFields]); - - const openChat = React.useCallback(() => { - if (popup.current || !path) return; - const width = 500; - const height = 800; - - // Calculate Bottom-Right Position - const rightX = (window.screenX || window.screenLeft) + window.outerWidth; - const bottomY = (window.screenY || window.screenTop) + window.outerHeight; - const top = bottomY - height; - const left = rightX - width; - - const options = Object.entries({ popup: true, width, height, top, left }) - .map(([k, v]) => `${k}=${v}`) - .join(","); - popup.current = window.open(path, "_blank", options); - - if (!popup.current) return; - - const handleMessage = (e: MessageEvent) => { - const { source, data: { type } } = e; - if (source !== popup.current) return; - if (type === "ready") init(); - }; - - const checkClosed = setInterval(() => { - if (popup.current?.closed) { - window.removeEventListener("message", handleMessage, false); - popup.current = null; - clearInterval(checkClosed); - } - }, 500); - - window.addEventListener("message", handleMessage, false); - }, [path, init]); - - // Send pre-chat fields again immediately if they change - React.useEffect(() => { - sendPreChatFields(); - }, [sendPreChatFields]); - - return path ? { openChat } : {}; -}; diff --git a/src/components/HelpMenu/index.spec.tsx b/src/components/HelpMenu/index.spec.tsx index 4d7909c07..45b8e05ee 100644 --- a/src/components/HelpMenu/index.spec.tsx +++ b/src/components/HelpMenu/index.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext'; -import { HelpMenu, HelpMenuItem, HelpMenuProps } from '.'; +import { HelpMenu, HelpMenuItem, HelpMenuProps, NewTabIcon } from '.'; import { NavBar } from '../NavBar'; import { ChatConfiguration } from './hooks'; @@ -79,7 +79,7 @@ describe('HelpMenu', () => { }; const chatEmbedPath = 'https://example.com/'; const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, businessHours: happyHoursResponse}; - + render( @@ -94,4 +94,291 @@ describe('HelpMenu', () => { fireEvent.click(await screen.findByText('Help')); await screen.findByRole('menuitem', { name: /chat with us/i }); }); + + it('calls openChat when Chat With Us is clicked', async () => { + const happyHoursResponse: ChatConfiguration['businessHours'] = { + businessHoursInfo: { + businessHours: [ + { startTime: Date.now() - 60_000, endTime: Date.now() + 1_440_000 } + ] + }, + timestamp: Date.now(), + }; + const chatEmbedPath = 'https://example.com/chat'; + const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, businessHours: happyHoursResponse}; + + // Mock window.open to verify it's called by openChat + const mockWindowOpen = jest.spyOn(window, 'open').mockReturnValue({ + closed: false, + postMessage: jest.fn(), + } as any); + + render( + + + + + + ); + + // Open menu + fireEvent.click(await screen.findByText('Help')); + + // Click Chat With Us + const chatButton = await screen.findByRole('menuitem', { name: /chat with us/i }); + fireEvent.click(chatButton); + + // Verify window.open was called with chat embed path + expect(mockWindowOpen).toHaveBeenCalledWith( + chatEmbedPath, + '_blank', + expect.stringContaining('popup=true') + ); + + mockWindowOpen.mockRestore(); + }); + + it('shows and hides iframe when Report an issue is clicked', async () => { + render( + + + + window.alert('Ran HelpMenu callback function')}> + Test Callback + + + + + ); + + // Open the menu + fireEvent.click(await screen.findByText('Help')); + + // Click "Report an issue" + const reportButton = await screen.findByRole('menuitem', { name: /report an issue/i }); + fireEvent.click(reportButton); + + // Verify iframe is shown with correct URL encoding + const iframe = await screen.findByTitle('Contact form'); + expect(iframe.getAttribute('src')).toContain('https://openstax.org/embedded/contact'); + expect(iframe.getAttribute('src')).toContain('body=userId%3Dtest123'); + expect(iframe.getAttribute('src')).toContain('body=email%3Duser%40example.com'); + + // Verify PutAway button exists and click it to close iframe + const putAwayButton = screen.getByLabelText('close form'); + expect(putAwayButton).toBeTruthy(); + + // Click PutAway to close iframe + fireEvent.click(putAwayButton); + + // Verify iframe is removed + expect(screen.queryByTitle('Contact form')).toBeNull(); + }); + + it('registers message event listener for CONTACT_FORM_SUBMITTED', async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const {unmount} = render( + + + + window.alert('Ran HelpMenu callback function')}> + Test Callback + + + + + ); + + // Verify the message event listener was registered + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'message', + expect.any(Function), + false + ); + + // Unmount and verify cleanup + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'message', + expect.any(Function), + false + ); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it('correctly encodes special characters in contactFormUrl', async () => { + const paramsWithSpecialChars = [ + {key: 'name', value: 'Test & User'}, + {key: 'message', value: 'Hello=World?'}, + {key: 'special', value: 'a+b c/d'}, + ]; + + render( + + + + window.alert('Ran HelpMenu callback function')}> + Test Callback + + + + + ); + + // Open the menu and click Report an issue + fireEvent.click(await screen.findByText('Help')); + const reportButton = await screen.findByRole('menuitem', { name: /report an issue/i }); + fireEvent.click(reportButton); + + // Verify iframe URL encodes special characters + const iframe = await screen.findByTitle('Contact form'); + const src = iframe.getAttribute('src'); + expect(src).toContain('body=name%3DTest%20%26%20User'); + expect(src).toContain('body=message%3DHello%3DWorld%3F'); + expect(src).toContain('body=special%3Da%2Bb%20c%2Fd'); + }); + + it('exports NewTabIcon component', () => { + // The NewTabIcon is exported for use in other components + expect(NewTabIcon).toBeDefined(); + expect(typeof NewTabIcon).toBe('function'); + }); + + it('renders NewTabIcon with correct SVG attributes', () => { + const { container } = render(); + + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute('role')).toBe('img'); + expect(svg?.getAttribute('width')).toBe('12'); + expect(svg?.getAttribute('height')).toBe('11'); + + const title = container.querySelector('title'); + expect(title?.textContent).toBe('new tab'); + + const path = container.querySelector('path'); + expect(path).toBeTruthy(); + }); + + it('closes iframe when CONTACT_FORM_SUBMITTED message is received', async () => { + render( + + + + window.alert('Ran HelpMenu callback function')}> + Test Callback + + + + + ); + + // Open the menu and show iframe + fireEvent.click(await screen.findByText('Help')); + const reportButton = await screen.findByRole('menuitem', { name: /report an issue/i }); + fireEvent.click(reportButton); + + // Verify iframe is shown + expect(screen.getByTitle('Contact form')).toBeTruthy(); + + // Simulate the CONTACT_FORM_SUBMITTED message event + const messageEvent = new MessageEvent('message', { + data: 'CONTACT_FORM_SUBMITTED' + }); + window.dispatchEvent(messageEvent); + + // Verify iframe is closed + expect(screen.queryByTitle('Contact form')).toBeNull(); + }); + + it('renders custom children in the help menu', async () => { + const customAction = jest.fn(); + + render( + + + + + Custom Action Item + + + + + ); + + // Open the menu + fireEvent.click(await screen.findByText('Help')); + + // Verify custom child is rendered + const customItem = await screen.findByRole('menuitem', { name: /custom action item/i }); + expect(customItem).toBeTruthy(); + + // Click it and verify callback is invoked + fireEvent.click(customItem); + expect(customAction).toHaveBeenCalledTimes(1); + }); + + it('memoizes chatConfig correctly', async () => { + const chatEmbedPath = 'https://example.com/'; + const businessHours: ChatConfiguration['businessHours'] = { + businessHoursInfo: { + businessHours: [ + { startTime: Date.now() - 60_000, endTime: Date.now() + 1_440_000 } + ] + }, + timestamp: Date.now(), + }; + const chatConfig: HelpMenuProps['chatConfig'] = { chatEmbedPath, businessHours }; + + const { rerender } = render( + + + + + + ); + + // Open menu and verify chat option appears + fireEvent.click(await screen.findByText('Help')); + await screen.findByRole('menuitem', { name: /chat with us/i }); + + // Rerender with same chatConfig object (should use memoized value) + rerender( + + + + + + ); + + // Verify chat option still appears + await screen.findByRole('menuitem', { name: /chat with us/i }); + }); + + it('handles undefined chatConfig gracefully', async () => { + render( + + + + + + ); + + // Open menu and verify fallback to Report an issue + fireEvent.click(await screen.findByText('Help')); + await screen.findByRole('menuitem', { name: /report an issue/i }); + }); });