Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import type { PopularHotTakes } from './PopularHotTakesList';
import { PopularHotTakesList } from './PopularHotTakesList';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import loggedUser from '../../../../__tests__/fixture/loggedUser';
import { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled';

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

const shareEnabledMock = useHotTakeShareEnabled as jest.Mock;
const shareLabel = 'Share the hot takes leaderboard';

const items: PopularHotTakes[] = [
{
score: 1,
hotTake: {
id: 'p-1',
title: 'Strict mode everywhere',
subtitle: null,
emoji: '🧨',
},
user: { username: 'spicydev' },
},
];

let client: QueryClient;

beforeEach(() => {
jest.clearAllMocks();
shareEnabledMock.mockReturnValue(false);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});

const renderList = (): RenderResult =>
render(
<TestBootProvider client={client} auth={{ user: loggedUser }}>
<PopularHotTakesList
containerProps={{ title: 'Most popular hot takes' }}
items={items}
isLoading={false}
/>
</TestBootProvider>,
);

describe('PopularHotTakesList', () => {
it('keeps the stock heading markup when the flag is off', () => {
renderList();

const heading = screen.getByRole('heading', { level: 3 });
expect(heading).toHaveClass('mb-2');
// No wrapper row: the heading still sits directly above the list.
expect(heading.nextElementSibling?.tagName).toBe('OL');
expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument();
});

it('renders exactly one header share when the flag is on', () => {
shareEnabledMock.mockReturnValue(true);
renderList();

const heading = screen.getByRole('heading', { level: 3 });
expect(heading).not.toHaveClass('mb-2');
expect(heading.parentElement).toHaveClass('justify-between');
expect(screen.getAllByLabelText(shareLabel)).toHaveLength(1);
});

it('keeps rows deep-linking to the hot-takes section of the owner profile', () => {
shareEnabledMock.mockReturnValue(true);
renderList();

expect(
screen.getByRole('link', { name: /Strict mode everywhere/ }),
).toHaveAttribute('href', '/spicydev#hot-takes');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
TypographyType,
} from '../../typography/Typography';
import { webappUrl } from '../../../lib/constants';
import { getHotTakesProfileUrl } from '../../../features/profile/components/hotTakes/common';
import { HotTakeShareControl } from '../../../features/profile/components/hotTakes/HotTakeShareButton';
import { Origin } from '../../../lib/log';
import { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled';

export type PopularHotTakes = {
score: number;
Expand All @@ -22,13 +26,28 @@ export function PopularHotTakesList({
items,
...props
}: CommonLeaderboardProps<PopularHotTakes[]>): ReactElement {
const isShareEnabled = useHotTakeShareEnabled();
// The custom header must mirror the container's default heading markup so
// flag-off (no header passed) and flag-on only differ by the share control.
const header = isShareEnabled ? (
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="font-bold typo-title3">{props.containerProps.title}</h3>
<HotTakeShareControl
link={`${webappUrl}users`}
text="The most popular hot takes on daily.dev — developers' spiciest opinions, ranked."
label="Share the hot takes leaderboard"
origin={Origin.PopularHotTakes}
/>
</div>
) : undefined;

return (
<LeaderboardList {...props}>
<LeaderboardList {...props} header={header}>
{items?.map(({ hotTake, score, user }) => {
return (
<LeaderboardListItem
key={hotTake.id}
href={`${webappUrl}${user.username}#hot-takes`}
href={getHotTakesProfileUrl(user.username)}
index={score}
className="flex w-full flex-row items-center rounded-8 px-2 py-2 hover:bg-accent-pepper-subtler"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { HotTake } from '../../../graphql/user/userHotTake';
import { useDiscoverHotTakes } from '../../../hooks/useDiscoverHotTakes';
import { useVoteHotTake } from '../../../hooks/vote/useVoteHotTake';
import { useLogContext } from '../../../contexts/LogContext';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useHotTakeShareEnabled } from '../../../hooks/useHotTakeShareEnabled';
import { LogEvent, Origin } from '../../../lib/log';
import HotAndColdModal from './HotAndColdModal';

Expand All @@ -30,10 +32,15 @@ jest.mock('../../../hooks/useRequestProtocol', () => ({
useRequestProtocol: () => ({ isCompanion: false }),
}));

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

const mockedUseDiscoverHotTakes = useDiscoverHotTakes as jest.Mock;
const mockedUseVoteHotTake = useVoteHotTake as jest.Mock;
const mockedUseLogContext = useLogContext as jest.Mock;
const mockedUseAuthContext = useAuthContext as jest.Mock;
const mockedUseHotTakeShareEnabled = useHotTakeShareEnabled as jest.Mock;

const createHotTake = (id = 'take-1'): HotTake => ({
id,
Expand All @@ -46,13 +53,33 @@ const createHotTake = (id = 'take-1'): HotTake => ({
upvoted: false,
});

const createTakeAuthor = (): HotTake['user'] =>
({
id: 'user-2',
name: 'Spicy Dev',
username: 'spicydev',
image: 'https://daily.dev/avatar.png',
createdAt: '2026-01-01T00:00:00.000Z',
reputation: 42,
permalink: '/spicydev',
companies: [],
isPlus: false,
} as HotTake['user']);

const renderComponent = (onRequestClose = jest.fn()) => {
// The flag-on share control renders the real ShareActions, which needs a
// query client for link shortening.
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<HotAndColdModal
isOpen
onRequestClose={onRequestClose}
ariaHideApp={false}
/>,
<QueryClientProvider client={client}>
<HotAndColdModal
isOpen
onRequestClose={onRequestClose}
ariaHideApp={false}
/>
</QueryClientProvider>,
);

return { onRequestClose };
Expand All @@ -78,6 +105,7 @@ describe('HotAndColdModal', () => {
mockedUseAuthContext.mockReturnValue({
user: { username: 'tester' },
});
mockedUseHotTakeShareEnabled.mockReturnValue(false);
mockedUseDiscoverHotTakes.mockReturnValue({
hotTakes: [createHotTake()],
currentTake: createHotTake(),
Expand Down Expand Up @@ -371,4 +399,55 @@ describe('HotAndColdModal', () => {
).toBeVisible();
expect(screen.getAllByText('Starter feed ready')[0]).toBeVisible();
});

describe('card share', () => {
const shareLabel = 'Share "Type safety everywhere"';

it('renders exactly one share control, on the top card only', () => {
mockedUseHotTakeShareEnabled.mockReturnValue(true);
const currentTake = { ...createHotTake('top'), user: createTakeAuthor() };
const nextTake = { ...createHotTake('next'), user: createTakeAuthor() };

mockedUseDiscoverHotTakes.mockReturnValue({
hotTakes: [currentTake, nextTake],
currentTake,
nextTake,
isEmpty: false,
isLoading: false,
dismissCurrent,
});

renderComponent();

expect(screen.getAllByLabelText(shareLabel)).toHaveLength(1);
// The voting affordances are untouched.
expect(
screen.getByRole('button', { name: 'Hot take - upvote' }),
).toBeVisible();
});

it('renders no share control for takes without an author', () => {
mockedUseHotTakeShareEnabled.mockReturnValue(true);

renderComponent();

expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument();
});

it('renders no share control when the flag is off', () => {
const currentTake = { ...createHotTake('top'), user: createTakeAuthor() };
mockedUseDiscoverHotTakes.mockReturnValue({
hotTakes: [currentTake],
currentTake,
nextTake: null,
isEmpty: false,
isLoading: false,
dismissCurrent,
});

renderComponent();

expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument();
});
});
});
29 changes: 27 additions & 2 deletions packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ import { PlusUserBadge } from '../../PlusUserBadge';
import { Loader } from '../../Loader';
import LogoIcon from '../../../svg/LogoIcon';
import type { HotTake } from '../../../graphql/user/userHotTake';
import { getAddHotTakeProfileUrl } from '../../../features/profile/components/hotTakes/common';
import {
getAddHotTakeProfileUrl,
getHotTakeShareText,
getHotTakesProfileUrl,
} from '../../../features/profile/components/hotTakes/common';
import { HotTakeShareButton } from '../../../features/profile/components/hotTakes/HotTakeShareButton';

const SWIPE_THRESHOLD = 80;
const ONBOARDING_INTRO_INTERESTING_OFFSET = 56;
Expand Down Expand Up @@ -843,7 +848,9 @@ const SLEEP_BUBBLES: ReadonlyArray<{
{ left: '88%', bottom: '8%', size: 8, delay: 0.45, duration: 2.5 },
];

const HotTakeCard = ({
// Exported for Storybook: the swipeable card is only reachable behind live
// discover data inside the modal.
export const HotTakeCard = ({
hotTake,
isTop,
offset,
Expand Down Expand Up @@ -1376,6 +1383,24 @@ const HotTakeCard = ({
</div>
</a>
)}

{/* Rendered last + explicit z so it stays clickable above the swipe
effect overlays; only the top card gets a live control. */}
{isTop && hotTake.user?.username && (
<div className="z-20 absolute right-2 top-2">
<HotTakeShareButton
link={getHotTakesProfileUrl(hotTake.user.username)}
text={getHotTakeShareText({
title: hotTake.title,
username: hotTake.user.username,
})}
label={`Share "${hotTake.title}"`}
targetId={hotTake.id}
origin={Origin.HotAndCold}
buttonSize={ButtonSize.Small}
/>
</div>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import type { HotTake } from '../../../../graphql/user/userHotTake';
import { HotTakeItem } from './HotTakeItem';
import { TestBootProvider } from '../../../../../__tests__/helpers/boot';
import loggedUser from '../../../../../__tests__/fixture/loggedUser';
import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2';
import { useHotTakeShareEnabled } from '../../../../hooks/useHotTakeShareEnabled';

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

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

const engagementBarMock = useEngagementBarV2 as jest.Mock;
const shareEnabledMock = useHotTakeShareEnabled as jest.Mock;

const item: HotTake = {
id: 'take-1',
emoji: '🔥',
title: 'Small PRs or bust',
subtitle: 'Review time is a feature',
position: 1,
createdAt: '2026-01-01T00:00:00.000Z',
upvotes: 3,
upvoted: false,
};

const shareLabel = 'Share "Small PRs or bust"';

let client: QueryClient;

beforeEach(() => {
jest.clearAllMocks();
engagementBarMock.mockReturnValue(false);
shareEnabledMock.mockReturnValue(true);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});

const renderItem = (
props: Partial<Parameters<typeof HotTakeItem>[0]> = {},
): RenderResult =>
render(
<TestBootProvider client={client} auth={{ user: loggedUser }}>
<HotTakeItem
item={item}
isOwner={false}
ownerUsername="spicydev"
onUpvoteClick={jest.fn()}
{...props}
/>
</TestBootProvider>,
);

describe.each([
['V1', false],
['V2', true],
])('HotTakeItem %s share control', (_, isV2) => {
beforeEach(() => engagementBarMock.mockReturnValue(isV2));

it('renders exactly one share control next to the existing actions', () => {
renderItem();

expect(screen.getAllByLabelText(shareLabel)).toHaveLength(1);
// The upvote affordance is untouched.
expect(screen.getByLabelText(/upvote/i)).toBeInTheDocument();
});

it('renders no share control without an owner username', () => {
renderItem({ ownerUsername: undefined });

expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument();
});

it('is byte-identical to the pre-share markup when the flag is off', () => {
shareEnabledMock.mockReturnValue(false);
const { container: flagOff } = renderItem();
// Rendering without the new prop is the exact markup this PR branched off.
const { container: baseline } = renderItem({ ownerUsername: undefined });

expect(screen.queryAllByLabelText(shareLabel)).toHaveLength(0);
expect(flagOff.innerHTML).toBe(baseline.innerHTML);
});
});
Loading
Loading