diff --git a/src/components/Layout/Header.test.tsx b/src/components/Layout/Header.test.tsx index dd675edc13..e12f1b0903 100644 --- a/src/components/Layout/Header.test.tsx +++ b/src/components/Layout/Header.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { useStaticQuery } from 'gatsby'; +import { track } from '@ably/ui/core/insights'; import Header from './Header'; import UserContext from 'src/contexts/user-context'; @@ -17,6 +18,10 @@ jest.mock('src/contexts/layout-context', () => ({ }), })); +jest.mock('@ably/ui/core/insights', () => ({ + track: jest.fn(), +})); + jest.mock('src/components/Icon', () => { const MockIcon: React.FC<{ name: string }> = ({ name }) =>
{name}
; MockIcon.displayName = 'MockIcon'; @@ -143,7 +148,7 @@ describe('Header', () => { expect(searchBar).not.toBeInTheDocument(); }); - it('does not render Ask AI button and chat bar when inkeepChatEnabled is false', () => { + it('always renders the Ask AI button, but not the chat instance, when inkeepChatEnabled is false', () => { (useStaticQuery as jest.Mock).mockReturnValue({ site: { siteMetadata: { @@ -157,12 +162,13 @@ describe('Header', () => { render(
); - expect(screen.queryByText('Ask AI')).not.toBeInTheDocument(); + // The Ask AI button always renders; only the Inkeep chat instance is flag-gated. + expect(screen.getByText('Ask AI')).toBeInTheDocument(); const chatBar = document.getElementById('inkeep-ai-chat'); expect(chatBar).not.toBeInTheDocument(); }); - it('does not render search bar or Ask AI button when both flags are false', () => { + it('always renders the search trigger and Ask AI button, but not the Inkeep instances, when both flags are false', () => { (useStaticQuery as jest.Mock).mockReturnValue({ site: { siteMetadata: { @@ -176,10 +182,62 @@ describe('Header', () => { render(
); - expect(screen.queryByText('Ask AI')).not.toBeInTheDocument(); + // Our own search trigger and the Ask AI button are always present; the Inkeep + // search/chat instances only mount when their flags are enabled. + expect(screen.getByText('Search')).toBeInTheDocument(); + expect(screen.getByText('Ask AI')).toBeInTheDocument(); const searchBar = document.getElementById('inkeep-search'); const chatBar = document.getElementById('inkeep-ai-chat'); expect(searchBar).not.toBeInTheDocument(); expect(chatBar).not.toBeInTheDocument(); }); + + // Mimics Inkeep having mounted its widget: a child `div` whose open shadow root holds + // the real trigger `button`. This is the exact structure the header reaches into to + // open the modal, so it pins down the shadow-DOM click path that jsdom can't exercise + // against the real widget. If the selector chain (`#host > div` → shadowRoot → button) + // regresses, these go red. + const mountInkeepTrigger = (hostId: string) => { + const host = document.getElementById(hostId); + const inner = document.createElement('div'); + host?.appendChild(inner); + const triggerButton = document.createElement('button'); + inner.attachShadow({ mode: 'open' }).appendChild(triggerButton); + const clickSpy = jest.fn(); + triggerButton.addEventListener('click', clickSpy); + return clickSpy; + }; + + // Both Inkeep instances must be mounted for their hidden holders to exist, so explicitly + // enable the flags here — earlier tests leave useStaticQuery returning them disabled. + const enableInkeep = () => + (useStaticQuery as jest.Mock).mockReturnValue({ + site: { + siteMetadata: { + externalScriptsData: { inkeepSearchEnabled: true, inkeepChatEnabled: true }, + }, + }, + }); + + it('opens the Inkeep search modal and tracks the click when the search trigger is clicked', () => { + enableInkeep(); + render(
); + const clickSpy = mountInkeepTrigger('inkeep-search'); + + fireEvent.click(screen.getByText('Search')); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith('docs_search_button_clicked'); + }); + + it('opens the Inkeep chat modal and tracks the click when the Ask AI button is clicked', () => { + enableInkeep(); + render(
); + const clickSpy = mountInkeepTrigger('inkeep-ai-chat'); + + fireEvent.click(screen.getByText('Ask AI')); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith('docs_ask_ai_button_clicked'); + }); }); diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index de7305490e..ce9a3079c3 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -42,6 +42,53 @@ const activeHeaderLinkClassName = 'text-neutral-1300 dark:text-neutral-000 bg-or const inactiveHeaderLinkClassName = 'text-neutral-900 dark:text-neutral-500 hover:text-neutral-1300 dark:hover:text-neutral-000 hover:bg-neutral-100 dark:hover:bg-neutral-1200'; +// Opens an Inkeep widget (search or chat) by reaching into its shadow DOM. The Inkeep +// instances stay mounted but hidden (see #inkeep-search-holder), so these are our own +// triggers for them. No-ops where the widget isn't loaded (e.g. local dev) or its +// internal trigger isn't a top-level button. +const openInkeepWidget = (hostSelector: string, eventName: string) => { + const trigger = document.querySelector(`${hostSelector} > div`)?.shadowRoot?.querySelector('button'); + if (!trigger) { + return; + } + track(eventName); + trigger.click(); +}; + +const openInkeepSearch = () => openInkeepWidget('#inkeep-search', 'docs_search_button_clicked'); +const openInkeepChat = () => openInkeepWidget('#inkeep-ai-chat', 'docs_ask_ai_button_clicked'); + +// Custom search trigger rendered in both local and production so the header bar is a +// single, design-controlled element. In production it opens the Inkeep modal; locally +// it is inert. The modal itself is unchanged. +const SearchTrigger: React.FC = () => ( + +); + const mobileTabs = ['Platform', 'Products', 'Examples']; const helpResourcesItems = [ @@ -116,8 +163,10 @@ const Header: React.FC = () => { } }, 150); - // Physically shift the inkeep search bar around given that it's initialised once - const targetId = isMobileMenuOpen ? 'inkeep-search-mobile-mount' : 'inkeep-search-mount'; + // The Inkeep search bar is initialised once. On mobile we surface it inside the open + // menu; otherwise it lives in a hidden holder (the visible desktop trigger is our own + // SearchTrigger button, which opens this instance's modal). + const targetId = isMobileMenuOpen ? 'inkeep-search-mobile-mount' : 'inkeep-search-holder'; const targetElement = document.getElementById(targetId); const searchBar = searchBarRef.current; @@ -244,59 +293,14 @@ const Header: React.FC = () => { )}
- {!externalScriptsData.inkeepSearchEnabled && ( -
- -
- )} +
- {externalScriptsData.inkeepChatEnabled && ( - - )} + @@ -404,7 +408,7 @@ const Header: React.FC = () => {
-
+
{externalScriptsData.inkeepSearchEnabled && ( )}