diff --git a/dotcom-rendering/fixtures/manual/customSubnav.ts b/dotcom-rendering/fixtures/manual/customSubnav.ts new file mode 100644 index 00000000000..8e1f6f40dae --- /dev/null +++ b/dotcom-rendering/fixtures/manual/customSubnav.ts @@ -0,0 +1,26 @@ +import type { CustomSubnav } from '../../src/types/customSubnav'; + +export const customSubnav: CustomSubnav = { + id: 'us-politics', + header: { + headerText: 'US politics', + copy: '', + dotcomPath: '/us-news/us-politics', + showHeaderText: true, + }, + format: 'large', + links: [ + { + linkText: 'Trump administration', + dotcomPath: '/us-news/trump-administration', + }, + { linkText: 'US Congress', dotcomPath: '/us-news/us-congress' }, + { linkText: 'US supreme court', dotcomPath: '/law/us-supreme-court' }, + { + linkText: 'US elections 2024', + dotcomPath: '/us-news/us-elections-2024', + }, + { linkText: 'US immigration', dotcomPath: '/us-news/usimmigration' }, + ], + pages: [], +}; diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index 767df4c56d1..8b1a5c6605e 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, @@ -818,6 +820,7 @@ export const Card = ({ return ( + {headerContent} css` :hover .media-overlay { width: 100%; height: 100%; @@ -59,7 +61,12 @@ const hoverStyles = css` */ :has( ul.sublinks:hover, - .video-container:not(.cinemagraph):hover, + .mini-nav:hover, + .video-container:not( + ${isLoopAndInLoopClickTestVariant + ? `.cinemagraph, .loop` + : `.cinemagraph`} + ):hover, .slideshow-carousel-footer:hover, .branding-logo:hover ) { @@ -72,23 +79,23 @@ const hoverStyles = css` } `; -const topBarStyles = (colour: string) => css` +const topBarStyles = (colour: string, padding: number) => css` ::before { border-top: 1px solid ${colour}; content: ''; width: 100%; - padding-bottom: ${space[2]}px; + padding-bottom: ${padding}px; background-color: unset; } `; -const mobileTopBarStyles = (colour: string) => css` +const mobileTopBarStyles = (colour: string, padding: number) => css` ${until.tablet} { - ${topBarStyles(colour)} + ${topBarStyles(colour, padding)} } `; -const desktopTopBarStyles = (colour: string) => css` +const desktopTopBarStyles = (colour: string, padding: number) => css` ${from.tablet} { - ${topBarStyles(colour)} + ${topBarStyles(colour, padding)} } `; @@ -99,6 +106,8 @@ export const CardWrapper = ({ showTopBarMobile, containerPalette, topBarColour = palette('--card-border-top'), + topBarPadding = space[2], + isLoopAndInLoopClickTestVariant, }: Props) => { return ( @@ -106,9 +115,11 @@ export const CardWrapper = ({
{children} diff --git a/dotcom-rendering/src/components/CardMiniNav.tsx b/dotcom-rendering/src/components/CardMiniNav.tsx new file mode 100644 index 00000000000..f7693d487e8 --- /dev/null +++ b/dotcom-rendering/src/components/CardMiniNav.tsx @@ -0,0 +1,35 @@ +import { css } from '@emotion/react'; +import { getZIndex } from '../lib/getZIndex'; +import { palette } from '../palette'; +import type { DCRFrontCard } from '../types/front'; +import { CustomSubNav } from './Masthead/Titlepiece/CustomSubNav'; + +type Props = { + miniNavData: DCRFrontCard['miniNavData']; +}; + +export const CardMiniNav = ({ miniNavData }: Props) => { + if (!miniNavData) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/dotcom-rendering/src/components/FrontCard.miniNav.test.tsx b/dotcom-rendering/src/components/FrontCard.miniNav.test.tsx new file mode 100644 index 00000000000..7ed79f76030 --- /dev/null +++ b/dotcom-rendering/src/components/FrontCard.miniNav.test.tsx @@ -0,0 +1,201 @@ +import { render, screen } from '@testing-library/react'; +import { customSubnav } from '../../fixtures/manual/customSubnav'; +import { trails } from '../../fixtures/manual/trails'; +import type { CustomSubnav } from '../types/customSubnav'; +import type { DCRContainerType, DCRFrontCard } from '../types/front'; +import { ConfigProvider } from './ConfigContext'; +import { FrontCard } from './FrontCard'; + +const miniNavData = { + ...customSubnav, + id: 'football-mini-nav', + header: { + ...customSubnav.header, + headerText: 'Football', + dotcomPath: '/football', + copy: 'Football navigation', + }, + format: 'compact', + links: [{ linkText: 'More Football', dotcomPath: '/football' }], +} satisfies CustomSubnav; + +const defaultTrail = { + ...trails[0], + boostLevel: 'boost', + showMiniNav: true, + miniNavData, +} satisfies DCRFrontCard; + +const renderCard = ({ + trail = defaultTrail, + ...props +}: { + trail?: DCRFrontCard; + containerType?: DCRContainerType; +} = {}) => + render( + + + , + ); + +const expectMiniNavAbsent = () => { + expect(screen.queryByText('Football')).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'More Football' }), + ).not.toBeInTheDocument(); + expect(screen.getByText(defaultTrail.headline)).toBeInTheDocument(); +}; + +describe('FrontCard mini navigation', () => { + it.each(['boost', 'megaboost', 'gigaboost'] as const)( + 'renders mini navigation for a %s card', + (boostLevel) => { + renderCard({ trail: { ...defaultTrail, boostLevel } }); + + expect( + screen.getByRole('link', { name: 'More Football' }), + ).toHaveAttribute('href', '/football'); + expect(screen.getByText(defaultTrail.headline)).toBeInTheDocument(); + }, + ); + + it.each([undefined, 'default'] as const)( + 'does not render mini navigation with boost level %s', + (boostLevel) => { + renderCard({ trail: { ...defaultTrail, boostLevel } }); + + expectMiniNavAbsent(); + }, + ); + + it.each([undefined, 'static/medium/4', 'flexible/special'] as const)( + 'does not render mini navigation in container %s', + (containerType) => { + renderCard({ containerType }); + + expectMiniNavAbsent(); + }, + ); + + it('does not render mini navigation when disabled', () => { + renderCard({ trail: { ...defaultTrail, showMiniNav: false } }); + + expectMiniNavAbsent(); + }); + + it('does not render mini navigation when configuration is missing', () => { + renderCard({ trail: { ...defaultTrail, miniNavData: undefined } }); + + expectMiniNavAbsent(); + }); + + it('renders a header link without an underline when header text is enabled', () => { + renderCard(); + + const header = screen.getByRole('link', { name: 'Football' }); + expect(header).toHaveAttribute('href', '/football'); + expect(header).toHaveStyle('text-decoration: none'); + }); + + it('renders header text without a link when the header has no path', () => { + renderCard({ + trail: { + ...defaultTrail, + miniNavData: { + ...miniNavData, + header: { ...miniNavData.header, dotcomPath: undefined }, + }, + }, + }); + + expect(screen.getByText('Football')).toBeInTheDocument(); + expect(screen.getByText('Football').closest('a')).toBeNull(); + expect(screen.getByText('Football').closest('.mini-nav')).toHaveStyle({ + '--custom-subnav-header-text': 'var(--card-headline)', + }); + expect( + screen.getByRole('link', { name: 'More Football' }), + ).toHaveAttribute('href', '/football'); + }); + + it('hides the header without hiding navigation links when header text is disabled', () => { + renderCard({ + trail: { + ...defaultTrail, + miniNavData: { + ...miniNavData, + header: { ...miniNavData.header, showHeaderText: false }, + }, + }, + }); + + expect(screen.queryByText('Football')).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'More Football' }), + ).toHaveAttribute('href', '/football'); + expect(screen.getByText(defaultTrail.headline)).toBeInTheDocument(); + }); + + it('places mini navigation before the card content in a full-width column', () => { + renderCard(); + + 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(defaultTrail.headline), + ); + }); + + it('uses card styling rather than article subnav styling', () => { + renderCard(); + + const miniNav = screen + .getByRole('link', { name: 'More Football' }) + .closest('[data-component-id]'); + const list = screen.getByRole('list'); + + expect(miniNav).toHaveAttribute('data-rendering-page', 'card'); + // Article subnavs have a divider (with an 8px gap) and fixed row heights; card mini-navs must not. + // (jsdom drops the `border-left` shorthand as it contains a CSS var, so assert on the divider's padding.) + expect(list).not.toHaveStyle({ paddingLeft: '8px' }); + expect(miniNav).not.toHaveStyle({ minHeight: '36px' }); + }); + + it('uses the card kicker colour for the header and headline colour for links', () => { + renderCard(); + + const miniNav = screen + .getByRole('link', { name: 'More Football' }) + .closest('[data-component-id]'); + + expect(miniNav?.parentElement).toHaveStyle({ + '--custom-subnav-header-text': 'var(--card-kicker-text)', + '--masthead-nav-link-text': 'var(--card-headline)', + '--masthead-nav-link-text-hover': 'var(--card-headline)', + }); + }); +}); diff --git a/dotcom-rendering/src/components/FrontCard.stories.tsx b/dotcom-rendering/src/components/FrontCard.stories.tsx new file mode 100644 index 00000000000..ffa36055374 --- /dev/null +++ b/dotcom-rendering/src/components/FrontCard.stories.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/react'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { customSubnav } from '../../fixtures/manual/customSubnav'; +import { trails } from '../../fixtures/manual/trails'; +import type { DCRFrontCard } from '../types/front'; +import { LI } from './Card/components/LI'; +import { UL } from './Card/components/UL'; +import { FrontCard } from './FrontCard'; + +const trail = { + ...trails[4], + showMiniNav: true, + boostLevel: 'boost', + miniNavData: { ...customSubnav, format: 'compact' }, +} satisfies DCRFrontCard; + +const meta = { + component: FrontCard, + title: 'Components/FrontCard/MiniNav', + args: { + trail, + containerType: 'flexible/general', + headlineSizes: { + desktop: 'large', + tablet: 'large', + mobile: 'large', + }, + mediaPositionOnMobile: 'bottom', + mediaPositionOnDesktop: 'right', + mediaSize: 'xlarge', + imageLoading: 'eager', + serverTime: 1641038400000, + }, + decorators: [ + (Story) => ( +
+
    +
  • + +
  • +
  • + +
  • +
+
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const BelowBoostedLevel = { + args: { + trail: { + ...trail, + boostLevel: 'default', + }, + }, +} satisfies Story; + +export const HeaderIsLink = {} satisfies Story; + +export const HeaderIsNotLink = { + args: { + trail: { + ...trail, + miniNavData: { + ...trail.miniNavData, + header: { + ...customSubnav.header, + dotcomPath: undefined, + }, + }, + }, + }, +} satisfies Story; + +export const HiddenHeader = { + args: { + trail: { + ...trail, + miniNavData: { + ...trail.miniNavData, + header: { + ...customSubnav.header, + showHeaderText: false, + }, + }, + }, + }, +} satisfies Story; diff --git a/dotcom-rendering/src/components/FrontCard.tsx b/dotcom-rendering/src/components/FrontCard.tsx index fca7f7067bc..4f4803e62e9 100644 --- a/dotcom-rendering/src/components/FrontCard.tsx +++ b/dotcom-rendering/src/components/FrontCard.tsx @@ -1,8 +1,9 @@ import { ArticleDesign } from '../lib/articleFormat'; -import { isMediaCard } from '../lib/cardHelpers'; +import { isMediaCard, isMiniNavSupported } from '../lib/cardHelpers'; import type { DCRFrontCard } from '../types/front'; import type { Props as CardProps } from './Card/Card'; import { Card } from './Card/Card'; +import { CardMiniNav } from './CardMiniNav'; type Props = { trail: DCRFrontCard; @@ -62,5 +63,16 @@ export const FrontCard = (props: Props) => { headlineTestUuid: trail.headlineTestUuid, }; - return Card({ ...defaultProps, ...cardProps }); + const headerContent = + isMiniNavSupported(cardProps.containerType, trail.boostLevel) && + trail.showMiniNav && + trail.miniNavData ? ( + + ) : undefined; + + return Card({ + ...defaultProps, + ...cardProps, + ...(headerContent && { headerContent }), + }); }; diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx index c654c5d4ca0..991dc29f101 100644 --- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx +++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.stories.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/react'; import { palette as sourcePalette, space } from '@guardian/source/foundations'; import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { customSubnav as customSubNav } from '../../../../fixtures/manual/customSubnav'; import { ArticleDesign, ArticleDisplay, @@ -9,31 +10,6 @@ import { import type { CustomSubnav } from '../../../types/customSubnav'; import { CustomSubNav } from './CustomSubNav'; -const customSubNav: CustomSubnav = { - id: 'us-politics', - header: { - headerText: 'US politics', - copy: '', - dotcomPath: '/us-news/us-politics', - showHeaderText: true, - }, - format: 'large', - links: [ - { - linkText: 'Trump administration', - dotcomPath: '/us-news/trump-administration', - }, - { linkText: 'US Congress', dotcomPath: '/us-news/us-congress' }, - { linkText: 'US supreme court', dotcomPath: '/law/us-supreme-court' }, - { - linkText: 'US elections 2024', - dotcomPath: '/us-news/us-elections-2024', - }, - { linkText: 'US immigration', dotcomPath: '/us-news/usimmigration' }, - ], - pages: [], -}; - /** Distinct images per breakpoint so it's obvious which one is being served. */ const customSubNavWithImages: CustomSubnav = { ...customSubNav, diff --git a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx index fed81e51790..f92cdac06a5 100644 --- a/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx +++ b/dotcom-rendering/src/components/Masthead/Titlepiece/CustomSubNav.tsx @@ -27,10 +27,16 @@ import type { } from '../../../types/customSubnav'; import { useConfig } from '../../ConfigContext'; +/** + * Where the subnav is rendered. Extends the payload's `RenderingPage` with `card`, + * used when a subnav is shown as a mini-nav inside a front card. + */ +type SubNavPlacement = RenderingPage | 'card'; + type Props = { customSubNav: CustomSubnav; - renderingPage: RenderingPage; - pageId: string; + renderingPage: SubNavPlacement; + pageId?: string; hasPageSkin?: boolean; canonicalUrl?: string; }; @@ -59,10 +65,14 @@ const subNavStylesFromLeftCol = css` } `; -/** On articles the header and links sit on a single aligned row. */ -const articleContainerStyles = css` +/** Header and links sit on a single aligned row (article subnav and card mini-nav). */ +const inlineRowStyles = css` display: flex; align-items: stretch; +`; + +/** Article subnav row heights. */ +const articleRowHeightStyles = css` min-height: 36px; ${from.mobileMedium} { @@ -95,10 +105,26 @@ const articleSubNavStyles = css` } `; -/** Article subnavs have a divider separating the header text from the scrollable list. */ -const articleDividerStyles = css` +/** Article subnavs keep a small horizontal gap between the header and the list items. */ +const articleHeaderGapStyles = css` margin-left: ${space[2]}px; padding-left: ${space[2]}px; +`; + +/** The inline links list sizes to its content so it scrolls rather than squashing. */ +const inlineListSizingStyles = css` + flex-shrink: 0; + width: max-content; +`; + +const cardMiniNavHeaderGapStyles = css` + margin-left: ${space[1]}px; + padding-left: ${space[1]}px; +`; + +/** Article subnavs have a divider separating the header text from the scrollable list. */ +const articleDividerStyles = css` + ${articleHeaderGapStyles} border-left: 1px solid ${themePalette('--masthead-nav-lines')}; `; @@ -190,6 +216,7 @@ const imageListStyles = css` ${from.tablet} { min-height: 44px; + padding: 0 ${space[5]}px; } `; @@ -349,8 +376,7 @@ export const CustomSubNav = ({ const hasTrackedView = useRef(false); const [isInView, setNode] = useIsInView({}); const { renderingTarget } = useConfig(); - const isNetworkFront = isANetworkFront(pageId); - const isArticle = renderingPage === 'article'; + const isNetworkFront = pageId ? isANetworkFront(pageId) : false; useEffect(() => { if (isInView !== true || hasTrackedView.current) { @@ -375,11 +401,6 @@ export const CustomSubNav = ({ customSubNav.header.headerText, customSubNav.id, ]); - /** DCR receives images for all platforms; only web images are rendered here. */ - const webImages = (customSubNav.images ?? []).filter((image) => - image.platforms.includes('web'), - ); - const hasHeaderImage = !isArticle && webImages.length > 0; const linkItems = customSubNav.links.map(({ linkText, dotcomPath }) => (
  • @@ -403,89 +424,134 @@ export const CustomSubNav = ({
  • )); - if (hasHeaderImage) { - return ( -
    -
    - - {CustomHeader(customSubNav, imageHeaderTextStyles)} -
    -
      + image.platforms.includes('web'), + ); + const hasHeaderImage = webImages.length > 0; + if (hasHeaderImage) { + return ( +
      +
      + + {CustomHeader(customSubNav, imageHeaderTextStyles)} +
      +
        + {linkItems} +
      +
      + ); + } + return ( +
      - {linkItems} -
    -
    - ); - } - - if (isArticle) { - return ( -
    - {CustomHeader(customSubNav, articleHeaderStyles)} -
      + {linkItems} +
    +
    + ); + + /** On articles the header and links sit on one row, separated by a divider. */ + case 'article': + return ( +
    - {linkItems} - -
    - ); + {CustomHeader(customSubNav, articleHeaderStyles)} +
      + {linkItems} +
    +
    + ); + + case 'card': + return ( +
    + {CustomHeader(customSubNav, articleHeaderStyles)} +
      + {linkItems} +
    +
    + ); } - - return ( -
    - {CustomHeader(customSubNav, undefined)} -
      - {linkItems} -
    -
    - ); }; diff --git a/dotcom-rendering/src/frontend/feFront.ts b/dotcom-rendering/src/frontend/feFront.ts index 28ea737f1eb..373970510fd 100644 --- a/dotcom-rendering/src/frontend/feFront.ts +++ b/dotcom-rendering/src/frontend/feFront.ts @@ -9,6 +9,7 @@ import type { Newsletter, StarRating, } from '../types/content'; +import type { CustomSubnav } from '../types/customSubnav'; import type { FooterType } from '../types/footer'; import type { FrontEditorialTest } from '../types/front'; import type { FENavType } from '../types/frontend'; @@ -225,6 +226,7 @@ export type FEFrontCard = { href?: string; embedUri?: string; newsletterData?: Newsletter; + miniNavData?: CustomSubnav; tests?: FrontEditorialTest[]; }; header: { @@ -280,6 +282,8 @@ export type FEFrontCard = { showQuotedHeadline: boolean; imageHide: boolean; showLivePlayable: boolean; + showMiniNav?: boolean; + miniNavData?: CustomSubnav; }; format?: FEFormat; enriched?: FESnap; diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json index a94ffc99686..c48b28f24c5 100644 --- a/dotcom-rendering/src/frontend/schemas/feFront.json +++ b/dotcom-rendering/src/frontend/schemas/feFront.json @@ -638,6 +638,9 @@ "theme" ] }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" + }, "tests": { "type": "array", "items": { @@ -904,6 +907,12 @@ }, "showLivePlayable": { "type": "boolean" + }, + "showMiniNav": { + "type": "boolean" + }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" } }, "required": [ @@ -1700,6 +1709,9 @@ "theme" ] }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" + }, "tests": { "type": "array", "items": { @@ -1966,6 +1978,12 @@ }, "showLivePlayable": { "type": "boolean" + }, + "showMiniNav": { + "type": "boolean" + }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" } }, "required": [ @@ -2762,6 +2780,9 @@ "theme" ] }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" + }, "tests": { "type": "array", "items": { @@ -3028,6 +3049,12 @@ }, "showLivePlayable": { "type": "boolean" + }, + "showMiniNav": { + "type": "boolean" + }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" } }, "required": [ @@ -3901,6 +3928,149 @@ "url" ] }, + "CustomSubnav": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "header": { + "$ref": "#/definitions/CustomSubnavHeader" + }, + "format": { + "enum": [ + "compact", + "large" + ], + "type": "string" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavLink" + } + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavTargetedPage" + } + }, + "images": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavImage" + } + } + }, + "required": [ + "format", + "header", + "id", + "links", + "pages" + ] + }, + "CustomSubnavHeader": { + "type": "object", + "properties": { + "headerText": { + "type": "string" + }, + "showHeaderText": { + "type": "boolean" + }, + "dotcomPath": { + "type": "string" + }, + "copy": { + "type": "string" + } + }, + "required": [ + "copy", + "headerText", + "showHeaderText" + ] + }, + "CustomSubnavLink": { + "type": "object", + "properties": { + "linkText": { + "type": "string" + }, + "dotcomPath": { + "type": "string" + } + }, + "required": [ + "dotcomPath", + "linkText" + ] + }, + "CustomSubnavTargetedPage": { + "type": "object", + "properties": { + "type": { + "enum": [ + "article", + "front", + "hasTag" + ], + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "type" + ] + }, + "CustomSubnavImage": { + "type": "object", + "properties": { + "imageSrc": { + "type": "string" + }, + "breakpoint": { + "$ref": "#/definitions/CustomSubnavImageBreakpoint" + }, + "platforms": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavImagePlatform" + } + } + }, + "required": [ + "breakpoint", + "imageSrc", + "platforms" + ] + }, + "CustomSubnavImageBreakpoint": { + "enum": [ + "desktop", + "leftCol", + "mobile", + "mobileLandscape", + "mobileMedium", + "phablet", + "tablet", + "wide" + ], + "type": "string" + }, + "CustomSubnavImagePlatform": { + "enum": [ + "android", + "ios", + "web" + ], + "type": "string" + }, "VariantId": { "enum": [ "A", @@ -4694,149 +4864,6 @@ "url" ] }, - "CustomSubnav": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "header": { - "$ref": "#/definitions/CustomSubnavHeader" - }, - "format": { - "enum": [ - "compact", - "large" - ], - "type": "string" - }, - "links": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavLink" - } - }, - "pages": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavTargetedPage" - } - }, - "images": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavImage" - } - } - }, - "required": [ - "format", - "header", - "id", - "links", - "pages" - ] - }, - "CustomSubnavHeader": { - "type": "object", - "properties": { - "headerText": { - "type": "string" - }, - "showHeaderText": { - "type": "boolean" - }, - "dotcomPath": { - "type": "string" - }, - "copy": { - "type": "string" - } - }, - "required": [ - "copy", - "headerText", - "showHeaderText" - ] - }, - "CustomSubnavLink": { - "type": "object", - "properties": { - "linkText": { - "type": "string" - }, - "dotcomPath": { - "type": "string" - } - }, - "required": [ - "dotcomPath", - "linkText" - ] - }, - "CustomSubnavTargetedPage": { - "type": "object", - "properties": { - "type": { - "enum": [ - "article", - "front", - "hasTag" - ], - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "path", - "type" - ] - }, - "CustomSubnavImage": { - "type": "object", - "properties": { - "imageSrc": { - "type": "string" - }, - "breakpoint": { - "$ref": "#/definitions/CustomSubnavImageBreakpoint" - }, - "platforms": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavImagePlatform" - } - } - }, - "required": [ - "breakpoint", - "imageSrc", - "platforms" - ] - }, - "CustomSubnavImageBreakpoint": { - "enum": [ - "desktop", - "leftCol", - "mobile", - "mobileLandscape", - "mobileMedium", - "phablet", - "tablet", - "wide" - ], - "type": "string" - }, - "CustomSubnavImagePlatform": { - "enum": [ - "android", - "ios", - "web" - ], - "type": "string" - }, "ReaderRevenuePositions": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/frontend/schemas/feTagPage.json b/dotcom-rendering/src/frontend/schemas/feTagPage.json index 54ac3157724..277eb572bd5 100644 --- a/dotcom-rendering/src/frontend/schemas/feTagPage.json +++ b/dotcom-rendering/src/frontend/schemas/feTagPage.json @@ -481,6 +481,9 @@ "theme" ] }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" + }, "tests": { "type": "array", "items": { @@ -747,6 +750,12 @@ }, "showLivePlayable": { "type": "boolean" + }, + "showMiniNav": { + "type": "boolean" + }, + "miniNavData": { + "$ref": "#/definitions/CustomSubnav" } }, "required": [ @@ -1798,6 +1807,149 @@ "sponsorName" ] }, + "CustomSubnav": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "header": { + "$ref": "#/definitions/CustomSubnavHeader" + }, + "format": { + "enum": [ + "compact", + "large" + ], + "type": "string" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavLink" + } + }, + "pages": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavTargetedPage" + } + }, + "images": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavImage" + } + } + }, + "required": [ + "format", + "header", + "id", + "links", + "pages" + ] + }, + "CustomSubnavHeader": { + "type": "object", + "properties": { + "headerText": { + "type": "string" + }, + "showHeaderText": { + "type": "boolean" + }, + "dotcomPath": { + "type": "string" + }, + "copy": { + "type": "string" + } + }, + "required": [ + "copy", + "headerText", + "showHeaderText" + ] + }, + "CustomSubnavLink": { + "type": "object", + "properties": { + "linkText": { + "type": "string" + }, + "dotcomPath": { + "type": "string" + } + }, + "required": [ + "dotcomPath", + "linkText" + ] + }, + "CustomSubnavTargetedPage": { + "type": "object", + "properties": { + "type": { + "enum": [ + "article", + "front", + "hasTag" + ], + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "type" + ] + }, + "CustomSubnavImage": { + "type": "object", + "properties": { + "imageSrc": { + "type": "string" + }, + "breakpoint": { + "$ref": "#/definitions/CustomSubnavImageBreakpoint" + }, + "platforms": { + "type": "array", + "items": { + "$ref": "#/definitions/CustomSubnavImagePlatform" + } + } + }, + "required": [ + "breakpoint", + "imageSrc", + "platforms" + ] + }, + "CustomSubnavImageBreakpoint": { + "enum": [ + "desktop", + "leftCol", + "mobile", + "mobileLandscape", + "mobileMedium", + "phablet", + "tablet", + "wide" + ], + "type": "string" + }, + "CustomSubnavImagePlatform": { + "enum": [ + "android", + "ios", + "web" + ], + "type": "string" + }, "VariantId": { "enum": [ "A", @@ -2553,149 +2705,6 @@ "url" ] }, - "CustomSubnav": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "header": { - "$ref": "#/definitions/CustomSubnavHeader" - }, - "format": { - "enum": [ - "compact", - "large" - ], - "type": "string" - }, - "links": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavLink" - } - }, - "pages": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavTargetedPage" - } - }, - "images": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavImage" - } - } - }, - "required": [ - "format", - "header", - "id", - "links", - "pages" - ] - }, - "CustomSubnavHeader": { - "type": "object", - "properties": { - "headerText": { - "type": "string" - }, - "showHeaderText": { - "type": "boolean" - }, - "dotcomPath": { - "type": "string" - }, - "copy": { - "type": "string" - } - }, - "required": [ - "copy", - "headerText", - "showHeaderText" - ] - }, - "CustomSubnavLink": { - "type": "object", - "properties": { - "linkText": { - "type": "string" - }, - "dotcomPath": { - "type": "string" - } - }, - "required": [ - "dotcomPath", - "linkText" - ] - }, - "CustomSubnavTargetedPage": { - "type": "object", - "properties": { - "type": { - "enum": [ - "article", - "front", - "hasTag" - ], - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "path", - "type" - ] - }, - "CustomSubnavImage": { - "type": "object", - "properties": { - "imageSrc": { - "type": "string" - }, - "breakpoint": { - "$ref": "#/definitions/CustomSubnavImageBreakpoint" - }, - "platforms": { - "type": "array", - "items": { - "$ref": "#/definitions/CustomSubnavImagePlatform" - } - } - }, - "required": [ - "breakpoint", - "imageSrc", - "platforms" - ] - }, - "CustomSubnavImageBreakpoint": { - "enum": [ - "desktop", - "leftCol", - "mobile", - "mobileLandscape", - "mobileMedium", - "phablet", - "tablet", - "wide" - ], - "type": "string" - }, - "CustomSubnavImagePlatform": { - "enum": [ - "android", - "ios", - "web" - ], - "type": "string" - }, "ReaderRevenuePositions": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/lib/cardHelpers.ts b/dotcom-rendering/src/lib/cardHelpers.ts index 592730a89ff..14e87b913a3 100644 --- a/dotcom-rendering/src/lib/cardHelpers.ts +++ b/dotcom-rendering/src/lib/cardHelpers.ts @@ -1,6 +1,16 @@ -import type { DCRContainerPalette } from '../types/front'; +import type { BoostLevel } from '../types/content'; +import type { DCRContainerPalette, DCRContainerType } from '../types/front'; import { ArticleDesign, type ArticleFormat } from './articleFormat'; +export const isMiniNavSupported = ( + containerType: DCRContainerType | undefined, + boostLevel: BoostLevel | undefined, +): boolean => + containerType === 'flexible/general' && + (boostLevel === 'boost' || + boostLevel === 'megaboost' || + boostLevel === 'gigaboost'); + export const isMediaCard = (format: ArticleFormat): boolean => { switch (format.design) { case ArticleDesign.Gallery: diff --git a/dotcom-rendering/src/model/enhanceCards.miniNav.test.ts b/dotcom-rendering/src/model/enhanceCards.miniNav.test.ts new file mode 100644 index 00000000000..2012c42398a --- /dev/null +++ b/dotcom-rendering/src/model/enhanceCards.miniNav.test.ts @@ -0,0 +1,105 @@ +import { customSubnav } from '../../fixtures/manual/customSubnav'; +import type { FEFrontCard } from '../frontend/feFront'; +import type { CustomSubnav } from '../types/customSubnav'; +import { enhanceCards } from './enhanceCards'; + +const card: FEFrontCard = { + properties: { + isBreaking: false, + showKickerTag: false, + showByline: false, + isLiveBlog: false, + isCrossword: false, + webTitle: 'Headline', + editionBrandings: [], + }, + header: { + isVideo: false, + isComment: false, + isGallery: false, + isAudio: false, + headline: 'Headline', + url: '/example', + hasMainVideoElement: false, + }, + card: { + id: 'example', + cardStyle: { type: 'DefaultCardstyle' }, + shortUrl: '', + group: '0', + isLive: false, + }, + discussion: { + isCommentable: false, + isClosedForComments: false, + }, + display: { + showBoostedHeadline: false, + showQuotedHeadline: false, + imageHide: false, + showLivePlayable: false, + }, + type: '', +}; + +const enhance = (frontCard: FEFrontCard) => + enhanceCards([frontCard], { + cardInTagPage: false, + editionId: 'UK', + discussionApiUrl: '', + serverSideABTests: {}, + featureSwitches: {}, + })[0]; + +describe('enhanceCards mini navigation', () => { + it('disables mini navigation for existing payloads without configuration', () => { + expect(enhance(card)).toMatchObject({ showMiniNav: false }); + expect(enhance(card)?.miniNavData).toBeUndefined(); + }); + + it.each([true, false])( + 'preserves configuration when showMiniNav is %s', + (showMiniNav) => { + const miniNavData: CustomSubnav = { + ...customSubnav, + format: 'compact', + pages: [{ type: 'front', path: '/uk' }], + images: [ + { + imageSrc: 'https://example.com/election.png', + breakpoint: 'mobile', + platforms: ['web'], + }, + ], + }; + const enhancedCard = enhance({ + ...card, + display: { ...card.display, showMiniNav, miniNavData }, + }); + expect(enhancedCard?.showMiniNav).toBe(showMiniNav); + expect(enhancedCard?.miniNavData).toEqual(miniNavData); + }, + ); + + it('preserves a hidden, non-linked header and editorial link order', () => { + const miniNavData: CustomSubnav = { + ...customSubnav, + format: 'compact', + header: { + ...customSubnav.header, + showHeaderText: false, + dotcomPath: undefined, + }, + links: [ + { linkText: 'Results', dotcomPath: '/election/results' }, + { linkText: 'Latest', dotcomPath: '/election/latest' }, + ], + }; + expect( + enhance({ + ...card, + display: { ...card.display, showMiniNav: true, miniNavData }, + })?.miniNavData, + ).toEqual(miniNavData); + }); +}); diff --git a/dotcom-rendering/src/model/enhanceCards.ts b/dotcom-rendering/src/model/enhanceCards.ts index c28aeb9917a..041d54705d4 100644 --- a/dotcom-rendering/src/model/enhanceCards.ts +++ b/dotcom-rendering/src/model/enhanceCards.ts @@ -673,6 +673,8 @@ export const enhanceCards = ( showByline: faciaCard.properties.showByline, snapData: enhanceSnaps(faciaCard.enriched), boostLevel: faciaCard.display.boostLevel, + showMiniNav: faciaCard.display.showMiniNav ?? false, + miniNavData: faciaCard.display.miniNavData, isImmersive: !!faciaCard.display.isImmersive, isCrossword: faciaCard.properties.isCrossword, isNewsletter, diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index adcf9c256ae..5b60c5bc68e 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -15,6 +15,7 @@ import type { import type { EditionId } from '../lib/edition'; import type { Branding, CollectionBranding } from './branding'; import type { BoostLevel, Newsletter, StarRating } from './content'; +import type { CustomSubnav } from './customSubnav'; import type { FooterType } from './footer'; import type { FENavType } from './frontend'; import type { ArticleMedia, MainMedia } from './mainMedia'; @@ -81,6 +82,8 @@ export type DCRFrontCard = { discussionApiUrl: string; isExternalLink: boolean; showLivePlayable: boolean; + showMiniNav?: boolean; + miniNavData?: CustomSubnav; trailText?: string; starRating?: StarRating; webPublicationDate?: string;