diff --git a/src/components/nav/Navbar.tsx b/src/components/nav/Navbar.tsx index a4469cb..f478539 100644 --- a/src/components/nav/Navbar.tsx +++ b/src/components/nav/Navbar.tsx @@ -2,13 +2,19 @@ import React, { useState, useEffect, useRef } from 'react'; import LogoImg from '../../assets/CoreCollective_White-Wordmark.png'; import { pushEvent } from '../../lib/dataLayer'; -export function trackNavClick(e: React.MouseEvent): void { +export function trackNavClick( + e: React.MouseEvent, + parentLabel?: string, +): void { const anchor = e.currentTarget; const img = anchor.querySelector("img"); - const linkText = anchor.textContent?.trim() || img?.alt || ""; - const linkUrl = anchor.getAttribute("href") || ""; + const item = anchor.textContent?.trim() || img?.alt || ""; + const breadcrumb = parentLabel ? `${parentLabel} > ${item}` : item; - pushEvent({ event: "nav_click", linkText, linkUrl }); + pushEvent({ + event: "navigation_click", + navigation: { item, breadcrumb, type: "Header" }, + }); // Do NOT call e.preventDefault() — default navigation must proceed } @@ -113,7 +119,7 @@ export default function Navbar() { : 'text-white' }`} onClick={(e) => { - trackNavClick(e); + trackNavClick(e, link.name); setDropdownOpen(null); }} > @@ -227,7 +233,7 @@ export default function Navbar() { : 'text-white' }`} onClick={(e) => { - trackNavClick(e); + trackNavClick(e, link.name); setIsOpen(false); setDropdownOpen(null); }} diff --git a/src/components/nav/__tests__/Navbar.pbt.test.ts b/src/components/nav/__tests__/Navbar.pbt.test.ts index df40e2e..85b8e50 100644 --- a/src/components/nav/__tests__/Navbar.pbt.test.ts +++ b/src/components/nav/__tests__/Navbar.pbt.test.ts @@ -19,23 +19,23 @@ function createMockEvent( } as unknown as React.MouseEvent; } -describe("Feature: consent-banner-datalayer, Property 2: Navigation link text extraction", () => { +describe("Feature: consent-banner-datalayer, Property 2: Navigation click event structure", () => { /** - * **Validates: Requirements 8.2, 8.3, 8.4** + * **Validates: navigation_click event taxonomy** * * For any anchor element containing arbitrary text content (including - * leading/trailing whitespace) or an image with an alt attribute, the - * trackNavClick extraction logic SHALL produce a linkText equal to the - * trimmed textContent of the anchor (or the alt attribute of the contained - * img if textContent is empty), and a linkUrl equal to the exact href - * attribute value. + * leading/trailing whitespace) or an image with an alt attribute, + * trackNavClick SHALL produce a navigation_click event with: + * - navigation.item: the trimmed visible text (or img alt fallback) + * - navigation.breadcrumb: "parentLabel > item" if parentLabel provided, else just item + * - navigation.type: always "Header" */ beforeEach(() => { mockedPushEvent.mockClear(); }); - it("extracts trimmed textContent as linkText and preserves href exactly", () => { + it("extracts trimmed textContent as item and builds correct breadcrumb without parent", () => { const arbWhitespace = fc.stringOf(fc.constantFrom(" ", "\t", "\n", "\r")); const arbVisibleText = fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0); const arbHref = fc.string(); @@ -57,10 +57,50 @@ describe("Feature: consent-banner-datalayer, Property 2: Navigation link text ex expect(mockedPushEvent).toHaveBeenCalledTimes(1); const call = mockedPushEvent.mock.calls[0][0]; + const expectedItem = (leadingWs + text + trailingWs).trim(); expect(call).toEqual({ - event: "nav_click", - linkText: (leadingWs + text + trailingWs).trim(), - linkUrl: href, + event: "navigation_click", + navigation: { + item: expectedItem, + breadcrumb: expectedItem, + type: "Header", + }, + }); + } + ), + { numRuns: 100 } + ); + }); + + it("builds parent > item breadcrumb when parentLabel is provided", () => { + const arbVisibleText = fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0); + const arbParent = fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0); + const arbHref = fc.string(); + + fc.assert( + fc.property( + arbVisibleText, + arbParent, + arbHref, + (text, parentLabel, href) => { + mockedPushEvent.mockClear(); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", href); + anchor.textContent = text; + + trackNavClick(createMockEvent(anchor), parentLabel); + + expect(mockedPushEvent).toHaveBeenCalledTimes(1); + const call = mockedPushEvent.mock.calls[0][0]; + const expectedItem = text.trim(); + expect(call).toEqual({ + event: "navigation_click", + navigation: { + item: expectedItem, + breadcrumb: `${parentLabel} > ${expectedItem}`, + type: "Header", + }, }); } ), @@ -83,7 +123,6 @@ describe("Feature: consent-banner-datalayer, Property 2: Navigation link text ex const anchor = document.createElement("a"); anchor.setAttribute("href", href); - // Add whitespace-only text node (if any) so textContent.trim() is empty if (whitespace.length > 0) { anchor.appendChild(document.createTextNode(whitespace)); } @@ -96,51 +135,21 @@ describe("Feature: consent-banner-datalayer, Property 2: Navigation link text ex expect(mockedPushEvent).toHaveBeenCalledTimes(1); const call = mockedPushEvent.mock.calls[0][0]; - // textContent includes alt text from img element in jsdom - // The actual behavior: anchor.textContent includes the img alt text - // in the DOM (img elements don't have textContent visible), - // but whitespace + alt scenario depends on the DOM behavior. - // The logic is: textContent?.trim() || img?.alt || "" + // textContent includes alt text from img in some DOM implementations const anchorTextContent = anchor.textContent?.trim() || ""; - const expectedLinkText = anchorTextContent || img.alt || ""; + const expectedItem = anchorTextContent || img.alt || ""; expect(call).toEqual({ - event: "nav_click", - linkText: expectedLinkText, - linkUrl: href, + event: "navigation_click", + navigation: { + item: expectedItem, + breadcrumb: expectedItem, + type: "Header", + }, }); } ), { numRuns: 100 } ); }); - - it("preserves href attribute value exactly", () => { - // Generate various href patterns including special characters - const arbHref = fc.oneof( - fc.webUrl(), - fc.string(), - fc.constant("/"), - fc.constant("#"), - fc.constantFrom("/about/", "/contact?q=hello#top", "https://example.com/path") - ); - const arbText = fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0); - - fc.assert( - fc.property(arbHref, arbText, (href, text) => { - mockedPushEvent.mockClear(); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", href); - anchor.textContent = text; - - trackNavClick(createMockEvent(anchor)); - - expect(mockedPushEvent).toHaveBeenCalledTimes(1); - const call = mockedPushEvent.mock.calls[0][0]; - expect((call as any).linkUrl).toBe(href); - }), - { numRuns: 100 } - ); - }); }); diff --git a/src/components/nav/__tests__/Navbar.test.ts b/src/components/nav/__tests__/Navbar.test.ts index 40374aa..f25a941 100644 --- a/src/components/nav/__tests__/Navbar.test.ts +++ b/src/components/nav/__tests__/Navbar.test.ts @@ -23,17 +23,37 @@ describe("trackNavClick", () => { mockedPushEvent.mockClear(); }); - it("extracts visible text from anchor", () => { + it("pushes navigation_click with item and breadcrumb for top-level link", () => { const anchor = document.createElement("a"); - anchor.href = "/about/"; - anchor.textContent = "About Us"; + anchor.href = "/working-groups/"; + anchor.textContent = "Working Groups"; trackNavClick(createMockEvent(anchor)); expect(mockedPushEvent).toHaveBeenCalledWith({ - event: "nav_click", - linkText: "About Us", - linkUrl: "/about/", + event: "navigation_click", + navigation: { + item: "Working Groups", + breadcrumb: "Working Groups", + type: "Header", + }, + }); + }); + + it("builds breadcrumb from parent label for dropdown items", () => { + const anchor = document.createElement("a"); + anchor.href = "/faq/"; + anchor.textContent = "FAQ"; + + trackNavClick(createMockEvent(anchor), "About"); + + expect(mockedPushEvent).toHaveBeenCalledWith({ + event: "navigation_click", + navigation: { + item: "FAQ", + breadcrumb: "About > FAQ", + type: "Header", + }, }); }); @@ -45,9 +65,12 @@ describe("trackNavClick", () => { trackNavClick(createMockEvent(anchor)); expect(mockedPushEvent).toHaveBeenCalledWith({ - event: "nav_click", - linkText: "Contact", - linkUrl: "/contact/", + event: "navigation_click", + navigation: { + item: "Contact", + breadcrumb: "Contact", + type: "Header", + }, }); }); @@ -61,9 +84,12 @@ describe("trackNavClick", () => { trackNavClick(createMockEvent(anchor)); expect(mockedPushEvent).toHaveBeenCalledWith({ - event: "nav_click", - linkText: "Logo", - linkUrl: "/", + event: "navigation_click", + navigation: { + item: "Logo", + breadcrumb: "Logo", + type: "Header", + }, }); }); @@ -71,29 +97,17 @@ describe("trackNavClick", () => { const anchor = document.createElement("a"); anchor.href = "/unknown/"; const img = document.createElement("img"); - // No alt attribute set anchor.appendChild(img); trackNavClick(createMockEvent(anchor)); expect(mockedPushEvent).toHaveBeenCalledWith({ - event: "nav_click", - linkText: "", - linkUrl: "/unknown/", - }); - }); - - it("preserves href exactly", () => { - const anchor = document.createElement("a"); - anchor.setAttribute("href", "/page?foo=bar#section"); - anchor.textContent = "Link"; - - trackNavClick(createMockEvent(anchor)); - - expect(mockedPushEvent).toHaveBeenCalledWith({ - event: "nav_click", - linkText: "Link", - linkUrl: "/page?foo=bar#section", + event: "navigation_click", + navigation: { + item: "", + breadcrumb: "", + type: "Header", + }, }); }); diff --git a/src/lib/__tests__/dataLayer.pbt.test.ts b/src/lib/__tests__/dataLayer.pbt.test.ts index 318b419..592a288 100644 --- a/src/lib/__tests__/dataLayer.pbt.test.ts +++ b/src/lib/__tests__/dataLayer.pbt.test.ts @@ -18,17 +18,17 @@ const arbFormSubmissionEvent = fc }) ); -/** Arbitrary for valid nav_click events */ +/** Arbitrary for valid navigation_click events */ const arbNavClickEvent = fc .record({ - linkText: fc.string(), - linkUrl: fc.string(), + item: fc.string(), + breadcrumb: fc.string(), + type: fc.constant("Header"), }) .map( - ({ linkText, linkUrl }): DataLayerEvent => ({ - event: "nav_click", - linkText, - linkUrl, + (navigation): DataLayerEvent => ({ + event: "navigation_click", + navigation, }) ); diff --git a/src/lib/__tests__/dataLayer.test.ts b/src/lib/__tests__/dataLayer.test.ts index 1714966..88c7841 100644 --- a/src/lib/__tests__/dataLayer.test.ts +++ b/src/lib/__tests__/dataLayer.test.ts @@ -17,14 +17,16 @@ describe("pushEvent", () => { }); }); - it("pushes correct structure for nav_click event", () => { - pushEvent({ event: "nav_click", linkText: "About", linkUrl: "/about/" }); + it("pushes correct structure for navigation_click event", () => { + pushEvent({ + event: "navigation_click", + navigation: { item: "About", breadcrumb: "About", type: "Header" }, + }); expect(window.dataLayer).toHaveLength(1); expect(window.dataLayer[0]).toEqual({ - event: "nav_click", - linkText: "About", - linkUrl: "/about/", + event: "navigation_click", + navigation: { item: "About", breadcrumb: "About", type: "Header" }, }); }); @@ -40,14 +42,16 @@ describe("pushEvent", () => { it("appends without clobbering existing entries", () => { window.dataLayer = [{ event: "gtm.js" } as any]; - pushEvent({ event: "nav_click", linkText: "Home", linkUrl: "/" }); + pushEvent({ + event: "navigation_click", + navigation: { item: "Home", breadcrumb: "Home", type: "Header" }, + }); expect(window.dataLayer).toHaveLength(2); expect(window.dataLayer[0]).toEqual({ event: "gtm.js" }); expect(window.dataLayer[1]).toEqual({ - event: "nav_click", - linkText: "Home", - linkUrl: "/", + event: "navigation_click", + navigation: { item: "Home", breadcrumb: "Home", type: "Header" }, }); }); }); diff --git a/src/lib/dataLayer.ts b/src/lib/dataLayer.ts index b6dcfcb..871c0b1 100644 --- a/src/lib/dataLayer.ts +++ b/src/lib/dataLayer.ts @@ -1,7 +1,16 @@ /** Supported DataLayer event types */ +export type NavigationClickEvent = { + event: "navigation_click"; + navigation: { + item: string; + breadcrumb: string; + type: string; + }; +}; + export type DataLayerEvent = | { event: "form_submission"; formName: string } - | { event: "nav_click"; linkText: string; linkUrl: string }; + | NavigationClickEvent; /** * Push a structured event to the GTM DataLayer.