From 3493543940d89cee23bef70ce2b3a68c4ba8952f Mon Sep 17 00:00:00 2001 From: Olwen Date: Fri, 23 Oct 2026 08:37:16 +0100 Subject: [PATCH 01/33] Render mini nav data --- dotcom-rendering/src/components/Card/Card.tsx | 3 + .../src/components/FrontCard.miniNav.test.tsx | 151 +++++++++ .../src/components/FrontCard.stories.tsx | 227 +++++++++++++ dotcom-rendering/src/components/FrontCard.tsx | 33 +- .../Masthead/Titlepiece/CustomSubNav.tsx | 36 +- dotcom-rendering/src/frontend/feFront.ts | 4 + .../src/frontend/schemas/feFront.json | 310 ++++++++++-------- .../src/frontend/schemas/feTagPage.json | 295 +++++++++-------- .../src/model/enhanceCards.miniNav.test.ts | 115 +++++++ dotcom-rendering/src/model/enhanceCards.ts | 4 + dotcom-rendering/src/types/front.ts | 3 + 11 files changed, 890 insertions(+), 291 deletions(-) create mode 100644 dotcom-rendering/src/components/FrontCard.miniNav.test.tsx create mode 100644 dotcom-rendering/src/components/FrontCard.stories.tsx create mode 100644 dotcom-rendering/src/model/enhanceCards.miniNav.test.ts diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 6783a1cac62..835d0b292a1 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -72,6 +72,7 @@ import { TrailText, type TrailTextSize } from './components/TrailText'; export type Position = 'inner' | 'outer' | 'none'; export type Props = { + headerContent?: React.ReactNode; linkTo: string; format: ArticleFormat; /** The format of the article holding the card */ @@ -353,6 +354,7 @@ const decideSublinkPosition = ( }; export const Card = ({ + headerContent, linkTo, format, contextFormat, @@ -855,6 +857,7 @@ export const Card = ({ } isLoopAndInLoopClickTestVariant={isLoopAndInLoopClickTestVariant} > + {headerContent} { + it.each([true, false])( + 'renders the mini nav with showHeaderText set to %s', + (showHeaderText) => { + const parsedResponse = JSON.parse(response) as { + pressedPage: { + collections: { + curated: FEFrontCard[]; + }[]; + }; + }; + const [frontCard] = + parsedResponse.pressedPage.collections[0]!.curated; + if (!frontCard) throw new Error('DCR response has no curated card'); + + const [enhancedCard] = enhanceCards([frontCard], { + cardInTagPage: false, + editionId: 'UK', + discussionApiUrl: '', + serverSideABTests: {}, + featureSwitches: {}, + }); + if (!enhancedCard) + throw new Error('DCR card could not be enhanced'); + if (!enhancedCard.miniNavData) + throw new Error('Mini nav data is missing'); + enhancedCard.miniNavData.header.showHeaderText = showHeaderText; + + render( + + + , + ); + + if (showHeaderText) { + const header = screen.getByRole('link', { name: 'Football' }); + expect(header).toHaveAttribute('href', '/football'); + expect(header).toHaveStyle('text-decoration: underline'); + } else { + expect(screen.queryByText('Football')).not.toBeInTheDocument(); + } + expect( + screen.getByRole('link', { name: 'More Football' }), + ).toHaveAttribute('href', '/football'); + expect(screen.getByText(/PSG beat Barça/)).toBeInTheDocument(); + + const miniNav = screen + .getByRole('link', { name: 'More Football' }) + .closest('[data-component-id]'); + const navWrapper = miniNav?.parentElement; + const cardWrapper = navWrapper?.parentElement; + + expect(cardWrapper).toHaveStyle({ + display: 'flex', + flexDirection: 'column', + width: '100%', + }); + expect(cardWrapper?.firstElementChild).toBe(navWrapper); + expect(cardWrapper).toContainElement( + screen.getByText(/PSG beat Barça/), + ); + expect(navWrapper).toHaveStyle({ + '--masthead-nav-link-text': 'var(--article-text)', + '--masthead-nav-link-text-hover': 'var(--article-text)', + }); + }, + ); +}); diff --git a/dotcom-rendering/src/components/FrontCard.stories.tsx b/dotcom-rendering/src/components/FrontCard.stories.tsx new file mode 100644 index 00000000000..a669fd0b33b --- /dev/null +++ b/dotcom-rendering/src/components/FrontCard.stories.tsx @@ -0,0 +1,227 @@ +import { css } from '@emotion/react'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { expect, within } from 'storybook/test'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import type { CustomSubnav } from '../types/customSubnav'; +import type { DCRFrontCard } from '../types/front'; +import { LI } from './Card/components/LI'; +import { UL } from './Card/components/UL'; +import { FrontCard } from './FrontCard'; + +const miniNavData: CustomSubnav = { + id: 'environment-mini-nav', + header: { + headerText: 'Environment', + dotcomPath: '/environment', + copy: 'Environment navigation', + showHeaderText: true, + }, + format: 'compact', + links: [ + { + linkText: 'Climate crisis and the changing planet', + dotcomPath: '/environment/climate-crisis', + }, + { + linkText: 'The transition to a greener economy', + dotcomPath: '/environment/green-economy', + }, + { + linkText: 'Wildlife and biodiversity conservation', + dotcomPath: '/environment/wildlife', + }, + { + linkText: 'Renewable energy and clean technology', + dotcomPath: '/environment/energy', + }, + { + linkText: 'Air pollution and public health', + dotcomPath: '/environment/pollution', + }, + { + linkText: 'Protecting our oceans and marine life', + dotcomPath: '/environment/oceans', + }, + { + linkText: 'Sustainable food and farming', + dotcomPath: '/environment/farming', + }, + ], + pages: [], +}; + +const trail: DCRFrontCard = { + format: { + design: ArticleDesign.Standard, + display: ArticleDisplay.Standard, + theme: Pillar.News, + }, + url: '/environment/2022/jan/01/communities-restore-woodland', + headline: 'Communities lead the way in restoring native woodland', + trailText: + 'Local projects are bringing trees and wildlife back to the landscape, with volunteers at the heart of the work.', + image: { + src: 'https://media.guim.co.uk/6537e163c9164d25ec6102641f6a04fa5ba76560/0_210_5472_3283/master/5472.jpg', + altText: 'Woodland landscape', + }, + byline: 'Environment correspondent', + showByline: false, + showQuotedHeadline: false, + isExternalLink: false, + isImmersive: false, + showLivePlayable: false, + dataLinkName: 'environment | mini nav story', + discussionApiUrl: '', + webPublicationDate: '2022-01-01T10:00:00.000Z', + showMiniNav: true, + miniNavData, +}; + +const meta = { + component: FrontCard, + title: 'Components/FrontCard/MiniNav', + args: { + trail, + trailText: trail.trailText, + mediaPositionOnMobile: 'left', + mediaPositionOnDesktop: 'right', + mediaSize: 'small', + imageLoading: 'eager', + serverTime: 1641038400000, + }, + decorators: [ + (Story) => ( +
+
    +
  • + +
  • +
  • + +
  • +
+
+ ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const miniNavs = canvasElement.querySelectorAll( + '[data-component-id="environment-mini-nav"]', + ); + await expect(miniNavs.length).toBeGreaterThan(0); + for (const miniNav of miniNavs) { + const card = miniNav.parentElement?.parentElement; + await expect(card).not.toBeNull(); + if (!card) throw new Error('Mini nav has no containing card'); + const headline = within(card).getByText(trail.headline); + await expect( + miniNav.getBoundingClientRect().bottom, + ).toBeLessThanOrEqual(headline.getBoundingClientRect().top); + await expect(miniNav.getBoundingClientRect().top).toBeGreaterThan( + card.getBoundingClientRect().top, + ); + await expect( + miniNav.getBoundingClientRect().right, + ).toBeLessThanOrEqual(card.getBoundingClientRect().right); + const links = miniNav.querySelector('ul'); + if (!links) throw new Error('Mini nav has no links list'); + await expect(links.scrollWidth).toBeGreaterThan(links.clientWidth); + } + for (const { linkText, dotcomPath } of miniNavData.links) { + for (const link of canvas.getAllByRole('link', { + name: linkText, + })) { + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', dotcomPath); + } + } + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const HeaderIsLink = { + play: async (context) => { + await meta.play(context); + const canvas = within(context.canvasElement); + for (const header of canvas.getAllByRole('link', { + name: 'Environment', + })) { + await expect(header).toBeVisible(); + await expect(header).toHaveAttribute('href', '/environment'); + await expect(header).toHaveStyle('text-decoration: underline'); + await expect(header).toHaveStyle('border-right-style: none'); + } + }, +} satisfies Story; + +export const HeaderIsNotLink = { + args: { + trail: { + ...trail, + miniNavData: { + ...miniNavData, + header: { + ...miniNavData.header, + dotcomPath: undefined, + }, + }, + }, + }, + play: async (context) => { + await meta.play(context); + const canvas = within(context.canvasElement); + for (const header of canvas.getAllByText('Environment')) { + await expect(header).toBeVisible(); + await expect(header.closest('a')).toBeNull(); + } + await expect( + canvas.queryByRole('link', { name: 'Environment' }), + ).not.toBeInTheDocument(); + }, +} satisfies Story; + +export const HiddenHeader = { + args: { + trail: { + ...trail, + miniNavData: { + ...miniNavData, + header: { + ...miniNavData.header, + showHeaderText: false, + }, + }, + }, + }, + play: async (context) => { + await meta.play(context); + const canvas = within(context.canvasElement); + await expect(canvas.queryByText('Environment')).not.toBeInTheDocument(); + await expect( + canvas.queryByRole('link', { name: 'Environment' }), + ).not.toBeInTheDocument(); + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/FrontCard.tsx b/dotcom-rendering/src/components/FrontCard.tsx index 930629a12e6..caef2f6021c 100644 --- a/dotcom-rendering/src/components/FrontCard.tsx +++ b/dotcom-rendering/src/components/FrontCard.tsx @@ -1,8 +1,12 @@ +import { css } from '@emotion/react'; import { ArticleDesign } from '../lib/articleFormat'; import { isMediaCard } from '../lib/cardHelpers'; +import { getZIndex } from '../lib/getZIndex'; +import { palette } from '../palette'; import type { DCRFrontCard } from '../types/front'; import type { Props as CardProps } from './Card/Card'; import { Card } from './Card/Card'; +import { CustomSubNav } from './Masthead/Titlepiece/CustomSubNav'; type Props = { trail: DCRFrontCard; @@ -62,5 +66,32 @@ export const FrontCard = (props: Props) => { headlineTestUuid: trail.headlineTestUuid, }; - return Card({ ...defaultProps, ...cardProps }); + const headerContent = + trail.showMiniNav && trail.miniNavData ? ( +
+ +
+ ) : undefined; + + return Card({ + ...defaultProps, + ...cardProps, + ...(headerContent && { headerContent }), + }); }; diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx index 22a899e0eef..af3ab734f25 100644 --- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx +++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx @@ -27,6 +27,8 @@ type Props = { currentNavLink: string; renderingPage: RenderingPage; hasPageSkin?: boolean; + underlineHeader?: boolean; + showHeaderDivider?: boolean; }; const subNavStyles = css` @@ -266,13 +268,22 @@ const headerLinkStyles = css` const HeaderText = ({ header: { headerText, dotcomPath }, cssOverrides, + underlineHeader, }: { header: CustomSubnavHeader; cssOverrides?: SerializedStyles; + underlineHeader?: boolean; }) => dotcomPath ? ( { return customSubNav.header.showHeaderText ? ( - + ) : undefined; }; @@ -311,6 +327,8 @@ export const CustomSubNav = ({ currentNavLink, renderingPage, hasPageSkin, + underlineHeader, + showHeaderDivider = true, }: Props) => { const isArticle = renderingPage === 'article'; /** DCR receives images for all platforms; only web images are rendered here. */ @@ -353,7 +371,11 @@ export const CustomSubNav = ({ images={webImages} headerText={customSubNav.header.headerText} /> - {CustomHeader(customSubNav, imageHeaderTextStyles)} + {CustomHeader( + customSubNav, + imageHeaderTextStyles, + underlineHeader, + )}