Skip to content

Commit

Permalink
Merge pull request #12048 from bbc/nasaa-create-amp-experiment
Browse files Browse the repository at this point in the history
[NASAA-203/204] - Create AmpExperiment component, add top stories experiment to PS articles
HarveyPeachey authored Oct 21, 2024
2 parents 76b8528 + bf3c63e commit e9c6b27
Showing 10 changed files with 503 additions and 12 deletions.
86 changes: 86 additions & 0 deletions src/app/components/AmpExperiment/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { render, waitFor } from '../react-testing-library-with-providers';
import AmpExperiment from './index';

const experimentConfig = {
someExperiment: {
variants: {
control: 33,
variant_1: 33,
variant_2: 33,
},
},
};

const multipleExperimentConfig = {
aExperiment: {
variants: {
control: 33,
variant_1: 33,
variant_2: 33,
},
},
bExperiment: {
variants: {
control: 33,
variant_1: 33,
variant_2: 33,
},
},
};

describe('Amp experiment container on Amp pages', () => {
it('should render an amp-experiment with the expected config', async () => {
const { container } = render(
<AmpExperiment experimentConfig={experimentConfig} />,
);
expect(container.querySelector('amp-experiment')).toBeInTheDocument();
expect(container).toMatchInlineSnapshot(`
<div>
<amp-experiment>
<script
type="application/json"
>
{"someExperiment":{"variants":{"control":33,"variant_1":33,"variant_2":33}}}
</script>
</amp-experiment>
</div>
`);
});

it('should render an amp-experiment with the expected config when multiple experiments are running at the same time', async () => {
const { container } = render(
<AmpExperiment experimentConfig={multipleExperimentConfig} />,
);
expect(container.querySelector('amp-experiment')).toBeInTheDocument();
expect(container).toMatchInlineSnapshot(`
<div>
<amp-experiment>
<script
type="application/json"
>
{"aExperiment":{"variants":{"control":33,"variant_1":33,"variant_2":33}},"bExperiment":{"variants":{"control":33,"variant_1":33,"variant_2":33}}}
</script>
</amp-experiment>
</div>
`);
});

it(`should add amp-experiment extension script to page head`, async () => {
render(<AmpExperiment experimentConfig={experimentConfig} />);

await waitFor(() => {
const scripts = Array.from(document.querySelectorAll('head script'));

expect(scripts).toEqual(
expect.arrayContaining([
expect.objectContaining({
src: `https://cdn.ampproject.org/v0/amp-experiment-0.1.js`,
}),
]),
);

expect(scripts).toHaveLength(1);
});
});
});
50 changes: 50 additions & 0 deletions src/app/components/AmpExperiment/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** @jsx jsx */
/* @jsxFrag React.Fragment */
import { jsx } from '@emotion/react';
import React from 'react';
import { Helmet } from 'react-helmet';

type Variant = string;
type Experiment = string;
type TrafficAllocationPercentage = number;

type AmpExperimentConfig = {
[key: Experiment]: {
sticky?: boolean;
consentNotificationId?: string;
variants: {
[key: Variant]: TrafficAllocationPercentage;
};
};
};

type AmpExperimentProps = {
[key: Experiment]: AmpExperimentConfig;
};

const AmpHead = () => (
<Helmet>
<script
async
custom-element="amp-experiment"
src="https://cdn.ampproject.org/v0/amp-experiment-0.1.js"
/>
</Helmet>
);

const AmpExperiment = ({ experimentConfig }: AmpExperimentProps) => {
return (
<>
<AmpHead />
<amp-experiment>
<script
type="application/json"
/* eslint-disable-next-line react/no-danger */
dangerouslySetInnerHTML={{ __html: JSON.stringify(experimentConfig) }}
/>
</amp-experiment>
</>
);
};

export default AmpExperiment;
7 changes: 7 additions & 0 deletions src/app/components/AmpExperiment/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare namespace JSX {
interface IntrinsicElements {
'amp-experiment': React.PropsWithChildren<
ScriptHTMLAttributes<HTMLScriptElement>
>;
}
}
20 changes: 18 additions & 2 deletions src/app/pages/ArticlePage/ArticlePage.styles.ts
Original file line number Diff line number Diff line change
@@ -81,8 +81,7 @@ export default {
paddingBottom: `${spacings.QUADRUPLE}rem`,
},
}),

topStoriesAndFeaturesSection: ({ spacings, mq }: Theme) =>
featuresSection: ({ spacings, mq }: Theme) =>
css({
marginBottom: `${spacings.TRIPLE}rem`,

@@ -91,4 +90,21 @@ export default {
padding: `${spacings.DOUBLE}rem`,
},
}),
topStoriesSection: ({ spacings, mq }: Theme) =>
css({
marginBottom: `${spacings.TRIPLE}rem`,
[mq.GROUP_4_MIN_WIDTH]: {
display: 'block',
marginBottom: `${spacings.FULL}rem`,
padding: `${spacings.DOUBLE}rem`,
},
'[amp-x-topStoriesExperiment="show_at_halfway"] &': {
display: 'none',
[mq.GROUP_4_MIN_WIDTH]: {
display: 'block',
marginBottom: `${spacings.FULL}rem`,
padding: `${spacings.DOUBLE}rem`,
},
},
}),
};
33 changes: 28 additions & 5 deletions src/app/pages/ArticlePage/ArticlePage.tsx
Original file line number Diff line number Diff line change
@@ -59,20 +59,26 @@ import {
import { ServiceContext } from '../../contexts/ServiceContext';
import RelatedContentSection from '../../components/RelatedContentSection';
import Disclaimer from '../../components/Disclaimer';

import SecondaryColumn from './SecondaryColumn';

import styles from './ArticlePage.styles';
import { ComponentToRenderProps, TimeStampProps } from './types';
import AmpExperiment from '../../components/AmpExperiment';
import {
experimentTopStoriesConfig,
getExperimentTopStories,
ExperimentTopStories,
} from './experimentTopStories/helpers';

const ArticlePage = ({ pageData }: { pageData: Article }) => {
const { isApp, pageType, service } = useContext(RequestContext);
const { isApp, pageType, service, isAmp, id } = useContext(RequestContext);

const {
articleAuthor,
isTrustProjectParticipant,
showRelatedTopics,
brandName,
} = useContext(ServiceContext);

const { enabled: preloadLeadImageToggle } = useToggle('preloadLeadImage');

const {
@@ -131,6 +137,16 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
...(isCPS && { pageTitle: `${atiAnalytics.pageTitle} - ${brandName}` }),
};

const topStoriesContent = pageData?.secondaryColumn?.topStories;
const { shouldEnableExperimentTopStories, transformedBlocks } =
getExperimentTopStories({
blocks,
topStoriesContent,
isAmp,
service,
id,
});

const componentsToRender = {
visuallyHiddenHeadline,
headline: headings,
@@ -174,6 +190,10 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
<Disclaimer {...props} increasePaddingOnDesktop={false} />
),
podcastPromo: () => (podcastPromoEnabled ? <InlinePodcastPromo /> : null),
experimentTopStories: () =>
topStoriesContent ? (
<ExperimentTopStories topStoriesContent={topStoriesContent} />
) : null,
};

const visuallyHiddenBlock = {
@@ -183,8 +203,8 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
};

const articleBlocks = startsWithHeading
? blocks
: [visuallyHiddenBlock, ...blocks];
? transformedBlocks
: [visuallyHiddenBlock, ...transformedBlocks];

const promoImageBlocks =
pageData?.promo?.images?.defaultPromoImage?.blocks ?? [];
@@ -206,6 +226,9 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {

return (
<div css={styles.pageWrapper}>
{shouldEnableExperimentTopStories && (
<AmpExperiment experimentConfig={experimentTopStoriesConfig} />
)}
<ATIAnalytics atiData={atiData} />
<ChartbeatAnalytics
sectionName={pageData?.relatedContent?.section?.name}
7 changes: 2 additions & 5 deletions src/app/pages/ArticlePage/SecondaryColumn.tsx
Original file line number Diff line number Diff line change
@@ -21,15 +21,12 @@ const SecondaryColumn = ({ pageData }: { pageData: Article }) => {
return (
<div css={styles.secondaryColumn}>
{topStoriesContent && (
<div
css={styles.topStoriesAndFeaturesSection}
data-testid="top-stories"
>
<div css={styles.topStoriesSection} data-testid="top-stories">
<TopStoriesSection content={topStoriesContent} />
</div>
)}
{featuresContent && (
<div css={styles.topStoriesAndFeaturesSection} data-testid="features">
<div css={styles.featuresSection} data-testid="features">
<FeaturesAnalysis
content={featuresContent}
parentColumns={{}}
109 changes: 109 additions & 0 deletions src/app/pages/ArticlePage/experimentTopStories/helpers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { getExperimentTopStories } from './helpers';
import { topStoriesList } from '../PagePromoSections/TopStoriesSection/fixture/index';

describe('AMP top stories experiment', () => {
const mockTextBlock = {
type: 'text',
model: {
blocks: [],
},
};
const expectedExperimentTopStoriesBlock = (index: number) => {
return {
type: 'experimentTopStories',
model: topStoriesList,
id: `experimentTopStories-${index}`,
};
};

const blocksShortLength = [mockTextBlock];

const blocksEvenLength = [
mockTextBlock,
mockTextBlock,
mockTextBlock,
mockTextBlock,
];
const blocksOddLength = [mockTextBlock, mockTextBlock, mockTextBlock];

describe('getExperimentTopStories()', () => {
it('returns shouldEnableExperimentTopStories as true if props match conditions.', () => {
const { shouldEnableExperimentTopStories } = getExperimentTopStories({
blocks: blocksEvenLength,
topStoriesContent: topStoriesList,
isAmp: true,
id: 'c6v11qzyv8po',
service: 'news',
});
expect(shouldEnableExperimentTopStories).toBe(true);
});

it.each`
testDescription | isAmp | id | service
${'all props are undefined'} | ${false} | ${undefined} | ${undefined}
${'only isAmp is true'} | ${true} | ${undefined} | ${undefined}
${'only pathname is undefined'} | ${true} | ${undefined} | ${'news'}
${'only pathname is defined and valid'} | ${false} | ${'c6v11qzyv8po'} | ${undefined}
${'all props defined but pathname is invalid'} | ${false} | ${'c1231qzyv8po'} | ${undefined}
${'only service is undefined'} | ${true} | ${'c6v11qzyv8po'} | ${undefined}
${'only service is defined and valid'} | ${false} | ${undefined} | ${'news'}
${'all props defined but service is invalid'} | ${true} | ${'c6v11qzyv8po'} | ${'igbo'}
`(
'returns shouldEnableExperimentTopStories as false because $testDescription.',
({ isAmp, id, service }) => {
const { shouldEnableExperimentTopStories } = getExperimentTopStories({
blocks: blocksEvenLength,
topStoriesContent: topStoriesList,
isAmp,
id,
service,
});

expect(shouldEnableExperimentTopStories).toBe(false);
},
);

const expectedBlocksEvenLength = [
mockTextBlock,
mockTextBlock,
expectedExperimentTopStoriesBlock(2),
mockTextBlock,
mockTextBlock,
];
const expectedBlocksOddLength = [
mockTextBlock,
expectedExperimentTopStoriesBlock(1),
mockTextBlock,
mockTextBlock,
];

it.each`
testType | inputBlocks | expectedOutput
${'even'} | ${blocksEvenLength} | ${expectedBlocksEvenLength}
${'odd'} | ${blocksOddLength} | ${expectedBlocksOddLength}
`(
'should insert experimentTopStories block into blocks array in the correct position when blocks.length is $testType',
({ inputBlocks, expectedOutput }) => {
const { transformedBlocks } = getExperimentTopStories({
blocks: inputBlocks,
topStoriesContent: topStoriesList,
isAmp: true,
id: 'c6v11qzyv8po',
service: 'news',
});
expect(transformedBlocks).toEqual(expectedOutput);
},
);

it('does not insert experiment top stories blocks if the blocks array length is < 2.', () => {
const { transformedBlocks } = getExperimentTopStories({
blocks: blocksShortLength,
topStoriesContent: topStoriesList,
isAmp: true,
id: 'c6v11qzyv8po',
service: 'news',
});
expect(transformedBlocks).toBe(blocksShortLength);
});
});
});
116 changes: 116 additions & 0 deletions src/app/pages/ArticlePage/experimentTopStories/helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/** @jsx jsx */
import { jsx } from '@emotion/react';
import { OptimoBlock } from '#app/models/types/optimo';
import { TopStoryItem } from '#app/pages/ArticlePage/PagePromoSections/TopStoriesSection/types';
import TopStoriesSection from '../PagePromoSections/TopStoriesSection';
import styles from './index.styles';

export const experimentTopStoriesConfig = {
topStoriesExperiment: {
variants: {
control: 50,
show_at_halfway: 50,
},
},
};

const enableExperimentTopStories = ({
topStoriesContent,
isAmp,
service,
id,
}: {
topStoriesContent: TopStoryItem[] | undefined;
isAmp: boolean;
service: string;
id: string | null;
}) => {
if (!topStoriesContent || !isAmp || !service || !id) return false;
const newsTestAsset = 'c6v11qzyv8po';
const newsAsset = 'cz7xywn940ro';
const sportAsset = 'cpgw0xjmpd3o';
const experimentAssets = [newsAsset, newsTestAsset, sportAsset];
const experimentServices = ['news', 'sport'];

return (
isAmp &&
id &&
experimentServices.includes(service) &&
experimentAssets.includes(id)
);
};

const insertExperimentTopStories = ({
blocks,
topStoriesContent,
}: {
blocks: OptimoBlock[];
topStoriesContent: TopStoryItem[];
}) => {
const insertIndex = Math.floor(blocks.length * 0.5); // halfway index of blocks array
const experimentTopStoriesBlock = {
type: 'experimentTopStories',
model: topStoriesContent,
id: `experimentTopStories-${insertIndex}`,
};

const blocksClone = [...blocks];
blocksClone.splice(insertIndex, 0, experimentTopStoriesBlock);
return blocksClone;
};

export const getExperimentTopStories = ({
blocks,
topStoriesContent,
isAmp,
service,
id,
}: {
blocks: OptimoBlock[];
topStoriesContent: TopStoryItem[] | undefined;
isAmp: boolean;
service: string;
id: string | null;
}) => {
if (!topStoriesContent)
return {
transformedBlocks: blocks,
shouldEnableExperimentTopStories: false,
};

const shouldEnableExperimentTopStories = enableExperimentTopStories({
topStoriesContent,
isAmp,
service,
id,
});

const transformedBlocks =
shouldEnableExperimentTopStories && blocks.length > 2
? insertExperimentTopStories({
blocks,
topStoriesContent,
})
: blocks;

return {
transformedBlocks,
shouldEnableExperimentTopStories,
};
};

export const ExperimentTopStories = ({
topStoriesContent,
}: {
topStoriesContent: TopStoryItem[];
}) => {
return (
<div
css={styles.experimentTopStoriesSection}
data-testid="experiment-top-stories"
data-vars-top-stories-position="experiment"
>
<TopStoriesSection content={topStoriesContent} />
</div>
);
};
15 changes: 15 additions & 0 deletions src/app/pages/ArticlePage/experimentTopStories/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { css, Theme } from '@emotion/react';

export default {
experimentTopStoriesSection: ({ spacings, mq }: Theme) =>
css({
display: 'none',
marginBottom: `${spacings.TRIPLE}rem`,
'[amp-x-topStoriesExperiment="show_at_halfway"] &': {
display: 'block',
[mq.GROUP_4_MIN_WIDTH]: {
display: 'none',
},
},
}),
};
72 changes: 72 additions & 0 deletions src/app/pages/ArticlePage/index.test.tsx
Original file line number Diff line number Diff line change
@@ -39,6 +39,7 @@ import { ServiceContextProvider } from '../../contexts/ServiceContext';
import ArticlePage from './ArticlePage';
import ThemeProvider from '../../components/ThemeProvider';
import ATIAnalytics from '../../components/ATIAnalytics';
import { topStoriesList } from './PagePromoSections/TopStoriesSection/fixture/index';

jest.mock('../../components/ThemeProvider');

@@ -64,6 +65,8 @@ type Props = {
showAdsBasedOnLocation?: boolean;
isApp?: boolean;
promo?: boolean | null;
isAmp?: boolean;
id?: string | null;
};

const Context = ({
@@ -74,12 +77,16 @@ const Context = ({
showAdsBasedOnLocation = false,
isApp = false,
promo = null,
isAmp = false,
id,
}: PropsWithChildren<Props> = {}) => {
const appInput = {
...input,
service,
showAdsBasedOnLocation,
isApp,
isAmp,
id,
};

return (
@@ -869,4 +876,69 @@ describe('Article Page', () => {
);
});
});

describe('when rendering an AMP page', () => {
const pageDataWithSecondaryColumn = {
...articleDataNews,
secondaryColumn: {
topStories: topStoriesList,
features: [],
},
};

const renderAmpPage = ({
service,
id,
}: {
service: Services;
id: string | null;
}) => {
return render(
<Context isAmp service={service} id={id}>
<ArticlePage pageData={pageDataWithSecondaryColumn} />
</Context>,
{
isAmp: true,
service,
id,
},
);
};

const validNewsAsset = 'c6v11qzyv8po';
const validSportAsset = 'cpgw0xjmpd3o';

it.each`
service | id
${'news'} | ${validNewsAsset}
${'sport'} | ${validSportAsset}
`(
'should render page with experiment-top-stories blocks only on specific $service assets',
({ service, id }) => {
const { queryByTestId } = renderAmpPage({
service,
id,
});

expect(queryByTestId('experiment-top-stories')).toBeInTheDocument();
},
);

it.each`
service | id | testDescription
${'news'} | ${'c1231qzyv8po'} | ${'news assets not specified'}
${'sport'} | ${'c1231qzyv8po'} | ${'sport assets not specified'}
${'pidgin'} | ${'c6v11qzyv8po'} | ${`services which are not 'news' or 'sport'`}
`(
'should render page without experiment-top-stories blocks on $testDescription',
({ service, id }) => {
const { queryByTestId } = renderAmpPage({
service,
id,
});

expect(queryByTestId('experiment-top-stories')).not.toBeInTheDocument();
},
);
});
});

0 comments on commit e9c6b27

Please sign in to comment.