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
12 changes: 12 additions & 0 deletions packages/shared/src/components/MainFeedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent';
import { useLayoutVariant } from '../hooks/layout/useLayoutVariant';
import { ExploreSectionTabs } from './header/ExploreSectionTabs';
import { ExploreSortDropdown } from './header/ExploreSortDropdown';
import { ExploreFeedShareButton } from './header/ExploreFeedShareButton';
import { ButtonSize, ButtonVariant } from './buttons/Button';

const FeedExploreHeader = dynamic(
() =>
Expand Down Expand Up @@ -764,6 +766,16 @@ export default function MainFeedLayout({
{showExploreV2PageHeader && (
<header className={classNames(pageHeaderClassName, '!py-0')}>
<ExploreSectionTabs />
{/* The share affordance joins the sort controls' right-side cluster;
the header's own gap-2 spaces it, and it self-gates on the
share_discovery flag so flag-off DOM is unchanged. */}
{isAnyExplore && (
<ExploreFeedShareButton
sharePath={tabToUrl[urlToTab[router.pathname] ?? tab]}
buttonVariant={ButtonVariant.Float}
buttonSize={ButtonSize.Small}
/>
)}
{isAnyExplore && <ExploreSortDropdown />}
</header>
)}
Expand Down
88 changes: 88 additions & 0 deletions packages/shared/src/components/archive/ArchiveFeedPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { ArchiveFeedPage } from './ArchiveFeedPage';
import { ArchivePeriodType, ArchiveScopeType } from '../../graphql/archive';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { LogEvent, Origin } from '../../lib/log';
import { ShareProvider } from '../../lib/share';

const logEvent = jest.fn();
const writeText = jest.fn().mockResolvedValue(undefined);

const gbWithFeatures = (features: Record<string, boolean>): GrowthBook =>
new GrowthBook({
features: Object.fromEntries(
Object.entries(features).map(([id, value]) => [
id,
{ defaultValue: value },
]),
),
});

beforeEach(() => {
jest.clearAllMocks();
Object.assign(navigator, { clipboard: { writeText } });
});

const renderComponent = (gb?: GrowthBook): RenderResult => {
const client = new QueryClient();

return render(
<TestBootProvider client={client} gb={gb} log={{ logEvent }}>
<ArchiveFeedPage
archive={null}
scopeType={ArchiveScopeType.Tag}
scopeId="react"
scopeName="react"
periodType={ArchivePeriodType.Month}
year={2026}
month={5}
/>
</TestBootProvider>,
);
};

it('keeps the original heading and renders no share control when flags are off', () => {
renderComponent();

expect(screen.queryByLabelText('Share this archive')).not.toBeInTheDocument();
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveClass('mx-4 font-bold typo-title2 tablet:typo-title1', {
exact: true,
});
});

it('copies the canonical archive page url and logs when flags are on', async () => {
renderComponent(
gbWithFeatures({ sharing_visibility: true, share_discovery: true }),
);

const controls = screen.getAllByLabelText('Share this archive');
expect(controls).toHaveLength(1);

await act(async () => {
fireEvent.click(controls[0]);
});

// webappUrl is '/' under Jest, so the canonical link is the bare path
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('/tags/react/best-of/2026/05'),
);
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.ShareLog,
target_id: '/tags/react/best-of/2026/05',
extra: JSON.stringify({
origin: Origin.BestOfArchive,
provider: ShareProvider.CopyLink,
}),
});
});
61 changes: 57 additions & 4 deletions packages/shared/src/components/archive/ArchiveFeedPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@ import classNames from 'classnames';
import type { Archive, ArchiveItem } from '../../graphql/archive';
import { ArchivePeriodType } from '../../graphql/archive';
import type { ArchiveScopeInfo } from '../../lib/archive';
import { getArchiveTitle, getArchiveIndexUrl } from '../../lib/archive';
import {
getArchiveDescription,
getArchiveIndexUrl,
getArchiveTitle,
getArchiveUrl,
} from '../../lib/archive';
import { ArchiveNavigation } from './ArchiveNavigation';
import { ArchivePostItem } from './ArchivePostItem';
import { ElementPlaceholder } from '../ElementPlaceholder';
import Link from '../utilities/Link';
import { ArrowIcon } from '../icons';
import { IconSize } from '../Icon';
import { ShareActions } from '../share/ShareActions';
import { ButtonSize } from '../buttons/Button';
import { useShareDiscovery } from '../../hooks/useShareDiscovery';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent, Origin } from '../../lib/log';
import { webappUrl } from '../../lib/constants';
import { ReferralCampaignKey } from '../../lib/referral';

interface ArchiveFeedPageProps {
scopeType: ArchiveScopeInfo['scopeType'];
Expand Down Expand Up @@ -95,6 +107,26 @@ export function ArchiveFeedPage({
scopeId,
} as ArchiveScopeInfo);
const items = (archive?.items ?? []).filter((item) => item.post);
const { isEnabled: canShare } = useShareDiscovery();
const { logEvent } = useLogContext();
const archivePath = getArchiveUrl(
{ scopeType, scopeId } as ArchiveScopeInfo,
periodType,
year,
month,
);
const heading = (
<h1
className={classNames(
// The share row takes over the horizontal inset when it renders, so
// flag-off keeps the original class list byte-for-byte.
!canShare && 'mx-4',
'font-bold typo-title2 tablet:typo-title1',
)}
>
Best of {scopeName} &mdash; {title.replace('Best of ', '')}
</h1>
);

return (
<div
Expand All @@ -104,9 +136,30 @@ export function ArchiveFeedPage({
)}
>
{/* Header */}
<h1 className="mx-4 font-bold typo-title2 tablet:typo-title1">
Best of {scopeName} &mdash; {title.replace('Best of ', '')}
</h1>
{canShare ? (
<div className="mx-4 flex items-center justify-between gap-2">
{heading}
<ShareActions
link={`${webappUrl}${archivePath.slice(1)}`}
text={getArchiveDescription(scopeName, periodType, year, month)}
label="Share this archive"
cid={ReferralCampaignKey.Generic}
buttonSize={ButtonSize.Medium}
onShare={(provider) =>
logEvent({
event_name: LogEvent.ShareLog,
target_id: archivePath,
extra: JSON.stringify({
origin: Origin.BestOfArchive,
provider,
}),
})
}
/>
</div>
) : (
heading
)}

{/* Top navigation */}
<ArchiveNavigation
Expand Down
99 changes: 99 additions & 0 deletions packages/shared/src/components/archive/ArchiveIndexPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { ArchiveIndexPage } from './ArchiveIndexPage';
import type { Archive } from '../../graphql/archive';
import {
ArchivePeriodType,
ArchiveRankingType,
ArchiveScopeType,
ArchiveSubjectType,
} from '../../graphql/archive';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { LogEvent, Origin } from '../../lib/log';
import { ShareProvider } from '../../lib/share';

const logEvent = jest.fn();
const writeText = jest.fn().mockResolvedValue(undefined);

const archive: Archive = {
id: 'a1',
subjectType: ArchiveSubjectType.Post,
rankingType: ArchiveRankingType.Best,
scopeType: ArchiveScopeType.Global,
scopeId: null,
periodType: ArchivePeriodType.Month,
periodStart: '2026-05-01T00:00:00.000Z',
items: [],
};

const gbWithFeatures = (features: Record<string, boolean>): GrowthBook =>
new GrowthBook({
features: Object.fromEntries(
Object.entries(features).map(([id, value]) => [
id,
{ defaultValue: value },
]),
),
});

beforeEach(() => {
jest.clearAllMocks();
Object.assign(navigator, { clipboard: { writeText } });
});

const renderComponent = (gb?: GrowthBook): RenderResult => {
const client = new QueryClient();

return render(
<TestBootProvider client={client} gb={gb} log={{ logEvent }}>
<ArchiveIndexPage
archives={[archive]}
scopeType={ArchiveScopeType.Global}
scopeName="daily.dev"
/>
</TestBootProvider>,
);
};

it('keeps the original heading and renders no share control when flags are off', () => {
renderComponent();

expect(screen.queryByLabelText('Share this archive')).not.toBeInTheDocument();
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveClass('mx-4 font-bold typo-title2 tablet:typo-title1', {
exact: true,
});
});

it('copies the canonical archive index url and logs when flags are on', async () => {
renderComponent(
gbWithFeatures({ sharing_visibility: true, share_discovery: true }),
);

const controls = screen.getAllByLabelText('Share this archive');
expect(controls).toHaveLength(1);

await act(async () => {
fireEvent.click(controls[0]);
});

// webappUrl is '/' under Jest, so the canonical link is the bare path
await waitFor(() => expect(writeText).toHaveBeenCalledWith('/posts/best-of'));
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.ShareLog,
target_id: '/posts/best-of',
extra: JSON.stringify({
origin: Origin.BestOfArchive,
provider: ShareProvider.CopyLink,
}),
});
});
53 changes: 50 additions & 3 deletions packages/shared/src/components/archive/ArchiveIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import classNames from 'classnames';
import type { Archive } from '../../graphql/archive';
import type { ArchiveScopeInfo, ArchivesByYear } from '../../lib/archive';
import {
getArchiveIndexUrl,
getArchiveUrlFromArchive,
getMonthName,
groupArchivesByYear,
Expand All @@ -13,6 +14,13 @@ import Link from '../utilities/Link';
import { ArrowIcon } from '../icons';
import { IconSize } from '../Icon';
import { ElementPlaceholder } from '../ElementPlaceholder';
import { ShareActions } from '../share/ShareActions';
import { ButtonSize } from '../buttons/Button';
import { useShareDiscovery } from '../../hooks/useShareDiscovery';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent, Origin } from '../../lib/log';
import { webappUrl } from '../../lib/constants';
import { ReferralCampaignKey } from '../../lib/referral';

interface ArchiveIndexPageProps {
scopeType: ArchiveScopeInfo['scopeType'];
Expand Down Expand Up @@ -159,13 +167,52 @@ export function ArchiveIndexPage({
className,
}: ArchiveIndexPageProps): ReactElement {
const groups = groupArchivesByYear(archives);
const { isEnabled: canShare } = useShareDiscovery();
const { logEvent } = useLogContext();
const indexPath = getArchiveIndexUrl({
scopeType,
scopeId,
} as ArchiveScopeInfo);
const heading = (
<h1
className={classNames(
// The share row takes over the horizontal inset when it renders, so
// flag-off keeps the original class list byte-for-byte.
!canShare && 'mx-4',
'font-bold typo-title2 tablet:typo-title1',
)}
>
Best of {scopeName} &mdash; Archive
</h1>
);

return (
<div className={classNames('flex flex-col', className)}>
{/* Header */}
<h1 className="mx-4 font-bold typo-title2 tablet:typo-title1">
Best of {scopeName} &mdash; Archive
</h1>
{canShare ? (
<div className="mx-4 flex items-center justify-between gap-2">
{heading}
<ShareActions
link={`${webappUrl}${indexPath.slice(1)}`}
text={`The most upvoted ${scopeName} posts by month and year, curated by the daily.dev community.`}
label="Share this archive"
cid={ReferralCampaignKey.Generic}
buttonSize={ButtonSize.Medium}
onShare={(provider) =>
logEvent({
event_name: LogEvent.ShareLog,
target_id: indexPath,
extra: JSON.stringify({
origin: Origin.BestOfArchive,
provider,
}),
})
}
/>
</div>
) : (
heading
)}

{/* Archive grid by year */}
<ArchiveGrid
Expand Down
Loading
Loading