Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/components/nav/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAnchorElement>): void {
export function trackNavClick(
e: React.MouseEvent<HTMLAnchorElement>,
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
}

Expand Down Expand Up @@ -113,7 +119,7 @@ export default function Navbar() {
: 'text-white'
}`}
onClick={(e) => {
trackNavClick(e);
trackNavClick(e, link.name);
setDropdownOpen(null);
}}
>
Expand Down Expand Up @@ -227,7 +233,7 @@ export default function Navbar() {
: 'text-white'
}`}
onClick={(e) => {
trackNavClick(e);
trackNavClick(e, link.name);
setIsOpen(false);
setDropdownOpen(null);
}}
Expand Down
109 changes: 59 additions & 50 deletions src/components/nav/__tests__/Navbar.pbt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,23 @@ function createMockEvent(
} as unknown as React.MouseEvent<HTMLAnchorElement>;
}

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();
Expand All @@ -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",
},
});
}
),
Expand All @@ -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));
}
Expand All @@ -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 }
);
});
});
74 changes: 44 additions & 30 deletions src/components/nav/__tests__/Navbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
});
});

Expand All @@ -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",
},
});
});

Expand All @@ -61,39 +84,30 @@ describe("trackNavClick", () => {
trackNavClick(createMockEvent(anchor));

expect(mockedPushEvent).toHaveBeenCalledWith({
event: "nav_click",
linkText: "Logo",
linkUrl: "/",
event: "navigation_click",
navigation: {
item: "Logo",
breadcrumb: "Logo",
type: "Header",
},
});
});

it("returns empty string when neither text nor img alt exists", () => {
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",
},
});
});

Expand Down
14 changes: 7 additions & 7 deletions src/lib/__tests__/dataLayer.pbt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
);

Expand Down
22 changes: 13 additions & 9 deletions src/lib/__tests__/dataLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
});

Expand All @@ -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" },
});
});
});
Expand Down
11 changes: 10 additions & 1 deletion src/lib/dataLayer.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading