Skip to content
Open
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
36 changes: 34 additions & 2 deletions src/app/pages/ArticlePage/ArticlePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { use, useState } from 'react';
import { use, useState, useCallback } from 'react';
import { useTheme } from '@emotion/react';
import useToggle from '#hooks/useToggle';
import useMediaQuery from '#hooks/useMediaQuery';
import useScrollDepthTracker from '#hooks/useScrollDepthTracker';
import { GROUP_4_MIN_WIDTH_BP } from '#app/components/ThemeProvider/mediaQueries';
import { singleTextBlock } from '#app/models/blocks';
import { BylineLinkedData } from '#app/components/LinkedData/types';
import OptimizelyPageMetrics from '#app/components/OptimizelyPageMetrics';
Expand Down Expand Up @@ -203,6 +206,7 @@ const getContinueReadingButton =

const ArticlePage = ({ pageData }: { pageData: Article }) => {
const [showAllContent, setShowAllContent] = useState(false);
const [isDesktopViewport, setIsDesktopViewport] = useState(false);
const { isApp, isAmp, isLite, pageType } = use(RequestContext);

const {
Expand All @@ -212,6 +216,16 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
translations,
} = use(ServiceContext);

// Track when viewport enters GROUP_4_MIN_WIDTH (1008px+) where button is hidden
const handleDesktopMediaQueryChange = useCallback(mediaQueryList => {
setIsDesktopViewport(mediaQueryList.matches);
}, []);

useMediaQuery(
`(min-width: ${GROUP_4_MIN_WIDTH_BP}rem)`,
handleDesktopMediaQueryChange,
);

const { enabled: preloadLeadImageToggle } = useToggle('preloadLeadImage');
const { enabled: continueReadingButtonToggle } = useToggle(
'continueReadingButton',
Expand Down Expand Up @@ -304,6 +318,24 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
continueReadingButtonToggle,
);

// Extract block types
// Check if block types include embeds
const articleEmbedTypes = ['embedHtml', 'oEmbed'];

const hasEmbeds = blocks.some(block =>
articleEmbedTypes.includes(block.type),
);

// On desktop (GROUP_4+), all content is visible, so enable scroll tracking immediately
// On mobile/tablet, only enable tracking when button is clicked and content is expanded
const scrollDepthEnabled =
!hasEmbeds &&
(isDesktopViewport || !showContinueReadingButton || showAllContent);
const scrollDepthRef = useScrollDepthTracker(
'article-scroll-depth',
scrollDepthEnabled,
);

const promoImageBlocks =
pageData?.promo?.images?.defaultPromoImage?.blocks ?? [];

Expand Down Expand Up @@ -444,7 +476,7 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
<ArticleMessageBanner aboutTags={aboutTags} taggings={taggings} />
<div css={styles.grid}>
<div css={!isPGL ? styles.primaryColumn : styles.pglColumn}>
<main css={styles.mainContent} role="main">
<main css={styles.mainContent} role="main" ref={scrollDepthRef}>
<Blocks
blocks={articleBlocks}
componentsToRender={componentsToRender}
Expand Down
240 changes: 240 additions & 0 deletions src/app/pages/ArticlePage/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { Curation } from '#app/models/types/curationData';
import { Article, OptimoBlock } from '#app/models/types/optimo';
import * as clickTracking from '#app/hooks/useClickTrackerHandler';
import * as viewTracking from '#app/hooks/useViewTracker';
import useScrollDepthTracker from '#app/hooks/useScrollDepthTracker';
import useMediaQuery from '#hooks/useMediaQuery';
import {
render,
screen,
Expand Down Expand Up @@ -81,6 +83,9 @@ jest.mock('#app/lib/utilities/onClient', () => ({
onClient: jest.fn(() => true),
}));

jest.mock('#app/hooks/useScrollDepthTracker', () => jest.fn(() => null));
jest.mock('#hooks/useMediaQuery', () => jest.fn());

const input = {
bbcOrigin: 'https://www.test.bbc.co.uk',
id: 'c0000000000o',
Expand Down Expand Up @@ -1547,4 +1552,239 @@ describe('Article Page', () => {
expect(queryByTestId('location-based-topic-oj')).not.toBeInTheDocument();
});
});

describe('Scroll Depth Tracking', () => {
const continueReadingBlock = {
id: 'continue-reading-block',
type: 'continueReading',
model: {},
};

const baseBlocks =
articleDataPersianWithFourParagraphs.content.model.blocks;

describe('with Continue Reading Button', () => {
let mockUseMediaQuery: jest.Mock;
let mockUseScrollDepthTracker: jest.Mock;
let mediaQueryCallback: ((mediaQueryList: MediaQueryList) => void) | null;

beforeEach(() => {
mockUseMediaQuery = jest.mocked(useMediaQuery);
mockUseScrollDepthTracker = jest.mocked(useScrollDepthTracker);
mediaQueryCallback = null;

jest
.spyOn(clickTracking, 'default')
.mockReturnValue({ onClick: jest.fn() });

// Capture the callback passed to useMediaQuery
mockUseMediaQuery.mockImplementation((query, callback) => {
mediaQueryCallback = callback;
});

mockUseScrollDepthTracker.mockReturnValue(null);
});

it('should enable scroll tracking immediately on desktop viewport (GROUP_4_MIN_WIDTH+)', async () => {
const pageData: Article = {
...articleDataPersianWithFourParagraphs,
content: {
...articleDataPersianWithFourParagraphs.content,
model: {
...articleDataPersianWithFourParagraphs.content.model,
blocks: [...baseBlocks, continueReadingBlock],
},
},
};

render(<ArticlePage pageData={pageData} />, {
service: 'persian',
toggles: { continueReadingButton: { enabled: true } },
});

if (mediaQueryCallback) {
act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mediaQueryCallback!({ matches: true } as MediaQueryList);
});
}

await waitFor(() => {
expect(mockUseScrollDepthTracker).toHaveBeenCalledWith(
'article-scroll-depth',
true,
);
});
});

it('should disable scroll tracking when button is collapsed on mobile viewport', async () => {
const pageData: Article = {
...articleDataPersianWithFourParagraphs,
content: {
...articleDataPersianWithFourParagraphs.content,
model: {
...articleDataPersianWithFourParagraphs.content.model,
blocks: [...baseBlocks, continueReadingBlock],
},
},
};

render(<ArticlePage pageData={pageData} />, {
service: 'persian',
toggles: { continueReadingButton: { enabled: true } },
});

if (mediaQueryCallback) {
act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mediaQueryCallback!({ matches: false } as MediaQueryList);
});
}

await waitFor(() => {
expect(mockUseScrollDepthTracker).toHaveBeenCalledWith(
'article-scroll-depth',
false,
);
});
});

it('should enable scroll tracking when Continue Reading button is clicked on mobile', async () => {
const pageData: Article = {
...articleDataPersianWithFourParagraphs,
content: {
...articleDataPersianWithFourParagraphs.content,
model: {
...articleDataPersianWithFourParagraphs.content.model,
blocks: [...baseBlocks, continueReadingBlock],
},
},
};

render(<ArticlePage pageData={pageData} />, {
service: 'persian',
toggles: { continueReadingButton: { enabled: true } },
});

if (mediaQueryCallback) {
act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
mediaQueryCallback!({ matches: false } as MediaQueryList);
});
}

const continueReadingButton = screen.getByTestId(
'continue-reading-button',
);

expect(continueReadingButton).toBeInTheDocument();

// Click the button to expand content
act(() => {
continueReadingButton.click();
});

// After button click with showAllContent = true, scroll tracking should be enabled
await waitFor(() => {
expect(mockUseScrollDepthTracker).toHaveBeenCalledWith(
'article-scroll-depth',
true,
);
});
});
});

describe('without Continue Reading Button', () => {
let mockUseScrollDepthTracker: jest.Mock;

beforeEach(() => {
mockUseScrollDepthTracker = jest.mocked(useScrollDepthTracker);
mockUseScrollDepthTracker.mockReturnValue(null);
});

it('should enable scroll tracking immediately when no Continue Reading block is present', () => {
const pageData: Article = {
...articleDataPersianWithFourParagraphs,
content: {
...articleDataPersianWithFourParagraphs.content,
model: {
...articleDataPersianWithFourParagraphs.content.model,
blocks: baseBlocks, // No continue reading block
},
},
};

render(<ArticlePage pageData={pageData} />, {
service: 'persian',
toggles: { continueReadingButton: { enabled: true } },
});

// Scroll tracking should be enabled from the start
expect(mockUseScrollDepthTracker).toHaveBeenCalledWith(
'article-scroll-depth',
true,
);
});

it('should enable scroll tracking immediately when toggle is disabled', () => {
const pageData: Article = {
...articleDataPersianWithFourParagraphs,
content: {
...articleDataPersianWithFourParagraphs.content,
model: {
...articleDataPersianWithFourParagraphs.content.model,
blocks: [...baseBlocks, continueReadingBlock],
},
},
};

render(<ArticlePage pageData={pageData} />, {
service: 'persian',
toggles: { continueReadingButton: { enabled: false } },
});

// Scroll tracking should be enabled because button toggle is off
expect(mockUseScrollDepthTracker).toHaveBeenCalledWith(
'article-scroll-depth',
true,
);
});
});

it('should disable scroll tracking when the article contains an embed', () => {
const mockUseScrollDepthTracker: jest.Mock = jest.mocked(
useScrollDepthTracker,
);
mockUseScrollDepthTracker.mockReturnValue(null);

const pageDataWithRiddle: Article = {
...articleDataNewsWithEmbeds,
};

render(<ArticlePage pageData={pageDataWithRiddle} />, {
service: 'persian',
toggles: { continueReadingButton: { enabled: false } },
});

expect(mockUseScrollDepthTracker).toHaveBeenCalledWith(
'article-scroll-depth',
false,
);
});

it('should listen to the correct media query breakpoint', () => {
const pageData = articleDataPersianWithFourParagraphs;
const mockUseMediaQuery = jest.mocked(useMediaQuery);

render(<ArticlePage pageData={pageData} />, {
service: 'persian',
});

// Should listen to GROUP_4_MIN_WIDTH breakpoint (1008px+)
expect(mockUseMediaQuery).toHaveBeenCalledWith(
'(min-width: 63rem)',
expect.any(Function),
);
});
});
});
Loading