diff --git a/package.json b/package.json
index bb5245dee..b551f636b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@openstax/ui-components",
- "version": "1.18.7",
+ "version": "1.19.0",
"license": "MIT",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
@@ -40,6 +40,7 @@
"@types/dompurify": "^3.0.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^12.0.0",
+ "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^28.1.4",
"@types/node": "^18.7.5",
diff --git a/src/components/HelpMenu.spec.tsx b/src/components/HelpMenu.spec.tsx
deleted file mode 100644
index db2d6cc2b..000000000
--- a/src/components/HelpMenu.spec.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { render } from '@testing-library/react';
-import { BodyPortalSlotsContext } from './BodyPortalSlotsContext';
-import { HelpMenu, HelpMenuItem } from './HelpMenu';
-import { NavBar } from './NavBar';
-
-describe('HelpMenu', () => {
- let root: HTMLElement;
-
- beforeEach(() => {
- root = document.createElement('main');
- root.id = 'root';
- document.body.append(root);
- });
-
- it('matches snapshot', () => {
- render(
-
-
-
- window.alert('Ran HelpMenu callback function')}>
- Test Callback
-
-
-
-
- );
-
- expect(document.body).toMatchSnapshot();
- });
-});
diff --git a/src/components/HelpMenu.stories.tsx b/src/components/HelpMenu.stories.tsx
deleted file mode 100644
index 4c7dd2cc3..000000000
--- a/src/components/HelpMenu.stories.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createGlobalStyle } from 'styled-components';
-import { BodyPortalSlotsContext } from './BodyPortalSlotsContext';
-import { HelpMenu, HelpMenuItem } from './HelpMenu';
-import { NavBar } from './NavBar';
-
-const BodyPortalGlobalStyle = createGlobalStyle`
- [data-portal-slot="nav"] {
- position: fixed;
- top: 0;
- width: 100%;
- }
-`;
-
-export const Default = () => {
- return (
-
-
-
-
- window.alert('Ran HelpMenu callback function')}>
- Test Callback
-
-
-
-
- );
-};
diff --git a/src/components/HelpMenu/HelpMenu.stories.tsx b/src/components/HelpMenu/HelpMenu.stories.tsx
new file mode 100644
index 000000000..db90653bb
--- /dev/null
+++ b/src/components/HelpMenu/HelpMenu.stories.tsx
@@ -0,0 +1,46 @@
+import { createGlobalStyle } from 'styled-components';
+import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext';
+import { HelpMenu, HelpMenuItem, HelpMenuProps } from '.';
+import { NavBar } from '../NavBar';
+import { ChatConfiguration } from './hooks';
+
+const BodyPortalGlobalStyle = createGlobalStyle`
+ [data-portal-slot="nav"] {
+ position: fixed;
+ top: 0;
+ width: 100%;
+ }
+`;
+
+const happyHoursResponse: ChatConfiguration['businessHours'] = {
+ businessHoursInfo: {
+ businessHours: [
+ { startTime: Date.now() - 60_000, endTime: Date.now() + 1_440_000 }
+ ]
+ },
+ timestamp: Date.now(),
+};
+
+const contactParams: HelpMenuProps['contactFormParams'] = [
+ { key: 'userId', value: 'test' },
+ { key: 'userFirstName', value: 'test' },
+ { key: 'organizationName', value: 'org' },
+];
+
+const chatEmbedPath = 'https://localhost/assignable-chat';
+const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, businessHours: happyHoursResponse};
+
+export const Default = () => {
+ return (
+
+
+
+
+ window.alert('Ran HelpMenu callback function')}>
+ Test Callback
+
+
+
+
+ );
+};
diff --git a/src/components/__snapshots__/HelpMenu.spec.tsx.snap b/src/components/HelpMenu/__snapshots__/index.spec.tsx.snap
similarity index 61%
rename from src/components/__snapshots__/HelpMenu.spec.tsx.snap
rename to src/components/HelpMenu/__snapshots__/index.spec.tsx.snap
index 1cb9b0f48..19ed30bb2 100644
--- a/src/components/__snapshots__/HelpMenu.spec.tsx.snap
+++ b/src/components/HelpMenu/__snapshots__/index.spec.tsx.snap
@@ -3,6 +3,7 @@
exports[`HelpMenu matches snapshot 1`] = `
-
+
+
+
+
+
`;
diff --git a/src/components/HelpMenu/hooks.spec.tsx b/src/components/HelpMenu/hooks.spec.tsx
new file mode 100644
index 000000000..c71ddc283
--- /dev/null
+++ b/src/components/HelpMenu/hooks.spec.tsx
@@ -0,0 +1,574 @@
+// __tests__/useScript.test.tsx
+import { renderHook } from '@testing-library/react-hooks';
+import { BusinessHours, BusinessHoursResponse, formatBusinessHoursRange, getPreChatFields, useBusinessHours, useChatController, useHoursRange } from './hooks';
+import { act } from 'react-test-renderer';
+
+const makeBusinessHours = (startTime: number, endTime: number): BusinessHours => ({
+ startTime, endTime
+});
+const makeBusinessHoursResponse = (now: number, ...businessHours: BusinessHours[]): BusinessHoursResponse => ({
+ businessHoursInfo: { businessHours },
+ timestamp: now,
+});
+const makeResponse = ({ hours }: { hours: BusinessHoursResponse }) => ({
+ ...hours
+});
+
+describe('useBusinessHours', () => {
+ // Reset fetch before each test
+ beforeEach(() => {
+ global.fetch = undefined as any;
+ jest.clearAllMocks();
+ });
+
+ beforeAll(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(123456);
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ })
+
+ it('uses business hours', async () => {
+ const now = Date.now();
+ const active = makeBusinessHours(now - 1000, now + 5 * 60 * 1000);
+ const inactive = makeBusinessHours(now + 10000, now + 5 * 60 * 1000);
+ const hours = makeBusinessHoursResponse(now, inactive, active);
+ const response = makeResponse({ hours });
+ const timeoutSpy = jest.spyOn(global, 'setTimeout');
+
+ const { result } = renderHook(() => useBusinessHours(response, 0));
+
+ expect(timeoutSpy.mock.lastCall[1]).toBe(active.endTime - now);
+ expect(result.current).toEqual(active);
+ });
+
+ it('returns undefined when no hoursResponse is provided', () => {
+ const { result } = renderHook(() => useBusinessHours(undefined));
+ expect(result.current).toBeUndefined();
+ });
+
+ it('returns the hour while the window is active', () => {
+ const start = Date.now() - 1000;
+ const end = Date.now() + 1000;
+ const response = makeResponse({
+ hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+ });
+
+ const { result } = renderHook(() =>
+ useBusinessHours(response, 0)
+ );
+
+ // The hook runs once immediately
+ expect(result.current).toEqual({ startTime: start, endTime: end });
+
+ // The timeout is scheduled for max(end-now, 1000) → 1s
+ act(() => { jest.advanceTimersByTime(999) });
+ expect(result.current).toEqual({ startTime: start, endTime: end });
+
+ act(() => { jest.advanceTimersByTime(1) });
+ expect(result.current).toBeUndefined(); // timeout cleared the state
+ });
+
+ it('has grace period that allows a match slightly before the start', () => {
+ const gracePeriod = 5000;
+ const start = Date.now() + 4000;
+ const end = Date.now() + 10000;
+ const response = makeResponse({
+ hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+ });
+
+ const { result } = renderHook(() =>
+ useBusinessHours(response, gracePeriod)
+ );
+
+ // Because start – grace <= now, we should still match
+ expect(result.current).toEqual({
+ startTime: start, endTime: end
+ });
+ });
+
+ it('returns undefined when no hour matches the window', () => {
+ const start = Date.now() + 10000;
+ const end = Date.now() + 20000;
+ const response = makeResponse({
+ hours: makeBusinessHoursResponse(Date.now(), makeBusinessHours(start, end))
+ });
+
+ const { result } = renderHook(() =>
+ useBusinessHours(response, 0)
+ );
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it('clears timeout on unmount', () => {
+ const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+ const start = Date.now() - 1000;
+ const end = Date.now() + 1000;
+ 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
+ });
+});
+
+describe('formatBusinessHoursRange', () => {
+ beforeEach(() => {
+ jest.spyOn(console, 'warn').mockImplementation(() => {/* squelch */});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('formats a normal range in 12-hour format with a short TZ for the end', () => {
+ const start = new Date('2023-01-01T09:00:00').getTime(); // 9 AM
+ const end = new Date('2023-01-01T17:00:00').getTime(); // 5 PM
+
+ const result = formatBusinessHoursRange(start, end);
+
+ // We can’t pin down the exact TZ label (it depends on the CI machine),
+ // but we know the shape is: - .
+ // Could be something like CST or maybe GMT-7
+ expect(result).toMatch(/^9\s*AM\s*-\s*5\s*PM\s*[A-Z0-9-]+$/);
+ });
+
+ it('handles noon to midnight correctly', () => {
+ const start = new Date('2023-01-01T12:00:00').getTime(); // 12 PM
+ const end = new Date('2023-01-01T00:00:00').getTime(); // 12 AM next day
+
+ const result = formatBusinessHoursRange(start, end);
+ expect(result).toMatch(/^12\s*PM\s*-\s*12\s*AM\s*[A-Z0-9-]+$/);
+ });
+
+ it('returns an empty string when start or end is NaN', () => {
+ expect(formatBusinessHoursRange(NaN, 123456)).toBe('');
+ expect(formatBusinessHoursRange(123456, NaN)).toBe('');
+ expect(formatBusinessHoursRange(NaN, NaN)).toBe('');
+ });
+
+ it('returns an empty string when timestamps cannot be parsed to a Date', () => {
+ // A value that is a number but is outside the safe integer range
+ const big = Number.MAX_SAFE_INTEGER + 1;
+ expect(formatBusinessHoursRange(big, big)).toBe('');
+ });
+
+ it('falls back to raw hour numbers when Intl.DateTimeFormat throws', () => {
+ // 1. Mock the constructor so that *any* call throws
+ jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
+ throw new Error('forced failure');
+ });
+
+ const start = new Date('2023-01-01T09:00:00').getTime();
+ const end = new Date('2023-01-01T17:00:00').getTime();
+
+ const result = formatBusinessHoursRange(start, end);
+
+ expect(result).toBe(`${new Date(start).getHours()} - ${new Date(end).getHours()}`);
+ });
+
+ it('calls console.warn with the expected message', () => {
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { /* nothing */ });
+
+ // Force an error from Intl
+ jest.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
+ throw new Error('boom');
+ })
+
+ formatBusinessHoursRange(
+ new Date('2023-01-01T09:00:00').getTime(),
+ new Date('2023-01-01T17:00:00').getTime(),
+ );
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Intl.DateTimeFormat not available'),
+ expect.any(Error),
+ );
+ });
+});
+
+describe('useHoursRange', () => {
+ beforeAll(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(Date.UTC(2025, 1, 1, 5));
+ });
+
+ it('returns formatted range', () => {
+ const now = Date.now();
+ const active = makeBusinessHours(now - 1000, now + 5 * 60 * 1000);
+ const inactive = makeBusinessHours(now + 10000, now + 5 * 60 * 1000);
+ const hours = makeBusinessHoursResponse(now, inactive, active);
+ const response = makeResponse({ hours });
+ const { result } = renderHook(() => useHoursRange(response));
+
+ expect(result.current).toBe(
+ formatBusinessHoursRange(active.startTime, active.endTime)
+ );
+ });
+
+ it('memoizes correctly', async () => {
+ const now = Date.now();
+ const response1 = makeResponse(
+ {
+ hours: makeBusinessHoursResponse(now, makeBusinessHours(now - 1000, now + 2 * 3600 * 1000))
+ }
+ );
+ const response2 = makeResponse(
+ {
+ hours: makeBusinessHoursResponse(now, makeBusinessHours(now - 1000, now + 1 * 3600 * 1000))
+ }
+ );
+
+ const { result, rerender } = renderHook(
+ (props: Parameters) => useHoursRange(...props),
+ { initialProps: [response1, 0] },
+ );
+
+ rerender([response1, 0]);
+ const firstResult = result.current;
+ expect(typeof firstResult).toBe('string');
+ expect(firstResult?.length).toBeGreaterThan(0);
+
+ rerender([response2, 0]);
+ const secondResult = result.current;
+ expect(typeof secondResult).toBe('string');
+ expect(secondResult?.length).toBeGreaterThan(0);
+ expect(firstResult).not.toEqual(secondResult);
+ });
+});
+
+describe('getPreChatFields', () => {
+ it('returns preChat fields', () => {
+ const params: Parameters[0] = [
+ { key: 'assignmentId', value: '1' },
+ { key: 'userName', value: 'Thomas Andrews' },
+ ]
+ const result = getPreChatFields(params);
+ expect(result).toMatchInlineSnapshot(`
+Object {
+ "hiddenFields": Object {
+ "Assignment_Id": "1",
+ },
+ "visibleFields": Object {
+ "School": Object {
+ "isEditableByEndUser": true,
+ "value": "",
+ },
+ "_email": Object {
+ "isEditableByEndUser": true,
+ "value": "",
+ },
+ "_firstName": Object {
+ "isEditableByEndUser": true,
+ "value": "Thomas",
+ },
+ "_lastName": Object {
+ "isEditableByEndUser": true,
+ "value": "Andrews",
+ },
+ },
+}
+`);
+ });
+ it('makes visible fields readonly when set with info from accounts', () => {
+ const params: Parameters[0] = [
+ { key: 'assignmentId', value: '1' },
+ { key: 'userFirstName', value: 'Thomas' },
+ { key: 'userLastName', value: 'Andrews' },
+ { key: 'userEmail', value: 't@t' },
+ { key: 'organizationName', value: 'Some place' },
+ ]
+ const result = getPreChatFields(params);
+ expect(result).toMatchInlineSnapshot(`
+Object {
+ "hiddenFields": Object {
+ "Assignment_Id": "1",
+ "Email": "t@t",
+ "First_Name": "Thomas",
+ "Last_Name": "Andrews",
+ "School": "Some place",
+ },
+ "visibleFields": Object {
+ "School": Object {
+ "isEditableByEndUser": true,
+ "value": "Some place",
+ },
+ "_email": Object {
+ "isEditableByEndUser": false,
+ "value": "t@t",
+ },
+ "_firstName": Object {
+ "isEditableByEndUser": false,
+ "value": "Thomas",
+ },
+ "_lastName": Object {
+ "isEditableByEndUser": false,
+ "value": "Andrews",
+ },
+ },
+}
+`);
+ });
+});
+
+const createMockPopup = () => ({
+ postMessage: jest.fn(),
+ closed: false,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+});
+
+describe('useChatController', () => {
+ const mockOpen = jest.fn();
+ const mockAddEventListener = jest.fn();
+ const mockRemoveEventListener = jest.fn();
+ const mockClearInterval = jest.fn();
+ const mockSetInterval = jest.fn();
+ const mockClearTimeout = jest.fn();
+ const mockSetTimeout = jest.fn();
+
+ beforeAll(() => {
+ global.window.open = mockOpen;
+ global.window.addEventListener = mockAddEventListener;
+ global.window.removeEventListener = mockRemoveEventListener;
+ global.setInterval = mockSetInterval as any;
+ global.clearInterval = mockClearInterval;
+ global.setTimeout = mockSetTimeout as any;
+ global.clearTimeout = mockClearTimeout;
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const preChatFields = getPreChatFields([
+ { key: 'userEmail', value: 'alice@example.com' },
+ ]);
+
+ const path = 'https://example.com/chat';
+
+ /** 2.1. Hook returns an empty object when `path` is undefined */
+ it('returns {} when path is undefined', () => {
+ const { result } = renderHook(() => useChatController(undefined, preChatFields));
+ expect(result.current).toEqual({});
+ });
+
+ /** 2.2. `openChat` is defined only when a path is present */
+ it('exposes openChat when path is defined', () => {
+ const { result } = renderHook(() => useChatController(path, preChatFields));
+ expect(typeof result.current.openChat).toBe('function');
+ });
+
+ /** 2.3. `openChat` does nothing if a popup is already open */
+ it('does not open a new popup if one is already open', () => {
+ const mockPopup = createMockPopup();
+ // fake a previous open
+ mockOpen.mockReturnValue(mockPopup);
+ const { result } = renderHook(() => useChatController(path, preChatFields));
+
+ act(() => {
+ // simulate that the first call already created a popup
+ result.current.openChat?.();
+ });
+
+ // Second call – popup already exists
+ act(() => {
+ result.current.openChat?.();
+ });
+
+ expect(mockOpen).toHaveBeenCalledTimes(1);
+ });
+
+ /** 2.3. `openChat` creates a window with correct geometry */
+ it('opens a popup with the correct size and position', () => {
+ const mockWindow = createMockPopup();
+ mockOpen.mockReturnValue(mockWindow);
+
+ const { result } = renderHook(() => useChatController(path, preChatFields));
+
+ act(() => {
+ result.current.openChat?.();
+ });
+
+ // 1. `window.open` called once
+ expect(mockOpen).toHaveBeenCalledTimes(1);
+
+ // 2. Check the options string
+ const optionsString = mockOpen.mock.calls[0][2];
+ expect(optionsString).toMatch(/width=[^,]+/);
+ expect(optionsString).toMatch(/height=[^,]+/);
+ expect(optionsString).toMatch(/top=[^,]+/); // bottom-right calc – exact values are hard to test reliably
+ expect(optionsString).toMatch(/left=[^,]+/); // bottom-right calc – exact values are hard to test reliably
+ expect(optionsString).toContain('popup=true');
+ });
+
+ /** 2.4. `postMessage` flow – popup receives ready → preChatFields → open */
+ it('sends preChatFields and open messages when the child signals ready', () => {
+ const mockPopup = createMockPopup();
+ mockOpen.mockReturnValue(mockPopup);
+
+ const mockHandleMessage = jest.fn();
+ mockAddEventListener.mockImplementation((event: string, cb: () => void) => {
+ if (event === 'message') mockHandleMessage.mockImplementation(cb);
+ });
+
+ // Set up the interval that checks for `closed`
+ mockSetInterval.mockImplementation(() => 42 as any); // fake interval id
+
+ const { result } = renderHook(() => useChatController(path, preChatFields));
+
+ act(() => {
+ result.current.openChat?.();
+ });
+
+ // After opening the popup we register a `handleMessage` listener
+ expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function), false);
+
+ // Simulate the chat window posting `"ready"`
+ const event: MessageEvent = {
+ source: mockPopup,
+ data: { type: 'ready' } as any,
+ } as any;
+
+ // Grab the actual `handleMessage` that was added
+ const handleMessage = mockAddEventListener.mock.calls.find(
+ (args) => args[0] === 'message'
+ )?.[1];
+ expect(handleMessage).toBeDefined();
+ act(() => {
+ handleMessage(event);
+ });
+
+ // `init` should have been called → sends `preChatFields` then `open`
+ expect(mockPopup.postMessage).toHaveBeenCalledTimes(2);
+ expect(mockPopup.postMessage).toHaveBeenNthCalledWith(
+ 1,
+ { type: 'preChatFields', data: preChatFields },
+ new URL(path).origin
+ );
+ expect(mockPopup.postMessage).toHaveBeenNthCalledWith(
+ 2,
+ { type: 'open' },
+ new URL(path).origin
+ );
+ });
+
+ /** 2.5. `openChat` cleans up the interval when the popup closes */
+ it('clears the polling interval and removes the message listener when popup closes', () => {
+ const mockPopup = createMockPopup();
+ const intervalId = 99;
+ mockOpen.mockReturnValue(mockPopup);
+ mockSetInterval.mockReturnValue(intervalId as any); // fake interval id
+
+ const { result } = renderHook(() => useChatController(path, preChatFields));
+
+ act(() => {
+ result.current.openChat?.();
+ });
+
+ // Simulate that the popup is closed after 1 tick
+ mockPopup.closed = true; // set closed flag
+ act(() => {
+ // this calls the interval callback
+ const checkClosed = mockSetInterval.mock.calls[0][0];
+ checkClosed();
+ });
+
+ // Verify cleanup
+ expect(mockRemoveEventListener).toHaveBeenCalledWith('message', expect.any(Function), false);
+ expect(mockClearInterval).toHaveBeenCalledWith(intervalId);
+ });
+
+ /** 2.6. `sendMessage` respects the origin – if the origin does not match it does nothing */
+ it('does not postMessage if popup origin does not match path origin', () => {
+ const mismatchedPath = 'https://evil.com/evil';
+ const { result } = renderHook(() => useChatController(mismatchedPath, preChatFields));
+
+ act(() => {
+ result.current.openChat?.();
+ });
+
+ // popup opened with wrong origin – postMessage will not be called
+ const mockPopup = mockOpen.mock.results[0].value as any;
+ expect(mockPopup.postMessage).not.toBeCalled();
+ });
+
+ /** 2.7. The effect that watches `preChatFields` sends a message immediately on mount and whenever the preChatFields object changes */
+ it('re-sends preChatFields when the payload changes', () => {
+ const { result, rerender } = renderHook(
+ ({ path, fields }) => useChatController(path, fields),
+ {
+ initialProps: { path, fields: preChatFields },
+ }
+ );
+
+ // First render triggers the effect
+ const firstPopup = createMockPopup();
+ mockOpen.mockReturnValue(firstPopup);
+
+ act(() => {
+ result.current.openChat?.();
+ });
+
+ const event: MessageEvent = {
+ source: firstPopup,
+ data: { type: 'ready' } as any,
+ } as any;
+
+ // Grab the actual `handleMessage` that was added
+ const handleMessage = mockAddEventListener.mock.calls.find(
+ (args) => args[0] === 'message'
+ )?.[1];
+ expect(handleMessage).toBeDefined();
+ act(() => {
+ handleMessage(event);
+ });
+
+ // Now change the fields
+ const newFields = getPreChatFields([
+ { key: 'userEmail', value: 'bob@example.com' },
+ ]);
+
+ act(() => {
+ rerender({ path, fields: newFields });
+ });
+
+ expect(firstPopup.postMessage).toHaveBeenCalledTimes(3);
+ const lastCall = firstPopup.postMessage.mock.lastCall;
+ expect(lastCall[0]).toEqual({ type: 'preChatFields', data: newFields });
+ });
+
+ /** 2.8. `sendMessage` is a no-op if no popup has been opened */
+ it('does not sendMessage when popup.current is null', () => {
+ const { rerender } = renderHook(
+ ({ path, fields }) => useChatController(path, fields),
+ {
+ initialProps: { path, fields: preChatFields },
+ }
+ );
+
+ // First render triggers the effect
+ const firstPopup = createMockPopup();
+ mockOpen.mockReturnValue(firstPopup);
+
+ // Now change the fields
+ const newFields = getPreChatFields([
+ { key: 'userEmail', value: 'bob@example.com' },
+ ]);
+
+ act(() => {
+ rerender({ path, fields: newFields });
+ });
+
+ expect(firstPopup.postMessage).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/src/components/HelpMenu/hooks.ts b/src/components/HelpMenu/hooks.ts
new file mode 100644
index 000000000..9ecd1d8c4
--- /dev/null
+++ b/src/components/HelpMenu/hooks.ts
@@ -0,0 +1,230 @@
+import React from "react";
+
+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;
+ 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
new file mode 100644
index 000000000..4d7909c07
--- /dev/null
+++ b/src/components/HelpMenu/index.spec.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext';
+import { HelpMenu, HelpMenuItem, HelpMenuProps } from '.';
+import { NavBar } from '../NavBar';
+import { ChatConfiguration } from './hooks';
+
+describe('HelpMenu', () => {
+ let root: HTMLElement;
+
+ beforeAll(() => {
+ global.CSS = {
+ supports: () => true,
+ escape: jest.fn(),
+ } as any;
+ jest.useFakeTimers();
+ jest.setSystemTime(0);
+ });
+
+ beforeEach(() => {
+ root = document.createElement('main');
+ root.id = 'root';
+ document.body.append(root);
+ });
+
+ it('matches snapshot', async () => {
+ render(
+
+
+
+ window.alert('Ran HelpMenu callback function')}>
+ Test Callback
+
+
+
+
+ );
+
+ // Reveal the default button in the help menu
+ fireEvent.click(await screen.findByText('Help'));
+ screen.getByText(/Report an issue/i);
+
+ expect(document.body).toMatchSnapshot();
+ });
+
+ it('errors if the service is unavailable', async () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {
+ // SILENCE
+ });
+ const errorResponse: ChatConfiguration['err'] = {
+ type: 'test',
+ detail: 'test'
+ };
+ const chatEmbedPath = 'https://example.com/';
+ const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, err: errorResponse};
+
+ render(
+
+
+
+ window.alert('Ran HelpMenu callback function')}>
+ Test Callback
+
+
+
+
+ );
+ fireEvent.click(await screen.findByText('Help'));
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('replaces button within hours', 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/';
+ const chatEmbedParams: HelpMenuProps['chatConfig'] = {chatEmbedPath, businessHours: happyHoursResponse};
+
+ render(
+
+
+
+ window.alert('Ran HelpMenu callback function')}>
+ Test Callback
+
+
+
+
+ );
+ fireEvent.click(await screen.findByText('Help'));
+ await screen.findByRole('menuitem', { name: /chat with us/i });
+ });
+});
diff --git a/src/components/HelpMenu.tsx b/src/components/HelpMenu/index.tsx
similarity index 77%
rename from src/components/HelpMenu.tsx
rename to src/components/HelpMenu/index.tsx
index 46e0bfefb..c4ad9c382 100644
--- a/src/components/HelpMenu.tsx
+++ b/src/components/HelpMenu/index.tsx
@@ -1,8 +1,9 @@
import React from 'react';
-import { NavBarMenuButton, NavBarMenuItem } from './NavBarMenuButtons';
-import { colors } from '../theme';
+import { NavBarMenuButton, NavBarMenuItem } from '../NavBarMenuButtons';
+import { colors } from '../../theme';
import styled from 'styled-components';
-import { BodyPortal } from './BodyPortal';
+import { BodyPortal } from '../BodyPortal';
+import { ChatConfiguration, getPreChatFields, useChatController, useHoursRange } from './hooks';
export const HelpMenuButton = styled(NavBarMenuButton)`
color: ${colors.palette.gray};
@@ -102,11 +103,20 @@ export const NewTabIcon = () => (
export interface HelpMenuProps {
contactFormParams: { key: string; value: string }[];
+ chatConfig?: Partial;
children?: React.ReactNode;
}
-export const HelpMenu: React.FC = ({ contactFormParams, children }) => {
+export const HelpMenu: React.FC = ({ contactFormParams, chatConfig, children }) => {
const [showIframe, setShowIframe] = React.useState();
+ const { chatEmbedPath, businessHours, err: chatError } = React.useMemo(() => (
+ chatConfig ?? {}
+ ), [chatConfig]);
+ const hoursRange = useHoursRange(businessHours);
+ const preChatFields = React.useMemo(() => (
+ getPreChatFields(contactFormParams)
+ ), [contactFormParams]);
+ const { openChat } = useChatController(chatEmbedPath, preChatFields);
const contactFormUrl = React.useMemo(() => {
const formUrl = 'https://openstax.org/embedded/contact';
@@ -117,7 +127,7 @@ export const HelpMenu: React.FC = ({ contactFormParams, children
return `${formUrl}?${params}`;
}, [contactFormParams]);
-
+
React.useEffect(() => {
const closeIt = ({data}: MessageEvent) => {
if (data === 'CONTACT_FORM_SUBMITTED') {
@@ -129,12 +139,25 @@ export const HelpMenu: React.FC = ({ contactFormParams, children
return () => window.removeEventListener('message', closeIt, false);
}, []);
+ if (chatError) {
+ // Silently fail while leaving some indication as to why
+ console.error('Error getting chat config', chatError);
+ }
+
return (
<>
- setShowIframe(contactFormUrl)}>
- Report an issue
-
+ {hoursRange && openChat
+ ? (
+ openChat()}>
+ Chat With Us ({hoursRange})
+
+ ) : (
+ setShowIframe(contactFormUrl)}>
+ Report an issue
+
+ )
+ }
{children}
diff --git a/yarn.lock b/yarn.lock
index ae2050c41..b816cbe01 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5487,6 +5487,14 @@
lodash "^4.17.21"
redent "^3.0.0"
+"@testing-library/react-hooks@^8.0.1":
+ version "8.0.1"
+ resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
+ integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+ react-error-boundary "^3.1.0"
+
"@testing-library/react@^12.0.0":
version "12.1.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
@@ -11550,6 +11558,13 @@ react-dom@^17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
+react-error-boundary@^3.1.0:
+ version "3.1.4"
+ resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
+ integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
react-frame-component@^5.2.3:
version "5.2.3"
resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-5.2.3.tgz#2d5d1e29b23d5b915c839b44980d03bb9cafc453"