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
147 changes: 147 additions & 0 deletions packages/shared/src/components/feeds/hero/FeedHero.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React from 'react';
import nock from 'nock';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import basePost from '../../../../__tests__/fixture/post';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import { mockGraphQL } from '../../../../__tests__/helpers/graphql';
import {
FEED_HERO_QUERY,
supportedTypesForPrivateSources,
} from '../../../graphql/feed';
import { FeedHero } from './FeedHero';
import { feedHeroShape } from './feedHeroShape';
import { useFeedHeroAd } from './useFeedHeroAd';

jest.mock('next/router', () => ({
useRouter: jest.fn(),
}));

jest.mock('./useFeedHeroAd', () => ({
useFeedHeroAd: jest.fn(),
}));

const posts = ['First hero post', 'Second hero post'].map((title, index) => ({
...basePost,
id: `hero-post-${index}`,
title,
}));

const highlights = ['First headline', 'Second headline', 'Third headline'].map(
(headline, index) => ({
id: `hero-highlight-${index}`,
channel: 'agents',
headline,
highlightedAt: '2026-04-05T09:00:00.000Z',
post: {
id: `hero-post-${index}`,
commentsPermalink: `/posts/hero-post-${index}`,
},
}),
);

beforeEach(() => {
jest.clearAllMocks();
nock.cleanAll();
jest
.mocked(useRouter)
.mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter));
jest.mocked(useFeedHeroAd).mockReturnValue({
ad: undefined,
placement: 'none',
shape: feedHeroShape(3),
});
});

const mockHero = (data: {
posts: typeof posts;
highlights: typeof highlights;
}) =>
mockGraphQL({
request: {
query: FEED_HERO_QUERY,
variables: {
loggedIn: false,
supportedTypes: supportedTypesForPrivateSources,
},
},
result: { data: { feedHero: data } },
});

const renderComponent = () =>
render(
<TestBootProvider client={new QueryClient()}>
<FeedHero feedName="popular" />
</TestBootProvider>,
);

describe('FeedHero', () => {
it('should render its cards and its rail from the one query', async () => {
mockHero({ posts, highlights });

renderComponent();

await waitFor(() =>
expect(
screen.getByRole('heading', { name: 'First hero post' }),
).toBeInTheDocument(),
);
expect(screen.getByText('Second headline')).toBeInTheDocument();
expect(screen.getByText('Third headline')).toBeInTheDocument();
});

it('should drop only the lead story from a stacked rail', async () => {
jest.mocked(useFeedHeroAd).mockReturnValue({
ad: undefined,
placement: 'none',
// Stacked: the lead story is already a card above the list.
shape: feedHeroShape(1),
});
mockHero({ posts, highlights });

renderComponent();

await waitFor(() =>
expect(
screen.getByRole('heading', { name: 'First hero post' }),
).toBeInTheDocument(),
);
// Matched on the post behind it, not on its position in the list.
expect(screen.queryByText('First headline')).not.toBeInTheDocument();
expect(screen.getByText('Second headline')).toBeInTheDocument();
expect(screen.getByText('Third headline')).toBeInTheDocument();
});

it('should keep a rail headline whose post is not the lead card', async () => {
jest.mocked(useFeedHeroAd).mockReturnValue({
ad: undefined,
placement: 'none',
shape: feedHeroShape(1),
});
// The cards and the headlines are separate lists, so the lead card need
// not be the first headline.
mockHero({ posts: [posts[1]], highlights });

renderComponent();

await waitFor(() =>
expect(
screen.getByRole('heading', { name: 'Second hero post' }),
).toBeInTheDocument(),
);
expect(screen.getByText('First headline')).toBeInTheDocument();
expect(screen.queryByText('Second headline')).not.toBeInTheDocument();
expect(screen.getByText('Third headline')).toBeInTheDocument();
});

it('should render nothing when the query returns no posts', async () => {
mockHero({ posts: [], highlights: [] });

const { container } = renderComponent();

await waitFor(() => expect(nock.isDone()).toBe(true));
expect(container).toBeEmptyDOMElement();
});
});
61 changes: 22 additions & 39 deletions packages/shared/src/components/feeds/hero/FeedHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import type { ReactElement } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Post } from '../../../graphql/posts';
import type { Connection } from '../../../graphql/common';
import { gqlClient } from '../../../graphql/common';
import type { FeedHeroData } from '../../../graphql/feed';
import {
FEED_BY_IDS_QUERY,
FEED_HERO_QUERY,
supportedTypesForPrivateSources,
} from '../../../graphql/feed';
import type { PostHighlight } from '../../../graphql/highlights';
import { majorHeadlinesQueryOptions } from '../../../graphql/highlights';
import type { ViewabilityData } from '../../../features/monetization/viewability';
import { viewabilityLogExtra } from '../../../features/monetization/viewability';
import { useAuthContext } from '../../../contexts/AuthContext';
Expand All @@ -29,15 +28,14 @@ import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
import { FeedHeroSection } from './FeedHeroSection';
import { useFeedHeroAd } from './useFeedHeroAd';

const HIGHLIGHT_COUNT = 6;
const FEATURED_POST_COUNT = 4;
// Distinct from `Origin.Feed` so the experiment can tell the hero's clicks and
// impressions apart from the grid's. Matches the ad events' own origin.
const HERO_ORIGIN = 'feed hero';

/**
* The carousel and the Happening Now list are the same headlines: the top few
* get their full post fetched for a card, the rest stay as rows.
* The carousel and the Happening Now list are two lists, not one: `feedHero`
* grades the cards across editorial highlights and lifecycle states, while the
* rows beside them stay major headlines. A post can be in both, or in one.
*/
export const FeedHero = ({
feedName,
Expand Down Expand Up @@ -66,51 +64,36 @@ export const FeedHero = ({

const { ad, placement, shape } = useFeedHeroAd();

const { data: headlines } = useQuery({
...majorHeadlinesQueryOptions({ first: HIGHLIGHT_COUNT }),
enabled: tokenRefreshed,
});
const highlights = useMemo(
() => headlines?.majorHeadlines?.edges?.map(({ node }) => node) ?? [],
[headlines],
);

const postIds = useMemo(
() => highlights.slice(0, FEATURED_POST_COUNT).map(({ post }) => post.id),
[highlights],
);

const { data: featured } = useQuery({
queryKey: generateQueryKey(RequestKey.FeedByIds, user, 'hero', ...postIds),
const { data: hero } = useQuery({
queryKey: generateQueryKey(RequestKey.FeedHero, user),
queryFn: () =>
gqlClient.request<{ page: Connection<Post> }>(FEED_BY_IDS_QUERY, {
first: postIds.length,
postIds,
gqlClient.request<FeedHeroData>(FEED_HERO_QUERY, {
loggedIn: !!user,
supportedTypes: supportedTypesForPrivateSources,
}),
enabled: tokenRefreshed && postIds.length > 0,
enabled: tokenRefreshed,
staleTime: StaleTime.Default,
});

// `feedByIds` answers in its own order, so re-key by id to keep the carousel
// in the same order as the headlines beside it.
const posts = useMemo(() => {
const byId = new Map(
featured?.page?.edges?.map(({ node }) => [node.id, node]) ?? [],
);

return postIds.map((id) => byId.get(id)).filter(Boolean) as Post[];
}, [featured, postIds]);
const highlights = useMemo(() => hero?.feedHero?.highlights ?? [], [hero]);
const posts: Post[] = useMemo(() => hero?.feedHero?.posts ?? [], [hero]);

const isRendered = posts.length > 0;
const adPlacement = isRendered ? placement : 'none';
const isAdShown = adPlacement !== 'none';

// Stacked, the lead story is already a card above the list, so drop it from
// the list rather than showing it twice a few pixels apart.
const railHighlights =
shape.layout === 'stacked' ? highlights.slice(1) : highlights;
// the list rather than showing it twice a few pixels apart. Matched on the
// post, not the position: the cards and the headlines are separate lists.
const railHighlights = useMemo(() => {
const leadPostId = posts[0]?.id;

if (shape.layout !== 'stacked' || !leadPostId) {
return highlights;
}

return highlights.filter(({ post }) => post.id !== leadPostId);
}, [highlights, posts, shape.layout]);

const onAdAction = useCallback(
(action: AdActions, extra?: Record<string, unknown>) => {
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/src/graphql/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,31 @@ export const FEED_V2_QUERY = gql`
${POST_HIGHLIGHT_FRAGMENT}
`;

export interface FeedHeroData {
feedHero: {
posts: Post[];
highlights: PostHighlight[];
};
}

export const FEED_HERO_QUERY = gql`
query FeedHero($loggedIn: Boolean! = false, ${SUPPORTED_TYPES}) {
feedHero(supportedTypes: $supportedTypes) {
posts {
...FeedPost
contentHtml
...UserPost @include(if: $loggedIn)
}
highlights {
...PostHighlightCard
}
}
}
${FEED_POST_FRAGMENT}
${USER_POST_FRAGMENT}
${POST_HIGHLIGHT_FRAGMENT}
`;

export const MOST_UPVOTED_FEED_QUERY = gql`
query MostUpvotedFeed(
$loggedIn: Boolean! = false
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/lib/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export enum RequestKey {
FeedSettings = 'feedSettings',
Ads = 'ads',
FeedByIds = 'feedByIds',
FeedHero = 'feedHero',
SlackChannels = 'slack_channels',
IntegrationRecentChannels = 'integration_recent_channels',
UserIntegrations = 'user_integrations',
Expand Down
Loading