From cca684fbd7fd18690318b9bc6fccced46da42527 Mon Sep 17 00:00:00 2001 From: Frederick O'Brien Date: Tue, 30 Jun 2026 12:56:56 +0100 Subject: [PATCH] Move standard article grid into its own component --- .../src/layouts/StandardLayout.tsx | 496 +----------------- .../src/layouts/StandardLayoutArticleGrid.tsx | 463 ++++++++++++++++ 2 files changed, 472 insertions(+), 487 deletions(-) create mode 100644 dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx diff --git a/dotcom-rendering/src/layouts/StandardLayout.tsx b/dotcom-rendering/src/layouts/StandardLayout.tsx index 8d24f6d3b8f..e20954cca52 100644 --- a/dotcom-rendering/src/layouts/StandardLayout.tsx +++ b/dotcom-rendering/src/layouts/StandardLayout.tsx @@ -1,51 +1,24 @@ -import { css } from '@emotion/react'; -import { log } from '@guardian/libs'; -import { - from, - palette as sourcePalette, - space, - until, -} from '@guardian/source/foundations'; -import { Hide } from '@guardian/source/react-components'; -import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { palette as sourcePalette } from '@guardian/source/foundations'; import { AdPortals } from '../components/AdPortals.island'; import { AdSlot, MobileStickyContainer } from '../components/AdSlot.web'; -import { AffiliateDisclaimer } from '../components/AffiliateDisclaimer'; -import { AppsEpic } from '../components/AppsEpic.island'; import { AppsFooter } from '../components/AppsFooter.island'; -import { ArticleBody } from '../components/ArticleBody'; -import { ArticleContainer } from '../components/ArticleContainer'; -import { ArticleHeadline } from '../components/ArticleHeadline'; -import { ArticleMetaApps } from '../components/ArticleMeta.apps'; -import { ArticleMeta } from '../components/ArticleMeta.web'; -import { ArticleTitle } from '../components/ArticleTitle'; import { Carousel } from '../components/Carousel.island'; import { CricketMatchHeaderWrapper } from '../components/CricketMatchHeaderWrapper.island'; -import { DecideLines } from '../components/DecideLines'; import { DirectoryPageNavIsland } from '../components/DirectoryPageNavIsland'; import { DiscussionLayout } from '../components/DiscussionLayout'; import { FootballMatchHeaderWrapper } from '../components/FootballMatchHeaderWrapper.island'; -import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island'; import { Footer } from '../components/Footer'; -import { GuardianLabsLines } from '../components/GuardianLabsLines'; import { HeaderAdSlot } from '../components/HeaderAdSlot'; import { Island } from '../components/Island'; import { LabsHeader } from '../components/LabsHeader'; -import { ListenToArticle } from '../components/ListenToArticle.island'; -import { MainMedia } from '../components/MainMedia'; import { Masthead } from '../components/Masthead/Masthead'; import { MatchHeaderFallback } from '../components/MatchHeaderFallback'; import { MostViewedFooterData } from '../components/MostViewedFooterData.island'; import { MostViewedFooterLayout } from '../components/MostViewedFooterLayout'; -import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; import { OnwardsUpper } from '../components/OnwardsUpper.island'; import { Section } from '../components/Section'; -import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; -import { Standfirst } from '../components/Standfirst'; import { StickyBottomBanner } from '../components/StickyBottomBanner.island'; -import { SubMeta } from '../components/SubMeta'; import { SubNav } from '../components/SubNav.island'; -import { grid } from '../grid'; import { ArticleDesign, ArticleDisplay, @@ -55,55 +28,14 @@ import { import { canRenderAds } from '../lib/canRenderAds'; import { getContributionsServiceUrl } from '../lib/contributions'; import { decideStoryPackageTrails } from '../lib/decideTrail'; -import { safeParseURL } from '../lib/parse'; -import { parse } from '../lib/slot-machine-flags'; import { useAB } from '../lib/useAB'; import { worldCupTagId } from '../lib/worldCup2026'; import type { NavType } from '../model/extract-nav'; import { palette as themePalette } from '../palette'; import type { ArticleDeprecated } from '../types/article'; import type { RenderingTarget } from '../types/renderingTarget'; -import { - type Area, - gridItemCss, - type LayoutType, -} from './lib/articleArrangements'; import { BannerWrapper, Stuck } from './lib/stickiness'; - -const stretchLines = css` - ${until.phablet} { - margin-left: -20px; - margin-right: -20px; - } - ${until.mobileLandscape} { - margin-left: -10px; - margin-right: -10px; - } -`; - -interface GridItemProps { - area: Area; - layoutType: LayoutType; - element?: 'div' | 'aside'; - className?: string; - children: React.ReactNode; -} - -const GridItem = ({ - area, - layoutType, - element: Element = 'div', - className, - children, -}: GridItemProps) => ( - - {children} - -); +import { StandardLayoutArticleGrid } from './StandardLayoutArticleGrid'; interface Props { article: ArticleDeprecated; @@ -112,12 +44,12 @@ interface Props { serverTime?: number; } -interface WebProps extends Props { +export interface WebProps extends Props { NAV: NavType; renderingTarget: 'Web'; } -interface AppProps extends Props { +export interface AppProps extends Props { renderingTarget: 'Apps'; } @@ -131,11 +63,6 @@ export const StandardLayout = (props: WebProps | AppProps) => { const isWeb = renderingTarget === 'Web'; const isApps = renderingTarget === 'Apps'; - const showBodyEndSlot = - isWeb && - (parse(article.slotMachineFlags ?? '').showBodyEnd || - article.config.switches.slotBodyEnd); - // TODO: // 1) Read 'forceEpic' value from URL parameter and use it to force the slot to render // 2) Otherwise, ensure slot only renders if `article.config.shouldHideReaderRevenue` equals false. @@ -145,11 +72,6 @@ export const StandardLayout = (props: WebProps | AppProps) => { ? article.matchUrl : undefined; - const footballMatchStatsUrl = - article.matchType === 'FootballMatchType' - ? article.matchStatsUrl - : undefined; - const isFootballMatchReport = format.design === ArticleDesign.MatchReport && !!footballMatchUrl; @@ -161,18 +83,8 @@ export const StandardLayout = (props: WebProps | AppProps) => { const isCricketMatchReport = format.design === ArticleDesign.MatchReport && !!cricketMatchUrl; - const isMedia = - format.design === ArticleDesign.Video || - format.design === ArticleDesign.Audio; - - const isVideo = format.design === ArticleDesign.Video; - - const isShowcase = format.display === ArticleDisplay.Showcase; - const showComments = article.isCommentable && !isPaidContent; - const { branding } = article.commercialProperties[article.editionId]; - const contributionsServiceUrl = getContributionsServiceUrl(article); const isLabs = format.theme === ArticleSpecial.Labs; @@ -181,12 +93,6 @@ export const StandardLayout = (props: WebProps | AppProps) => { const renderAds = canRenderAds(article); - const layoutType: LayoutType = isMedia - ? 'media' - : isShowcase - ? 'showcase' - : 'standard'; - return ( <> {isWeb && ( @@ -261,366 +167,13 @@ export const StandardLayout = (props: WebProps | AppProps) => { )} - {/* GridItem order matters — mobile layout relies on DOM order for grid placement. - See furnitureArrangements.ts if reordering. */} {/* This element is used to replace the article with the scorecard when the scorecard tab is clicked */}
-
- - - - - - - - - - - - - -
- {isWeb && - format.theme === ArticleSpecial.Labs && - format.design !== ArticleDesign.Video ? ( - - ) : ( - - )} -
- {isApps ? ( - <> - - - - - - {!!article.affiliateLinksDisclaimer && ( - - )} - - - ) : ( - <> - - {!!article.affiliateLinksDisclaimer && ( - - )} - - )} -
- - {/* Only show Listen to Article button on App landscape views */} - {isApps && ( - - {!isVideo && ( -
- - - -
- )} -
- )} - - - - - {isApps && ( - - - - )} - - {showBodyEndSlot && ( - - - - )} - - - -
- - - - - - - -
+
{isWeb && renderAds && !isLabs && (
{ - if (isMatchReport && !!footballMatchStatsUrl) { - const parsedUrl = safeParseURL(footballMatchStatsUrl); - if (!parsedUrl.ok) { - log( - 'dotcom', - new Error( - `Failed to parse match stats URL: ${footballMatchStatsUrl}`, - ), - ); - - return null; - } - return ( - - - - ); - } - - return null; -}; diff --git a/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx new file mode 100644 index 00000000000..53c6d6d0371 --- /dev/null +++ b/dotcom-rendering/src/layouts/StandardLayoutArticleGrid.tsx @@ -0,0 +1,463 @@ +import { css } from '@emotion/react'; +import { log } from '@guardian/libs'; +import { from, space, until } from '@guardian/source/foundations'; +import { Hide } from '@guardian/source/react-components'; +import { StraightLines } from '@guardian/source-development-kitchen/react-components'; +import { AffiliateDisclaimer } from '../components/AffiliateDisclaimer'; +import { AppsEpic } from '../components/AppsEpic.island'; +import { ArticleBody } from '../components/ArticleBody'; +import { ArticleContainer } from '../components/ArticleContainer'; +import { ArticleHeadline } from '../components/ArticleHeadline'; +import { ArticleMetaApps } from '../components/ArticleMeta.apps'; +import { ArticleMeta } from '../components/ArticleMeta.web'; +import { ArticleTitle } from '../components/ArticleTitle'; +import { DecideLines } from '../components/DecideLines'; +import { FootballMatchInfoWrapper } from '../components/FootballMatchInfoWrapper.island'; +import { GuardianLabsLines } from '../components/GuardianLabsLines'; +import { Island } from '../components/Island'; +import { ListenToArticle } from '../components/ListenToArticle.island'; +import { MainMedia } from '../components/MainMedia'; +import { MostViewedRightWithAd } from '../components/MostViewedRightWithAd.island'; +import { SlotBodyEnd } from '../components/SlotBodyEnd.island'; +import { Standfirst } from '../components/Standfirst'; +import { SubMeta } from '../components/SubMeta'; +import { grid } from '../grid'; +import { + ArticleDesign, + ArticleDisplay, + type ArticleFormat, + ArticleSpecial, +} from '../lib/articleFormat'; +import { getContributionsServiceUrl } from '../lib/contributions'; +import { safeParseURL } from '../lib/parse'; +import { parse } from '../lib/slot-machine-flags'; +import { palette as themePalette } from '../palette'; +import type { ArticleDeprecated } from '../types/article'; +import type { RenderingTarget } from '../types/renderingTarget'; +import { + type Area, + gridItemCss, + type LayoutType, +} from './lib/articleArrangements'; + +const stretchLines = css` + ${until.phablet} { + margin-left: -20px; + margin-right: -20px; + } + ${until.mobileLandscape} { + margin-left: -10px; + margin-right: -10px; + } +`; + +interface GridItemProps { + area: Area; + layoutType: LayoutType; + element?: 'div' | 'aside'; + className?: string; + children: React.ReactNode; +} + +const GridItem = ({ + area, + layoutType, + element: Element = 'div', + className, + children, +}: GridItemProps) => ( + + {children} + +); + +export const StandardLayoutArticleGrid = ({ + article, + format, + renderingTarget, +}: { + article: ArticleDeprecated; + format: ArticleFormat; + renderingTarget: RenderingTarget; +}) => { + const { + config: { host }, + } = article; + const isWeb = renderingTarget === 'Web'; + const isApps = renderingTarget === 'Apps'; + const renderAds = isWeb && !article.shouldHideAds; + + const contributionsServiceUrl = getContributionsServiceUrl(article); + + const showBodyEndSlot = + isWeb && + (parse(article.slotMachineFlags ?? '').showBodyEnd || + article.config.switches.slotBodyEnd); + + const { branding } = article.commercialProperties[article.editionId]; + + const footballMatchStatsUrl = + article.matchType === 'FootballMatchType' + ? article.matchStatsUrl + : undefined; + + const isLabs = format.theme === ArticleSpecial.Labs; + const isMedia = + format.design === ArticleDesign.Video || + format.design === ArticleDesign.Audio; + const isShowcase = format.display === ArticleDisplay.Showcase; + + const isVideo = format.design === ArticleDesign.Video; + + const footballMatchUrl = + article.matchType === 'FootballMatchType' + ? article.matchUrl + : undefined; + + const isFootballMatchReport = + format.design === ArticleDesign.MatchReport && !!footballMatchUrl; + + const layoutType: LayoutType = isMedia + ? 'media' + : isShowcase + ? 'showcase' + : 'standard'; + + return ( +
+ {/* GridItem order matters — mobile layout relies on DOM order for grid placement. + See furnitureArrangements.ts if reordering. */} + + + + + + + + + + + + + +
+ {isWeb && + format.theme === ArticleSpecial.Labs && + format.design !== ArticleDesign.Video ? ( + + ) : ( + + )} +
+ {isApps ? ( + <> + + + + + + {!!article.affiliateLinksDisclaimer && ( + + )} + + + ) : ( + <> + + {!!article.affiliateLinksDisclaimer && ( + + )} + + )} +
+ + {/* Only show Listen to Article button on App landscape views */} + {isApps && ( + + {!isVideo && ( +
+ + + +
+ )} +
+ )} + + + + + {isApps && ( + + + + )} + + {showBodyEndSlot && ( + + + + )} + + + +
+ + + + + + + +
+ ); +}; + +const MatchInfoContainer = ({ + isMatchReport, + footballMatchStatsUrl, +}: { + isMatchReport: boolean; + footballMatchStatsUrl: string | undefined; +}) => { + if (isMatchReport && !!footballMatchStatsUrl) { + const parsedUrl = safeParseURL(footballMatchStatsUrl); + if (!parsedUrl.ok) { + log( + 'dotcom', + new Error( + `Failed to parse match stats URL: ${footballMatchStatsUrl}`, + ), + ); + + return null; + } + return ( + + + + ); + } + + return null; +};