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
82 changes: 75 additions & 7 deletions packages/shared/src/components/brief/BriefListItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BriefListItem } from './BriefListItem';
import type { Post } from '../../graphql/posts';
import { LogEvent, Origin, TargetId } from '../../lib/log';

const mockOnPostClick = jest.fn();
const mockLogEvent = jest.fn();
const mockCopyLink = jest.fn();
const mockOpenSharePost = jest.fn();

let mockWithShareControls = false;

jest.mock('../../hooks/useOnPostClick', () => ({
__esModule: true,
Expand All @@ -20,6 +25,17 @@ jest.mock('../../hooks/usePlusSubscription', () => ({
usePlusSubscription: () => ({ isPlus: true }),
}));

jest.mock('../../hooks/useSharePost', () => ({
useSharePost: () => ({
copyLink: mockCopyLink,
openSharePost: mockOpenSharePost,
}),
}));

jest.mock('../../features/snapshot/useSharePlacement', () => ({
useSharePlacement: () => mockWithShareControls,
}));

const post = {
id: 'brief-1',
slug: 'brief-1',
Expand All @@ -30,18 +46,21 @@ const post = {

const renderComponent = (onClick = jest.fn()) =>
render(
<BriefListItem
post={post}
title={post.title}
onClick={onClick}
origin={Origin.BriefPage}
targetId={TargetId.List}
/>,
<QueryClientProvider client={new QueryClient()}>
<BriefListItem
post={post}
title={post.title}
onClick={onClick}
origin={Origin.BriefPage}
targetId={TargetId.List}
/>
</QueryClientProvider>,
);

describe('BriefListItem', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWithShareControls = false;
});

it('delegates regular clicks to the parent handler and tracks the click', () => {
Expand Down Expand Up @@ -87,4 +106,53 @@ describe('BriefListItem', () => {
expect(mockOnPostClick).toHaveBeenCalledWith({ post });
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});

it('renders no share controls while the placement is off', () => {
renderComponent();

expect(
screen.queryByRole('button', { name: 'Copy link' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Share briefing' }),
).not.toBeInTheDocument();
});

it('keeps the share controls inside the card', () => {
mockWithShareControls = true;
renderComponent();

const button = screen.getByRole('button', { name: 'Copy link' });
const article = button.closest('article');
const column = article?.querySelector('div.flex.flex-col');

// `w-full` on the text column pushes the control past the card border.
expect(column).not.toHaveClass('w-full');
expect(column).toHaveClass('min-w-0', 'flex-1');
expect(article).toContainElement(button);
});

it('copies the brief link without opening the brief', () => {
mockWithShareControls = true;
const onClick = jest.fn();
renderComponent(onClick);

fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));

expect(mockCopyLink).toHaveBeenCalledWith({ post });
expect(onClick).not.toHaveBeenCalled();
expect(mockOnPostClick).not.toHaveBeenCalled();
});

it('opens the share surface without opening the brief', () => {
mockWithShareControls = true;
const onClick = jest.fn();
renderComponent(onClick);

fireEvent.click(screen.getByRole('button', { name: 'Share briefing' }));

expect(mockOpenSharePost).toHaveBeenCalledWith({ post });
expect(mockCopyLink).not.toHaveBeenCalled();
expect(onClick).not.toHaveBeenCalled();
});
});
48 changes: 45 additions & 3 deletions packages/shared/src/components/brief/BriefListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
import type { PillProps } from '../Pill';
import { Pill } from '../Pill';
import { IconSize } from '../Icon';
import { BriefGradientIcon, LockIcon } from '../icons';
import { BriefGradientIcon, LinkIcon, LockIcon, ShareIcon } from '../icons';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { Tooltip } from '../tooltip/Tooltip';
import type { Origin, TargetId } from '../../lib/log';
import { LogEvent } from '../../lib/log';
import useOnPostClick from '../../hooks/useOnPostClick';
Expand All @@ -22,6 +24,10 @@ import { anchorDefaultRel } from '../../lib/strings';
import Link from '../utilities/Link';
import { useLogContext } from '../../contexts/LogContext';
import { usePlusSubscription } from '../../hooks/usePlusSubscription';
import { useSharePost } from '../../hooks/useSharePost';
import { CopyStateIcon } from '../share/CopyStateIcon';
import { featureBriefingShareControls } from '../../lib/featureManagement';
import { useSharePlacement } from '../../features/snapshot/useSharePlacement';

export type BriefListItemProps = {
className?: string;
Expand Down Expand Up @@ -55,6 +61,10 @@ export const BriefListItem = ({
const { isPlus } = usePlusSubscription();
const { logEvent } = useLogContext();
const onPostClick = useOnPostClick({ origin });
const { copyLink, isCopying, openSharePost } = useSharePost(origin);
const withShareControls = useSharePlacement({
feature: featureBriefingShareControls,
});

const trackBriefClick = () => {
onPostClick({ post });
Expand Down Expand Up @@ -86,14 +96,22 @@ export const BriefListItem = ({
<div className="hidden items-center mobileXL:flex">
<BriefGradientIcon secondary={!isRead} size={IconSize.Size48} />
</div>
<div className="flex w-full flex-col gap-1">
<div className="flex items-center gap-2">
<div
className={classNames(
'flex flex-col gap-1',
// `w-full` would claim the whole card and push the controls past
// its border.
withShareControls ? 'min-w-0 flex-1' : 'w-full',
)}
>
<div className="flex min-w-0 items-center gap-2">
<Typography
type={TypographyType.Title3}
bold
color={
isRead ? TypographyColor.Quaternary : TypographyColor.Primary
}
truncate={withShareControls}
>
{title}
</Typography>
Expand Down Expand Up @@ -150,6 +168,30 @@ export const BriefListItem = ({
onAuxClick={(event) => event.button === 1 && trackBriefClick()}
/>
</Link>
{withShareControls && (
// After the CardLink and above it: the overlay covers the whole row,
// so anything rendered before it never receives the click.
<div className="relative z-1 flex shrink-0 items-center gap-1">
<Tooltip content={isCopying ? 'Copied!' : 'Copy link'}>
<Button
aria-label="Copy link"
icon={<CopyStateIcon copied={isCopying} icon={LinkIcon} />}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
onClick={() => copyLink({ post })}
/>
</Tooltip>
<Tooltip content="Share">
<Button
aria-label="Share briefing"
icon={<ShareIcon />}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
onClick={() => openSharePost({ post })}
/>
</Tooltip>
</div>
)}
</article>
);
};
30 changes: 27 additions & 3 deletions packages/shared/src/components/post/brief/BriefPostContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ReactElement } from 'react';
import React, { useMemo, useEffect, useState } from 'react';
import React, { useMemo, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import {
Expand Down Expand Up @@ -46,7 +46,10 @@ import {
ButtonVariant,
} from '../../buttons/Button';
import { LogEvent, TargetId } from '../../../lib/log';
import { featurePlusCtaCopy } from '../../../lib/featureManagement';
import {
featureBriefingShareControls,
featurePlusCtaCopy,
} from '../../../lib/featureManagement';
import { LottieAnimation } from '../../LottieAnimation';
import { briefFeatureList, PlusList } from '../../plus/PlusList';
import { HourDropdown } from '../../fields/HourDropdown';
Expand All @@ -65,6 +68,10 @@ import { getFirstName } from '../../../lib/user';
import Link from '../../utilities/Link';
import { ActionType } from '../../../graphql/actions';
import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert';
import { BriefShareBand } from '../../../features/briefing/components/BriefShareBand';
import { BriefBodyShareControls } from '../../../features/briefing/components/BriefBodyShareControls';
import { SelectionSnapshotBar } from '../../../features/snapshot/SelectionSnapshotBar';
import { useSharePlacement } from '../../../features/snapshot/useSharePlacement';
import type { BriefPostHeaderProps } from '../../../features/briefing/components/BriefPostHeader';
import { BriefPostHeader } from '../../../features/briefing/components/BriefPostHeader';
import type { NotificationChannel } from '../../../hooks/notifications/useNotificationSettings';
Expand Down Expand Up @@ -135,6 +142,12 @@ const BriefPostContentRaw = ({
unsubscribePersonalizedDigest,
} = usePersonalizedDigest();
const [digestTimeIndex, setDigestTimeIndex] = useState<number | undefined>(8);
const briefBodyRef = useRef<HTMLDivElement>(null);
// The post page's highlight bar, on the briefing's own flag: one switch
// turns every control on this surface on or off together.
const isSelectionShareEnabled = useSharePlacement({
feature: featureBriefingShareControls,
});

const briefDigest = getPersonalizedDigest(UserPersonalizedDigestType.Brief);

Expand Down Expand Up @@ -390,7 +403,18 @@ const BriefPostContentRaw = ({
</Typography>
</div>
</div>
<Markdown content={contentHtml} />
<div ref={briefBodyRef}>
<Markdown content={contentHtml} />
</div>
{isSelectionShareEnabled && (
<SelectionSnapshotBar containerRef={briefBodyRef} post={post} />
)}
<BriefBodyShareControls
bodyRef={briefBodyRef}
contentHtml={contentHtml}
post={post}
/>
<BriefShareBand origin={origin} post={post} />
{isNotPlus && (
<div className="flex w-full rounded-12 border border-white bg-transparent">
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen } from '@testing-library/react';
import { BriefPostHeaderActions } from './BriefPostHeaderActions';
import type { Post } from '../../../graphql/posts';
import { Origin } from '../../../lib/log';

const mockCopyLink = jest.fn();
const mockOpenSharePost = jest.fn();

let mockAtEveryWidth = false;

jest.mock('../../../hooks/useSharePost', () => ({
useSharePost: () => ({
copyLink: mockCopyLink,
openSharePost: mockOpenSharePost,
}),
}));

jest.mock('../../../features/snapshot/useSharePlacement', () => ({
useSharePlacement: () => mockAtEveryWidth,
}));

const post = { id: 'brief-1', slug: 'brief-1' } as Post;

const renderComponent = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<BriefPostHeaderActions
showShareButton
contextMenuId="post-widgets-context"
origin={Origin.BriefPage}
post={post}
/>
</QueryClientProvider>,
);

describe('BriefPostHeaderActions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAtEveryWidth = false;
});

it('keeps the cluster desktop-only while the placement is off', () => {
renderComponent();

expect(
screen.getByRole('button', { name: 'Copy link' }).closest('div'),
).toHaveClass('hidden', 'laptop:flex');
expect(
screen.queryByRole('button', { name: 'Share briefing' }),
).not.toBeInTheDocument();
});

it('shows copy link and share at every width when the placement is on', () => {
mockAtEveryWidth = true;
renderComponent();

const cluster = screen
.getByRole('button', { name: 'Copy link' })
.closest('div');

expect(cluster).not.toHaveClass('hidden');
expect(
screen.getByRole('button', { name: 'Share briefing' }),
).toBeInTheDocument();
});

it('draws every control in the cluster at the same weight', () => {
mockAtEveryWidth = true;
renderComponent();

const controls = [
screen.getByRole('button', { name: 'Copy link' }),
screen.getByRole('button', { name: 'Share briefing' }),
screen.getByRole('link'),
];

controls.forEach((control) => expect(control).toHaveClass('btn-tertiary'));
});

it('copies the brief link and opens the share modal', () => {
mockAtEveryWidth = true;
renderComponent();

fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
fireEvent.click(screen.getByRole('button', { name: 'Share briefing' }));

expect(mockCopyLink).toHaveBeenCalledWith({ post });
expect(mockOpenSharePost).toHaveBeenCalledWith({ post });
});
});
Loading
Loading