From e7f1e83704edaf83bb3f4d897e77226b6d7babe8 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Wed, 10 Jun 2026 09:02:16 -0500 Subject: [PATCH 01/11] only trigger playwright tests on a push to main --- .github/workflows/playwright.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9a9da5311..1a6f7c520 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,6 +1,8 @@ name: Playwright -on: [pull_request] +on: + push: + branches: [main] defaults: run: From 57f65f997845b511e25790b149a73ea24d85ca9b Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Sat, 13 Jun 2026 02:23:29 -0500 Subject: [PATCH 02/11] feat: add PostHog experiment toolkit (read flags, capture events) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/helpers/posthog.ts | 57 +++++++++++++++++++ test/src/helpers/posthog.test.tsx | 93 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/app/helpers/posthog.ts create mode 100644 test/src/helpers/posthog.test.tsx diff --git a/src/app/helpers/posthog.ts b/src/app/helpers/posthog.ts new file mode 100644 index 000000000..7eb92dfd2 --- /dev/null +++ b/src/app/helpers/posthog.ts @@ -0,0 +1,57 @@ +import React from 'react'; + +export type FlagValue = string | boolean | undefined; + +type PostHogClient = { + getFeatureFlag: (key: string) => FlagValue; + onFeatureFlags: (cb: () => void) => void; + capture: (event: string, properties?: Record) => void; +}; + +/** PostHog is loaded by GTM; it may not be present yet (pre-consent). */ +function getPostHog(): PostHogClient | undefined { + return (window as unknown as {posthog?: PostHogClient}).posthog; +} + +/** Read an experiment/feature-flag variant. Reading it auto-fires the + * `$feature_flag_called` exposure event in PostHog. */ +export function getExperimentVariant(flagKey: string): FlagValue { + return getPostHog()?.getFeatureFlag(flagKey); +} + +/** Fire a goal/auxiliary event. Safe no-op when PostHog is absent. */ +export function captureEvent(event: string, properties?: Record) { + getPostHog()?.capture(event, properties); +} + +/** Hook form: returns the variant, re-rendering once PostHog's flags resolve. */ +export function useExperiment(flagKey: string): FlagValue { + const [variant, setVariant] = React.useState(() => + getExperimentVariant(flagKey) + ); + + React.useEffect(() => { + const ph = getPostHog(); + if (!ph) { + return; + } + ph.onFeatureFlags(() => setVariant(ph.getFeatureFlag(flagKey))); + }, [flagKey]); + + return variant; +} + +/** Subscribe to PostHog flag resolution and return a synchronous variant + * reader. Re-renders the caller once flags load, so callers can read any + * number of flags (e.g. while filtering a list) without breaking hooks rules. */ +export function useExperimentReader(): (flag: string) => FlagValue { + const [, forceRender] = React.useReducer((n: number) => n + 1, 0); + React.useEffect(() => { + const ph = getPostHog(); + if (!ph) { + return; + } + ph.onFeatureFlags(() => forceRender()); + }, []); + return getExperimentVariant; +} diff --git a/test/src/helpers/posthog.test.tsx b/test/src/helpers/posthog.test.tsx new file mode 100644 index 000000000..9eeda34be --- /dev/null +++ b/test/src/helpers/posthog.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import {render, screen, act} from '@testing-library/preact'; +import { + getExperimentVariant, + captureEvent, + useExperiment, + useExperimentReader +} from '~/helpers/posthog'; + +type FakePostHog = { + getFeatureFlag: jest.Mock; + onFeatureFlags: jest.Mock; + capture: jest.Mock; +}; + +function installPostHog(overrides: Partial = {}) { + const ph: FakePostHog = { + getFeatureFlag: jest.fn(), + onFeatureFlags: jest.fn(), + capture: jest.fn(), + ...overrides + }; + (window as unknown as {posthog?: FakePostHog}).posthog = ph; + return ph; +} + +afterEach(() => { + delete (window as unknown as {posthog?: unknown}).posthog; +}); + +describe('posthog helper', () => { + it('getExperimentVariant returns undefined when posthog is absent', () => { + expect(getExperimentVariant('nav-products-label')).toBeUndefined(); + }); + + it('getExperimentVariant reads the flag', () => { + installPostHog({getFeatureFlag: jest.fn().mockReturnValue('tools')}); + expect(getExperimentVariant('nav-products-label')).toBe('tools'); + }); + + it('captureEvent no-ops without posthog', () => { + expect(() => captureEvent('thing_clicked')).not.toThrow(); + }); + + it('captureEvent forwards to posthog', () => { + const ph = installPostHog(); + captureEvent('thing_clicked', {a: 1}); + expect(ph.capture).toHaveBeenCalledWith('thing_clicked', {a: 1}); + }); + + it('useExperiment returns control then updates when flags resolve', async () => { + const ph = installPostHog({ + getFeatureFlag: jest + .fn() + .mockReturnValueOnce(undefined) + .mockReturnValue('tools') + }); + + function Probe() { + const variant = useExperiment('nav-products-label'); + return {String(variant)}; + } + + render(); + screen.getByText('undefined'); + + const cb = ph.onFeatureFlags.mock.calls[0][0] as () => void; + act(() => cb()); + await screen.findByText('tools'); + }); +}); + +describe('useExperimentReader', () => { + it('returns a reader and re-renders when flags resolve', async () => { + const ph = { + getFeatureFlag: jest.fn().mockReturnValueOnce(undefined).mockReturnValue('on'), + onFeatureFlags: jest.fn(), + capture: jest.fn() + }; + (window as unknown as {posthog?: typeof ph}).posthog = ph; + + function Probe() { + const getVariant = useExperimentReader(); + return {String(getVariant('f'))}; + } + const {findByText, getByText} = render(); + getByText('undefined'); + const cb = ph.onFeatureFlags.mock.calls[0][0] as () => void; + act(() => cb()); + await findByText('on'); + delete (window as unknown as {posthog?: unknown}).posthog; + }); +}); From ce167f7899d785b6b3b6b917fee5b5b415381d32 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Sat, 13 Jun 2026 02:23:29 -0500 Subject: [PATCH 03/11] feat: generic flag-aware nav resolver + key-based Products A/B + K12 rollout Co-Authored-By: Claude Opus 4.8 (1M context) --- .../header/menus/main-menu/main-menu.tsx | 47 ++++-- .../header/menus/main-menu/nav-experiments.ts | 51 ++++++ .../header/main-menu-resolver.test.tsx | 147 ++++++++++++++++++ .../default/header/nav-experiments.test.ts | 32 ++++ 4 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 src/app/layouts/default/header/menus/main-menu/nav-experiments.ts create mode 100644 test/src/layouts/default/header/main-menu-resolver.test.tsx create mode 100644 test/src/layouts/default/header/nav-experiments.test.ts diff --git a/src/app/layouts/default/header/menus/main-menu/main-menu.tsx b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx index 10ec8ca80..51eaad735 100644 --- a/src/app/layouts/default/header/menus/main-menu/main-menu.tsx +++ b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx @@ -8,6 +8,13 @@ import { import {FormattedMessage} from 'react-intl'; import {useLocation} from 'react-router-dom'; import {useDataFromSlug} from '~/helpers/page-data-utils'; +import {useExperimentReader, useExperiment, type FlagValue} from '~/helpers/posthog'; +import { + NAV_PRODUCTS_LABEL_FLAG, + NAV_K12_ITEM_FLAG, + isNodeVisible, + dropdownLabel +} from './nav-experiments'; import Dropdown, {MenuItem} from './dropdown/dropdown'; import LoginMenu from './login-menu/login-menu'; import GiveButton from '../give-button/give-button'; @@ -18,24 +25,37 @@ type MenuItemData = | { name: string; menu: MenuItemData[]; + key?: string; + feature_flag?: string; + flag_value?: string; } | { label: string; partial_url: string; + key?: string; + feature_flag?: string; + flag_value?: string; } | object; -function DropdownOrMenuItem({item}: {item: MenuItemData}) { +function DropdownOrMenuItem({ + item, + getVariant +}: { + item: MenuItemData; + getVariant: (flag: string) => FlagValue; +}) { if (!('name' in item) && !('label' in item)) { return null; } + if (!isNodeVisible(item, getVariant)) { + return null; + } if ('menu' in item) { + const label = dropdownLabel(item, getVariant(NAV_PRODUCTS_LABEL_FLAG)); return ( - - + + ); } @@ -43,13 +63,20 @@ function DropdownOrMenuItem({item}: {item: MenuItemData}) { return ; } -function MenusFromStructure({structure}: {structure: MenuItemData[]}) { +function MenusFromStructure({ + structure, + getVariant +}: { + structure: MenuItemData[]; + getVariant: (flag: string) => FlagValue; +}) { return ( {structure.map((item, index) => ( ))} @@ -57,13 +84,14 @@ function MenusFromStructure({structure}: {structure: MenuItemData[]}) { } function MenusFromCMS() { + const getVariant = useExperimentReader(); const structure = useDataFromSlug('oxmenus') as MenuItemData[] | undefined; if (!structure) { return null; } - return ; + return ; } function K12MenuItem() { @@ -73,6 +101,7 @@ function K12MenuItem() { function SubjectsMenu() { const categories = useSubjectCategoryContext(); const {language} = useLanguageContext(); + const k12TopLevel = Boolean(useExperiment(NAV_K12_ITEM_FLAG)); // This will have to be revisited if/when we implement more languages const otherLocale = ['en', 'es'].filter((la) => la !== language)[0]; const {pathname} = useLocation(); @@ -110,7 +139,7 @@ function SubjectsMenu() { )} - {language === 'en' ? ( + {language === 'en' && !k12TopLevel ? (
diff --git a/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts b/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts new file mode 100644 index 000000000..84b39b120 --- /dev/null +++ b/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts @@ -0,0 +1,51 @@ +import type {FlagValue} from '~/helpers/posthog'; + +export const NAV_PRODUCTS_LABEL_FLAG = 'nav-products-label'; +export const NAV_K12_ITEM_FLAG = 'nav-k12-item'; + +/** Stable CMS `key` of the dropdown under the Products/Tools A/B. + * CONFIRM this matches the `key` set on that dropdown in Wagtail. */ +export const PRODUCTS_DROPDOWN_KEY = 'products-dropdown'; +const TOOLS_LABEL = 'Tools'; + +/** Flag metadata any CMS menu node may carry (snake_case, matching the + * oxmenus API — same casing as `partial_url`). */ +export type FlagAwareNode = { + key?: string; + feature_flag?: string; + flag_value?: string; +}; + +/** Generic visibility gate from a node's optional flag metadata. + * - no feature_flag → always visible + * - flag_value provided → visible iff String(variant) === flag_value + * - flag_value blank → visible iff the flag is truthy + * Nodes gated ON by a flag appear once PostHog's flags resolve (fine for + * additive items); author controls/defaults WITHOUT a feature_flag so they + * render immediately. */ +export function isNodeVisible( + node: FlagAwareNode, + getVariant: (flag: string) => FlagValue +): boolean { + const flag = node.feature_flag; + if (!flag) { + return true; + } + const variant = getVariant(flag); + if (node.flag_value) { + return String(variant) === node.flag_value; + } + return Boolean(variant); +} + +/** A/B label swap for the Products dropdown, keyed by stable `key` + * (not the display label, so editors can rename freely). */ +export function dropdownLabel( + node: {key?: string; name?: string}, + productsVariant: FlagValue +): string { + if (node.key === PRODUCTS_DROPDOWN_KEY && productsVariant === 'tools') { + return TOOLS_LABEL; + } + return node.name ?? ''; +} diff --git a/test/src/layouts/default/header/main-menu-resolver.test.tsx b/test/src/layouts/default/header/main-menu-resolver.test.tsx new file mode 100644 index 000000000..1c97f49a1 --- /dev/null +++ b/test/src/layouts/default/header/main-menu-resolver.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import {render, screen, act} from '@testing-library/preact'; +import ShellContextProvider from '../../../../helpers/shell-context'; +import MemoryRouter from '../../../../helpers/future-memory-router'; +import {MainMenuItems} from '~/layouts/default/header/menus/main-menu/main-menu'; +import {useDataFromSlug} from '~/helpers/page-data-utils'; + +jest.mock('~/models/give-today', () => jest.fn().mockReturnValue({})); + +// Mock page-data-utils so we can control what useDataFromSlug('oxmenus') returns. +// We spread requireActual so everything else (fetchFromCMS, etc.) still works. +jest.mock('~/helpers/page-data-utils', () => ({ + ...jest.requireActual('~/helpers/page-data-utils'), + useDataFromSlug: jest.fn() +})); + +/* eslint-disable camelcase */ + +type FakePostHog = { + getFeatureFlag: jest.Mock; + onFeatureFlags: jest.Mock; + capture: jest.Mock; +}; + +function installPostHog(overrides: Partial = {}) { + const ph: FakePostHog = { + getFeatureFlag: jest.fn().mockReturnValue(undefined), + onFeatureFlags: jest.fn(), + capture: jest.fn(), + ...overrides + }; + (window as unknown as {posthog?: FakePostHog}).posthog = ph; + return ph; +} + +afterEach(() => { + delete (window as unknown as {posthog?: unknown}).posthog; + (useDataFromSlug as jest.Mock).mockReset(); +}); + +function Component() { + return ( + + +
    + +
+
+
+ ); +} + +const cmsMenuWithFlaggedItem = [ + { + name: 'Products', + key: 'products-dropdown', + menu: [ + { + label: 'For K12 Teachers', + partial_url: '/k12', + feature_flag: 'nav-k12-item' + } + ] + } +]; + +describe('MainMenuItems — generic flag resolver', () => { + it('hides a flag-gated CMS menu item when flag is falsy', async () => { + installPostHog({ + getFeatureFlag: jest.fn().mockReturnValue(undefined) + }); + (useDataFromSlug as jest.Mock).mockReturnValue(cmsMenuWithFlaggedItem); + + render(); + + // Wait for SubjectsMenu to appear + await screen.findByText('Subjects'); + + // The flag-gated item in the Products dropdown should be hidden + expect(screen.queryByRole('link', {name: 'For K12 Teachers'})).toBeNull(); + }); + + it('shows a flag-gated CMS menu item when flag becomes truthy', async () => { + let flagsLoaded = false; + const ph = installPostHog({ + getFeatureFlag: jest.fn((flag: string) => { + if (!flagsLoaded) { + return undefined; + } + if (flag === 'nav-k12-item') { + return true; + } + return undefined; + }) + }); + (useDataFromSlug as jest.Mock).mockReturnValue(cmsMenuWithFlaggedItem); + + render(); + await screen.findByText('Subjects'); + + // Before flags load, the item is hidden + expect(screen.queryByRole('link', {name: 'For K12 Teachers'})).toBeNull(); + + // Simulate PostHog flags resolving — triggers forceRender in useExperimentReader + flagsLoaded = true; + const allCbs = ph.onFeatureFlags.mock.calls.map( + (call: [() => void]) => call[0] + ); + act(() => allCbs.forEach((cb: () => void) => cb())); + + await screen.findByRole('link', {name: 'For K12 Teachers'}); + }); + + it('hides SubjectsMenu hardcoded K12 item when nav-k12-item flag is truthy', async () => { + let flagsLoaded = false; + const ph = installPostHog({ + getFeatureFlag: jest.fn((flag: string) => { + if (!flagsLoaded) { + return undefined; + } + if (flag === 'nav-k12-item') { + return true; + } + return undefined; + }) + }); + (useDataFromSlug as jest.Mock).mockReturnValue([]); + + render(); + + // SubjectsMenu uses cmsFetch (not useDataFromSlug), so subjects still load + await screen.findByText('Math'); + + // K12 in-subjects link is present before flags load + expect(screen.getByRole('link', {name: '🍎 For K12 Teachers'})).toBeDefined(); + + // Simulate flags loading — triggers re-render in SubjectsMenu via useExperiment + flagsLoaded = true; + const allCbs = ph.onFeatureFlags.mock.calls.map( + (call: [() => void]) => call[0] + ); + act(() => allCbs.forEach((cb: () => void) => cb())); + + // The hardcoded in-Subjects K12 link should now be hidden + expect(screen.queryByRole('link', {name: '🍎 For K12 Teachers'})).toBeNull(); + }); +}); diff --git a/test/src/layouts/default/header/nav-experiments.test.ts b/test/src/layouts/default/header/nav-experiments.test.ts new file mode 100644 index 000000000..4761975b7 --- /dev/null +++ b/test/src/layouts/default/header/nav-experiments.test.ts @@ -0,0 +1,32 @@ +import { + isNodeVisible, + dropdownLabel, + PRODUCTS_DROPDOWN_KEY +} from '~/layouts/default/header/menus/main-menu/nav-experiments'; + +describe('isNodeVisible', () => { + it('is visible with no feature_flag', () => { + expect(isNodeVisible({}, () => undefined)).toBe(true); + }); + it('is visible iff the flag is truthy when flag_value is blank', () => { + expect(isNodeVisible({feature_flag: 'nav-k12-item'}, () => true)).toBe(true); + expect(isNodeVisible({feature_flag: 'nav-k12-item'}, () => undefined)).toBe(false); + }); + it('matches flag_value as a string', () => { + expect(isNodeVisible({feature_flag: 'f', flag_value: 'control'}, () => 'control')).toBe(true); + expect(isNodeVisible({feature_flag: 'f', flag_value: 'control'}, () => 'tools')).toBe(false); + expect(isNodeVisible({feature_flag: 'f', flag_value: 'false'}, () => false)).toBe(true); + }); +}); + +describe('dropdownLabel', () => { + it('keeps the name for non-target dropdowns', () => { + expect(dropdownLabel({key: 'subjects', name: 'Subjects'}, 'tools')).toBe('Subjects'); + }); + it('keeps the name for the control variant', () => { + expect(dropdownLabel({key: PRODUCTS_DROPDOWN_KEY, name: 'Products'}, undefined)).toBe('Products'); + }); + it('swaps to Tools for the target key in the tools variant', () => { + expect(dropdownLabel({key: PRODUCTS_DROPDOWN_KEY, name: 'Products'}, 'tools')).toBe('Tools'); + }); +}); From 62ba65bd493a8d69881cff3acc308d68edd64e78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:46:29 +0000 Subject: [PATCH 04/11] fix: add required blank lines to satisfy padding-line-between-statements ESLint rule --- src/app/helpers/posthog.ts | 3 +++ src/app/layouts/default/header/menus/main-menu/main-menu.tsx | 1 + .../layouts/default/header/menus/main-menu/nav-experiments.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/app/helpers/posthog.ts b/src/app/helpers/posthog.ts index 7eb92dfd2..8f7aa8321 100644 --- a/src/app/helpers/posthog.ts +++ b/src/app/helpers/posthog.ts @@ -32,6 +32,7 @@ export function useExperiment(flagKey: string): FlagValue { React.useEffect(() => { const ph = getPostHog(); + if (!ph) { return; } @@ -46,8 +47,10 @@ export function useExperiment(flagKey: string): FlagValue { * number of flags (e.g. while filtering a list) without breaking hooks rules. */ export function useExperimentReader(): (flag: string) => FlagValue { const [, forceRender] = React.useReducer((n: number) => n + 1, 0); + React.useEffect(() => { const ph = getPostHog(); + if (!ph) { return; } diff --git a/src/app/layouts/default/header/menus/main-menu/main-menu.tsx b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx index 51eaad735..13d4f5959 100644 --- a/src/app/layouts/default/header/menus/main-menu/main-menu.tsx +++ b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx @@ -53,6 +53,7 @@ function DropdownOrMenuItem({ } if ('menu' in item) { const label = dropdownLabel(item, getVariant(NAV_PRODUCTS_LABEL_FLAG)); + return ( diff --git a/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts b/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts index 84b39b120..8951f5101 100644 --- a/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts +++ b/src/app/layouts/default/header/menus/main-menu/nav-experiments.ts @@ -28,10 +28,12 @@ export function isNodeVisible( getVariant: (flag: string) => FlagValue ): boolean { const flag = node.feature_flag; + if (!flag) { return true; } const variant = getVariant(flag); + if (node.flag_value) { return String(variant) === node.flag_value; } From c562478221dd4fd7412fadc3e1fc26297eebdb0e Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Wed, 17 Jun 2026 00:52:36 -0500 Subject: [PATCH 05/11] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/app/helpers/posthog.ts | 60 ++++++++++++++++--- .../header/menus/main-menu/main-menu.tsx | 9 ++- test/src/helpers/posthog.test.tsx | 2 +- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/app/helpers/posthog.ts b/src/app/helpers/posthog.ts index 8f7aa8321..a64965381 100644 --- a/src/app/helpers/posthog.ts +++ b/src/app/helpers/posthog.ts @@ -31,12 +31,37 @@ export function useExperiment(flagKey: string): FlagValue { ); React.useEffect(() => { - const ph = getPostHog(); + let cancelled = false; + let interval: number | undefined; - if (!ph) { - return; + const subscribe = () => { + const ph = getPostHog(); + + if (!ph) { + return false; + } + ph.onFeatureFlags(() => { + if (!cancelled) { + setVariant(ph.getFeatureFlag(flagKey)); + } + }); + return true; + }; + + if (!subscribe()) { + interval = window.setInterval(() => { + if (subscribe() && interval !== undefined) { + window.clearInterval(interval); + } + }, 250); } - ph.onFeatureFlags(() => setVariant(ph.getFeatureFlag(flagKey))); + + return () => { + cancelled = true; + if (interval !== undefined) { + window.clearInterval(interval); + } + }; }, [flagKey]); return variant; @@ -49,12 +74,31 @@ export function useExperimentReader(): (flag: string) => FlagValue { const [, forceRender] = React.useReducer((n: number) => n + 1, 0); React.useEffect(() => { - const ph = getPostHog(); + let interval: number | undefined; - if (!ph) { - return; + const subscribe = () => { + const ph = getPostHog(); + + if (!ph) { + return false; + } + ph.onFeatureFlags(() => forceRender()); + return true; + }; + + if (!subscribe()) { + interval = window.setInterval(() => { + if (subscribe() && interval !== undefined) { + window.clearInterval(interval); + } + }, 250); } - ph.onFeatureFlags(() => forceRender()); + + return () => { + if (interval !== undefined) { + window.clearInterval(interval); + } + }; }, []); return getExperimentVariant; } diff --git a/src/app/layouts/default/header/menus/main-menu/main-menu.tsx b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx index 13d4f5959..9812d299e 100644 --- a/src/app/layouts/default/header/menus/main-menu/main-menu.tsx +++ b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx @@ -75,7 +75,14 @@ function MenusFromStructure({ {structure.map((item, index) => ( diff --git a/test/src/helpers/posthog.test.tsx b/test/src/helpers/posthog.test.tsx index 9eeda34be..8ac6cbe80 100644 --- a/test/src/helpers/posthog.test.tsx +++ b/test/src/helpers/posthog.test.tsx @@ -48,7 +48,7 @@ describe('posthog helper', () => { expect(ph.capture).toHaveBeenCalledWith('thing_clicked', {a: 1}); }); - it('useExperiment returns control then updates when flags resolve', async () => { + it('useExperiment returns undefined then updates when flags resolve', async () => { const ph = installPostHog({ getFeatureFlag: jest .fn() From bc807e726069a2074a9abaf5e4638d53c85cb636 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Thu, 9 Jul 2026 00:40:24 -0500 Subject: [PATCH 06/11] Drop k12 nav experiment, measure streamlined_nav, add PostHog conversion events The k12 top-level nav A/B is no longer needed now that streamlined_nav shipped as a CMS flag. Register streamlined_nav as a PostHog super property so any funnel can be sliced by cohort without new events, and capture the 11 conversion events (PDF downloads, donations, forms, search, resource access) via the existing lightweight captureEvent() helper rather than installing the posthog-js SDK, which would have double-initialized PostHog alongside the GTM-loaded instance. Co-Authored-By: Claude --- .gitignore | 1 + .../book-selector/book-selector.tsx | 14 ++++- src/app/components/search-bar/search-bar.tsx | 12 +++- src/app/components/shell/shell.tsx | 32 +++++++++- src/app/contexts/shared-data.ts | 11 ++++ src/app/helpers/posthog.ts | 54 +++++----------- .../header/menus/main-menu/main-menu.tsx | 28 +++++---- .../header/menus/main-menu/nav-experiments.ts | 1 - .../gated-content-dialog.tsx | 11 +++- src/app/pages/contact/form.tsx | 8 ++- .../give-before-pdf/common-elements.tsx | 9 ++- .../give-before-pdf/give-before-pdf.tsx | 4 +- .../give-before-pdf/thank-you-form.tsx | 2 + .../order-print-copy/order-print-copy.tsx | 15 ++++- .../common/resource-box/left-content.tsx | 5 ++ src/app/pages/errata-form/form/form.tsx | 2 + test/src/components/shell.test.tsx | 45 +++++++++++++ test/src/contexts/streamlined-nav.test.tsx | 16 +++++ test/src/helpers/posthog.test.tsx | 63 ++++++++++++++----- .../header/main-menu-resolver.test.tsx | 50 +++------------ .../default/header/nav-experiments.test.ts | 7 ++- 21 files changed, 263 insertions(+), 127 deletions(-) diff --git a/.gitignore b/.gitignore index 0b7d3321a..99d3b8c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ yarn-error.log .idea Brewfile.lock.json +.env diff --git a/src/app/components/book-selector/book-selector.tsx b/src/app/components/book-selector/book-selector.tsx index ce57dc998..7c13a7baa 100644 --- a/src/app/components/book-selector/book-selector.tsx +++ b/src/app/components/book-selector/book-selector.tsx @@ -6,6 +6,7 @@ import {salesforceTitles, SalesforceBook} from '~/helpers/books'; import BookCheckbox from '~/components/book-checkbox/book-checkbox'; import {useIntl} from 'react-intl'; import {useFirstSearchArgument} from './after-form-submit'; +import {captureEvent} from '~/helpers/posthog'; import './book-selector.scss'; const spanishPairings: Record = { @@ -252,6 +253,15 @@ function BookSelector({ }: { data: {books: Books}; } & PropsFromOutside) { + const trackedToggleBook = useCallback( + (book: SalesforceBook) => { + const action = selectedBooks.includes(book) ? 'deselected' : 'selected'; + + captureEvent('book_selected', {bookTitle: book.text, action}); + toggleBook(book); + }, + [selectedBooks, toggleBook] + ); const books = React.useMemo( () => salesforceTitles(data.books).filter(includeFilter), [data.books, includeFilter] @@ -323,7 +333,7 @@ function BookSelector({ ))} @@ -340,7 +350,7 @@ function BookSelector({ getBooks={booksBySubject} name={name} selectedBooks={selectedBooks} - toggleBook={toggleBook} + toggleBook={trackedToggleBook} limitReached={limitReached} forceOpen={searchLower.length > 0} /> diff --git a/src/app/components/search-bar/search-bar.tsx b/src/app/components/search-bar/search-bar.tsx index 876239945..030e06a34 100644 --- a/src/app/components/search-bar/search-bar.tsx +++ b/src/app/components/search-bar/search-bar.tsx @@ -8,6 +8,7 @@ import {faTimes} from '@fortawesome/free-solid-svg-icons/faTimes'; import {faSearch} from '@fortawesome/free-solid-svg-icons/faSearch'; import {treatSpaceOrEnterAsClick} from '~/helpers/events'; import cn from 'classnames'; +import {captureEvent} from '~/helpers/posthog'; import './search-bar.scss'; type SearchBarParams = { @@ -51,10 +52,11 @@ function SearchInput({amongWhat}: Pick) { (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); + captureEvent('book_search_performed', {searchQuery: searchString}); doSearch(); } }, - [doSearch] + [doSearch, searchString] ); const inputId = `search-${amongWhat}`; @@ -119,13 +121,17 @@ function ClearButton() { } function SearchButton() { - const {doSearch} = useSearchContext(); + const {doSearch, searchString} = useSearchContext(); + const handleSearch = React.useCallback(() => { + captureEvent('book_search_performed', {searchQuery: searchString}); + doSearch(); + }, [doSearch, searchString]); return (