Skip to content

Commit

Permalink
refactor to remove from redux
Browse files Browse the repository at this point in the history
  • Loading branch information
tomrf1 committed Jan 31, 2025
1 parent 73b2473 commit 2b982d1
Show file tree
Hide file tree
Showing 35 changed files with 240 additions and 161 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
UnitedStates,
} from '../../internationalisation/countryGroup';
import { _, init as abInit, getAmountsTestVariant } from '../abtest';
import type { Audience, Participations, Test, Variant } from '../abtest';
import type { Audience, Participations, Test, Variant } from '../models';

const { targetPageMatches } = _;
const { subsDigiSubPages, digiSub } = pageUrlRegexes.subscriptions;
Expand Down
115 changes: 26 additions & 89 deletions support-frontend/assets/helpers/abTests/abtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,84 +13,24 @@ import type {
} from '../contributions';
import { tests } from './abtestDefinitions';
import { getFallbackAmounts } from './helpers';

// ----- Types ----- //

const breakpoints = {
mobile: 320,
mobileMedium: 375,
mobileLandscape: 480,
phablet: 660,
tablet: 740,
desktop: 980,
leftCol: 1140,
wide: 1300,
};

type Breakpoint = keyof typeof breakpoints;

type BreakpointRange = {
minWidth?: Breakpoint;
maxWidth?: Breakpoint;
};

export type Participations = Record<string, string | undefined>;
import { getLandingPageParticipations } from './landingPageAbTests';
import type {
AcquisitionABTest,
Audience,
Participations,
Test,
Tests,
} from './models';
import { breakpoints } from './models';
import {
getParticipationsFromSession,
setLandingPageParticipations,
} from './sessionStorage';

export const testIsActive = (
value: [string, string | undefined],
): value is [string, string] => value[1] !== undefined;

export type Audience = {
offset: number;
size: number;
breakpoint?: BreakpointRange;
};

type AudienceType = IsoCountry | CountryGroupId | 'ALL' | 'CONTRIBUTIONS_ONLY';

type Audiences = {
[key in AudienceType]?: Audience;
};

type AcquisitionABTest = {
name: string;
variant: string;
testType?: string;
};

export type Variant = {
id: string;
};

export type Test = {
variants: Variant[];
audiences: Audiences;
isActive: boolean;
canRun?: () => boolean;
// Indicates whether the A/B test is controlled by the referrer (acquisition channel)
// e.g. Test of a banner design change on dotcom
// If true the A/B test participation info should be passed through in the acquisition data
// query parameter.
// In particular this allows 3rd party tests to be identified and tracked in support-frontend
// without too much "magic" involving the shared mvtId.
referrerControlled: boolean;
// If another test participation is referrerControlled, exclude this test
excludeIfInReferrerControlledTest?: boolean;
seed: number;
// An optional regex that will be tested against the path of the current page
// before activating this test eg. '/(uk|us|au|ca|nz)/subscribe$'
targetPage?: string | RegExp;
// Persist this test participation across more pages using this regex
persistPage?: string | RegExp;
omitCountries?: IsoCountry[];
// Some users will see a version of the checkout that only offers
// the option to make contributions. We won't want to include these
// users in some AB tests
excludeContributionsOnlyCountries: boolean;
};

export type Tests = Record<string, Test>;

// ----- Init ----- //

type ABtestInitalizerData = {
Expand All @@ -101,6 +41,7 @@ type ABtestInitalizerData = {
mvt?: number;
acquisitionDataTests?: AcquisitionABTest[];
path?: string;
settings: Settings;
};

function init({
Expand All @@ -111,6 +52,7 @@ function init({
mvt = getMvtId(),
acquisitionDataTests = getTestFromAcquisitionData() ?? [],
path = window.location.pathname,
settings,
}: ABtestInitalizerData): Participations {
const sessionParticipations = getParticipationsFromSession();
const participations = getParticipations(
Expand All @@ -124,10 +66,21 @@ function init({
sessionParticipations,
);

const landingPageParticipations = getLandingPageParticipations(
countryGroupId,
path,
settings.landingPageTests,
mvt,
);
if (landingPageParticipations) {
setLandingPageParticipations(landingPageParticipations);
}

const urlParticipations = getParticipationsFromUrl();
const serverSideParticipations = getServerSideParticipations();
return {
...participations,
...landingPageParticipations,
...serverSideParticipations,
...urlParticipations,
};
Expand Down Expand Up @@ -284,22 +237,6 @@ function getParticipationsFromUrl(): Participations | undefined {
return;
}

function getParticipationsFromSession(): Participations | undefined {
const participations = storage.getSession('abParticipations');
if (participations) {
try {
return JSON.parse(participations) as Participations;
} catch (error) {
console.error(
'Failed to parse abParticipations from session storage',
error,
);
return undefined;
}
}
return undefined;
}

function getServerSideParticipations(): Participations | null | undefined {
if (window.guardian.serversideTests) {
return window.guardian.serversideTests;
Expand Down
3 changes: 2 additions & 1 deletion support-frontend/assets/helpers/abTests/abtestDefinitions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Tests } from './abtest';
// ----- Tests ----- //
// Note: When setting up a test to run on the contributions thank you page
// you should always target both the landing page *and* the thank you page.
// This is to ensure the participation is picked up by ophan. The client side
// navigation from landing page to thank you page *won't* register any new
// participations.
import type { Tests } from './models';

export const pageUrlRegexes = {
contributions: {
/*
Expand Down
73 changes: 53 additions & 20 deletions support-frontend/assets/helpers/abTests/landingPageAbTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import type {
LandingPageVariant,
} from '../globalsAndSwitches/landingPageSettings';
import type { CountryGroupId } from '../internationalisation/countryGroup';
import { getMvtId } from './abtest';
import type { Participations } from './models';
import { getLandingPageParticipationsFromSession } from './sessionStorage';

export type LandingPageSelection = LandingPageVariant & { testName: string };

export const fallBackLandingPageSelection: LandingPageSelection = {
const fallBackLandingPageSelection: LandingPageSelection = {
testName: 'FALLBACK_LANDING_PAGE',
name: 'CONTROL',
copy: {
Expand All @@ -23,27 +24,59 @@ function randomNumber(mvtId: number, seed: string): number {
return Math.abs(rng.int32());
}

export function getLandingPageSettings(
const landingPageRegex = '^/(uk|us|ca|eu|nz|int)/contribute(/.*)?$';
function isLandingPage(path: string) {
return !!path && path.match(landingPageRegex);
}

export function getLandingPageParticipations(
countryGroupId: CountryGroupId,
path: string,
tests: LandingPageTest[] = [],
mvtId: number = getMvtId(),
mvtId: number,
): Participations | undefined {
if (isLandingPage(path)) {
// This is a landing page, assign user to a test + variant
const test = tests
.filter((test) => test.status == 'Live')
.find((test) => {
const { countryGroups } = test.targeting;
return countryGroups.includes(countryGroupId);
});

if (test) {
const idx = randomNumber(mvtId, test.name) % test.variants.length;
const variant = test.variants[idx];

if (variant) {
return {
[test.name]: variant.name,
};
}
}
} else {
// This is not a landing page, but check if the session has a landing page test participation
return getLandingPageParticipationsFromSession();
}
}

// Use the AB test participations to find the specific variant configuration for this page
export function getLandingPageVariant(
participations: Participations,
tests: LandingPageTest[],
): LandingPageVariant & { testName: string } {
const test = tests
.filter((test) => test.status == 'Live')
.find((test) => {
const { countryGroups } = test.targeting;
return countryGroups.includes(countryGroupId);
});

if (test) {
const idx = randomNumber(mvtId, test.name) % test.variants.length;
const variant = test.variants[idx];

if (variant) {
return {
testName: test.name,
...variant,
};
for (const test of tests) {
const variantName = participations[test.name];
if (variantName) {
const variant = test.variants.find(
(variant) => variant.name === variantName,
);
if (variant) {
return {
testName: test.name,
...variant,
};
}
}
}
return fallBackLandingPageSelection;
Expand Down
73 changes: 73 additions & 0 deletions support-frontend/assets/helpers/abTests/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { IsoCountry } from '../internationalisation/country';
import type { CountryGroupId } from '../internationalisation/countryGroup';

export const breakpoints = {
mobile: 320,
mobileMedium: 375,
mobileLandscape: 480,
phablet: 660,
tablet: 740,
desktop: 980,
leftCol: 1140,
wide: 1300,
};

type Breakpoint = keyof typeof breakpoints;

type BreakpointRange = {
minWidth?: Breakpoint;
maxWidth?: Breakpoint;
};

export type Audience = {
offset: number;
size: number;
breakpoint?: BreakpointRange;
};

type AudienceType = IsoCountry | CountryGroupId | 'ALL' | 'CONTRIBUTIONS_ONLY';

type Audiences = {
[key in AudienceType]?: Audience;
};

export type AcquisitionABTest = {
name: string;
variant: string;
testType?: string;
};

export type Variant = {
id: string;
};

export type Test = {
variants: Variant[];
audiences: Audiences;
isActive: boolean;
canRun?: () => boolean;
// Indicates whether the A/B test is controlled by the referrer (acquisition channel)
// e.g. Test of a banner design change on dotcom
// If true the A/B test participation info should be passed through in the acquisition data
// query parameter.
// In particular this allows 3rd party tests to be identified and tracked in support-frontend
// without too much "magic" involving the shared mvtId.
referrerControlled: boolean;
// If another test participation is referrerControlled, exclude this test
excludeIfInReferrerControlledTest?: boolean;
seed: number;
// An optional regex that will be tested against the path of the current page
// before activating this test eg. '/(uk|us|au|ca|nz)/subscribe$'
targetPage?: string | RegExp;
// Persist this test participation across more pages using this regex
persistPage?: string | RegExp;
omitCountries?: IsoCountry[];
// Some users will see a version of the checkout that only offers
// the option to make contributions. We won't want to include these
// users in some AB tests
excludeContributionsOnlyCountries: boolean;
};

export type Tests = Record<string, Test>;

export type Participations = Record<string, string | undefined>;
38 changes: 38 additions & 0 deletions support-frontend/assets/helpers/abTests/sessionStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as storage from '../storage/storage';
import type { Participations } from './models';

function getParticipationsFromSession(
key: string = 'abParticipations',
): Participations | undefined {
const participations = storage.getSession(key);
if (participations) {
try {
return JSON.parse(participations) as Participations;
} catch (error) {
console.error(
'Failed to parse abParticipations from session storage',
error,
);
return undefined;
}
}
return undefined;
}

const landingPageParticipationsKey = 'landingPageParticipations';
function getLandingPageParticipationsFromSession(): Participations | undefined {
return getParticipationsFromSession(landingPageParticipationsKey);
}

function setLandingPageParticipations(participations: Participations) {
storage.setSession(
landingPageParticipationsKey,
JSON.stringify(participations),
);
}

export {
getParticipationsFromSession,
getLandingPageParticipationsFromSession,
setLandingPageParticipations,
};
Loading

0 comments on commit 2b982d1

Please sign in to comment.