Skip to content
Draft
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
54 changes: 54 additions & 0 deletions src/app/components/ATIAnalytics/atiUrl/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ACTIVATION_EVENT,
CLICK_EVENT,
VIEW_EVENT,
VIEWABILITY_CLICK_EVENT,
Expand Down Expand Up @@ -189,3 +190,56 @@ export const buildReverbEventModel = ({
},
};
};

type ActivationEventProps = {
pageIdentifier?: string;
producerName?: string;
statsDestination?: string;
experimentName: string;
experimentVariant: string;
isSignedIn?: boolean;
hashedId?: string | null;
};

/**
* Builds the standalone Piano/Reverb "activation" beacon fired when a user is
* activated into an Optimizely experiment, decoupled from any view/click event.
*/
export const buildActivationEventModel = ({
pageIdentifier,
producerName,
statsDestination,
experimentName,
experimentVariant,
isSignedIn = false,
hashedId = null,
}: ActivationEventProps): ReverbBeaconConfig => ({
params: {
page: {
destination: statsDestination,
name: pageIdentifier,
producer: producerName,
additionalProperties: {
type: 'AT',
},
},
user: {
isSignedIn,
hashedId,
},
},
eventDetails: {
eventName: ACTIVATION_EVENT,
eventPublisher: 'optimizely',
actionName: 'optimizely',
actionType: 'experiment',
background: true,
container: 'unspecified',
experimentName,
experimentVariant,
experience: {
engine_type: ['experimentation'],
engine_id: [`optimizely.${experimentName}.${experimentVariant}`],
},
},
});
8 changes: 7 additions & 1 deletion src/app/components/ATIAnalytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ export type ReverbUserVars = {
};

export type ReverbEventDetails = {
actionName?: string;
actionType?: string;
anchorElement?: HTMLElement;
background?: boolean;
container?: string;
experience?: {
engine_type: Array<string>;
engine_id: Array<string>;
Expand All @@ -129,8 +133,10 @@ export type ReverbEventDetails = {
action: 'select' | 'view';
grouping?: string;
};
eventName: 'pageView' | 'sectionView' | 'sectionClick';
eventName: 'pageView' | 'sectionView' | 'sectionClick' | 'activation';
eventPublisher?: string;
experimentName?: string;
experimentVariant?: string;
group?: string | object;
isClick?: boolean;
item?: string | object;
Expand Down
88 changes: 88 additions & 0 deletions src/app/hooks/useOptimizelyActivationEvent/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { ReactNode } from 'react';
import {
renderHook,
act,
} from '#app/components/react-testing-library-with-providers';
import { EventTrackingContextProvider } from '#contexts/EventTrackingContext';
import { RequestContextProvider } from '#contexts/RequestContext';
import { ServiceContextProvider } from '#contexts/ServiceContext';
import { ToggleContextProvider } from '#contexts/ToggleContext';
import { STORY_PAGE } from '#app/routes/utils/pageTypes';
import { ATIData } from '#app/components/ATIAnalytics/types';
import { Toggles } from '#app/models/types/global';
import sendOptimizelyActivationEvent from '#app/lib/analyticsUtils/sendOptimizelyActivationEvent';
import useOptimizelyActivationEvent from '.';

jest.mock('#app/lib/analyticsUtils/sendOptimizelyActivationEvent');

const defaultToggles = { eventTracking: { enabled: true } } as Toggles;

const wrapper = ({
atiData,
children,
toggles = defaultToggles,
}: {
atiData?: ATIData;
children?: ReactNode | null;
toggles?: Toggles;
}) => (
<RequestContextProvider
bbcOrigin="https://www.test.bbc.com"
pageType={STORY_PAGE}
isAmp={false}
service="news"
pathname="/news/articles/c0000000000o"
>
<ServiceContextProvider service="news">
<ToggleContextProvider toggles={toggles}>
<EventTrackingContextProvider atiData={atiData}>
{children}
</EventTrackingContextProvider>
</ToggleContextProvider>
</ServiceContextProvider>
</RequestContextProvider>
);

describe('useOptimizelyActivationEvent', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('sends the activation event with the resolved ATI context when tracking is enabled', async () => {
const { result } = renderHook(() => useOptimizelyActivationEvent(), {
wrapper,
});

await act(async () => {
await result.current('foo', 'control');
});

expect(sendOptimizelyActivationEvent).toHaveBeenCalledTimes(1);
expect(sendOptimizelyActivationEvent).toHaveBeenCalledWith(
expect.objectContaining({
experimentName: 'foo',
experimentVariant: 'control',
trackingIsEnabled: true,
service: 'news',
}),
);
});

it('reports tracking as disabled when the eventTracking toggle is off', async () => {
const { result } = renderHook(() => useOptimizelyActivationEvent(), {
wrapper: props =>
wrapper({
...props,
toggles: { eventTracking: { enabled: false } } as Toggles,
}),
});

await act(async () => {
await result.current('foo', 'control');
});

expect(sendOptimizelyActivationEvent).toHaveBeenCalledWith(
expect.objectContaining({ trackingIsEnabled: false }),
);
});
});
56 changes: 56 additions & 0 deletions src/app/hooks/useOptimizelyActivationEvent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { use, useCallback } from 'react';
import { VIEW_EVENT } from '#app/lib/analyticsUtils/analytics.const';
import extractATITrackingProps from '#app/lib/analyticsUtils/extractATITrackingProps';
import sendOptimizelyActivationEvent from '#app/lib/analyticsUtils/sendOptimizelyActivationEvent';
import { ServiceContext } from '#contexts/ServiceContext';
import useTrackingToggle from '../useTrackingToggle';

/**
* Returns a stable callback that fires a standalone Piano/Reverb "activation"
* event for the given Optimizely experiment/variant, gathering the required
* ATI context (page, service, tracking toggle) once per render.
*/
const useOptimizelyActivationEvent = () => {
const {
pageIdentifier,
platform,
producerId,
producerName,
statsDestination,
isSignedIn,
hashedId,
} = extractATITrackingProps({ eventType: VIEW_EVENT });

const { trackingIsEnabled } = useTrackingToggle();
const { service } = use(ServiceContext);

return useCallback(
(experimentName: string, experimentVariant: string) =>
sendOptimizelyActivationEvent({
experimentName,
experimentVariant,
trackingIsEnabled,
pageIdentifier,
platform,
producerId,
producerName,
statsDestination,
service,
isSignedIn,
hashedId,
}),
[
trackingIsEnabled,
pageIdentifier,
platform,
producerId,
producerName,
statsDestination,
service,
isSignedIn,
hashedId,
],
);
};

export default useOptimizelyActivationEvent;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import onClient from '#lib/utilities/onClient';
import { ReactSDKClient } from '@optimizely/react-sdk';
import { RefObject } from 'react';
import activateExperiment from '.';

jest.mock('#lib/utilities/onClient');
Expand All @@ -18,6 +19,10 @@ describe('activateExperiment', () => {
const mockExperimentName = 'foo';
const mockExperimentVariation = 'bar';

const getActivatedExperiments = (): RefObject<string[]> => ({
current: [],
});

it('should set a forced variation and activate experiment when on client', async () => {
(onClient as jest.Mock).mockReturnValueOnce(true);
mockOptimizely.onReady.mockResolvedValue({ success: true });
Expand All @@ -26,6 +31,7 @@ describe('activateExperiment', () => {
optimizely: mockOptimizely as unknown as ReactSDKClient,
experimentName: mockExperimentName,
experimentVariation: mockExperimentVariation,
activatedExperiments: getActivatedExperiments(),
});

expect(mockOptimizely.onReady).toHaveBeenCalledTimes(1);
Expand All @@ -46,10 +52,54 @@ describe('activateExperiment', () => {
optimizely: mockOptimizely as unknown as ReactSDKClient,
experimentName: mockExperimentName,
experimentVariation: mockExperimentVariation,
activatedExperiments: getActivatedExperiments(),
});

expect(mockOptimizely.onReady).not.toHaveBeenCalled();
expect(mockOptimizely.setForcedVariation).not.toHaveBeenCalled();
expect(mockOptimizely.activate).not.toHaveBeenCalled();
});

it('should call onExperimentActivated once when activation succeeds', async () => {
(onClient as jest.Mock).mockReturnValueOnce(true);
mockOptimizely.onReady.mockResolvedValue({ success: true });
const onExperimentActivated = jest.fn();

await activateExperiment({
optimizely: mockOptimizely as unknown as ReactSDKClient,
experimentName: mockExperimentName,
experimentVariation: mockExperimentVariation,
activatedExperiments: getActivatedExperiments(),
onExperimentActivated,
});

expect(onExperimentActivated).toHaveBeenCalledTimes(1);
expect(onExperimentActivated).toHaveBeenCalledWith('foo', 'bar');
});

it('should not activate again or call onExperimentActivated if the experiment was already activated', async () => {
(onClient as jest.Mock).mockReturnValue(true);
mockOptimizely.onReady.mockResolvedValue({ success: true });
const onExperimentActivated = jest.fn();
const activatedExperiments = getActivatedExperiments();

await activateExperiment({
optimizely: mockOptimizely as unknown as ReactSDKClient,
experimentName: mockExperimentName,
experimentVariation: mockExperimentVariation,
activatedExperiments,
onExperimentActivated,
});

await activateExperiment({
optimizely: mockOptimizely as unknown as ReactSDKClient,
experimentName: mockExperimentName,
experimentVariation: mockExperimentVariation,
activatedExperiments,
onExperimentActivated,
});

expect(mockOptimizely.activate).toHaveBeenCalledTimes(1);
expect(onExperimentActivated).toHaveBeenCalledTimes(1);
});
});
12 changes: 11 additions & 1 deletion src/app/hooks/useOptimizelyVariation/activateExperiment/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import onClient from '#lib/utilities/onClient';
import { ReactSDKClient } from '@optimizely/react-sdk';
import { RefObject } from 'react';

type Props = {
optimizely: ReactSDKClient;
experimentName: string;
experimentVariation: string;
activatedExperiments: RefObject<string[]>;
onExperimentActivated?: (
experimentName: string,
experimentVariation: string,
) => void;
};

const activateExperiment = async ({
optimizely,
experimentName,
experimentVariation,
activatedExperiments,
onExperimentActivated,
}: Props) => {
if (onClient() && optimizely) {
const success = await optimizely?.onReady();
if (success) {
if (success && !activatedExperiments.current.includes(experimentName)) {
activatedExperiments.current.push(experimentName);
optimizely.setForcedVariation(experimentName, experimentVariation);
optimizely.activate(experimentName);
onExperimentActivated?.(experimentName, experimentVariation);
}
}
};
Expand Down
Loading
Loading