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
17 changes: 12 additions & 5 deletions packages/shared/src/components/CustomFeedOptionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type CustomFeedOptionsMenuProps = {
buttonVariant?: ButtonVariant;
shareProps: UseShareOrCopyLinkProps;
additionalOptions?: MenuItemProps[];
/** Drop the in-menu share entry when the surface renders a visible one. */
hideShare?: boolean;
};

const CustomFeedOptionsMenu = ({
Expand All @@ -38,6 +40,7 @@ const CustomFeedOptionsMenu = ({
onCreateNewFeed,
additionalOptions = [],
buttonVariant = ButtonVariant.Float,
hideShare = false,
}: CustomFeedOptionsMenuProps): ReactElement => {
const { openModal } = useLazyModal();
const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps);
Expand All @@ -59,11 +62,15 @@ const CustomFeedOptionsMenu = ({
};

const options: MenuItemProps[] = [
{
icon: <MenuIcon Icon={ShareIcon} />,
label: 'Share',
action: () => onShareOrCopyLink(),
},
...(hideShare
? []
: [
{
icon: <MenuIcon Icon={ShareIcon} />,
label: 'Share',
action: () => onShareOrCopyLink(),
},
]),
{
icon: <MenuIcon Icon={HashtagIcon} />,
label: 'Add to custom feed',
Expand Down
118 changes: 118 additions & 0 deletions packages/shared/src/components/share/EntityShareAction.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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 { EntityShareAction } from './EntityShareAction';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { LogEvent, Origin } from '../../lib/log';
import { ShareProvider } from '../../lib/share';
import { ReferralCampaignKey } from '../../lib/referral';
import type { ToastNotification } from '../../hooks/useToastNotification';
import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification';
import { useViewSize } from '../../hooks/useViewSize';

jest.mock('../../hooks/useViewSize', () => {
const actual = jest.requireActual('../../hooks/useViewSize');
return { __esModule: true, ...actual, useViewSize: jest.fn() };
});

const useViewSizeMock = useViewSize as jest.Mock;
const writeText = jest.fn().mockResolvedValue(undefined);
const logEvent = jest.fn();
const link = 'https://daily.dev/tags/webdev';
const text = 'Check out the webdev tag on daily.dev';

let client: QueryClient;

beforeEach(() => {
jest.clearAllMocks();
client = new QueryClient();
useViewSizeMock.mockReturnValue(true); // default: laptop
Object.assign(navigator, { clipboard: { writeText }, maxTouchPoints: 0 });
});

const renderComponent = (): RenderResult =>
render(
<TestBootProvider client={client} log={{ logEvent }}>
<EntityShareAction
link={link}
text={text}
cid={ReferralCampaignKey.ShareTag}
event={LogEvent.ShareTag}
targetId="webdev"
origin={Origin.TagPage}
/>
</TestBootProvider>,
);

const getToast = () =>
client.getQueryData<ToastNotification>(TOAST_NOTIF_KEY) ?? null;

describe('EntityShareAction', () => {
it('renders a labelled share trigger separate from the follow controls', () => {
renderComponent();

expect(screen.getByLabelText('Share')).toBeInTheDocument();
});

it('copies the link and shows the copied toast', async () => {
renderComponent();

await act(async () => {
fireEvent.click(screen.getByLabelText('Share'));
});
await act(async () => {
fireEvent.click(await screen.findByText('Copy link'));
});

await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
await waitFor(() =>
expect(getToast()?.message).toEqual('✅ Copied link to clipboard'),
);
});

it('logs the entity share event with the provider and origin', async () => {
renderComponent();

await act(async () => {
fireEvent.click(screen.getByLabelText('Share'));
});
await act(async () => {
fireEvent.click(await screen.findByText('Copy link'));
});

await waitFor(() =>
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.ShareTag,
target_id: 'webdev',
extra: JSON.stringify({
provider: ShareProvider.CopyLink,
origin: Origin.TagPage,
}),
}),
);
});

it('opens the native share sheet on a single tap on mobile', async () => {
useViewSizeMock.mockReturnValue(false);
const share = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { share, maxTouchPoints: 2 });

renderComponent();

await act(async () => {
fireEvent.click(screen.getByLabelText('Share'));
});

await waitFor(() =>
expect(share).toHaveBeenCalledWith({ text: `${text}\n${link}` }),
);
expect(writeText).not.toHaveBeenCalled();
});
});
64 changes: 64 additions & 0 deletions packages/shared/src/components/share/EntityShareAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ReactElement } from 'react';
import React from 'react';
import { ShareActions } from './ShareActions';
import { ButtonSize, ButtonVariant } from '../buttons/Button';
import { Divider } from '../utilities/Divider';
import { useLogContext } from '../../contexts/LogContext';
import type { LogEvent, Origin } from '../../lib/log';
import type { ReferralCampaignKey } from '../../lib/referral';
import type { ShareProvider } from '../../lib/share';

export interface EntityShareActionProps {
link: string;
text: string;
cid: ReferralCampaignKey;
/** Entity-specific share event, e.g. `LogEvent.ShareTag`. */
event: LogEvent;
targetId: string;
origin: Origin;
}

/**
* Share control for an entity header (tag / source), promoting share out of the
* "…" options menu. The leading vertical rule plus the ghost icon button keep
* "share this" spatially and visually separate from the filled Follow/Following
* button — different intents must not read as one segmented control.
*
* Rendered as a fragment so the host action row's own gap spaces the rule
* symmetrically against the buttons on either side of it.
*/
export function EntityShareAction({
link,
text,
cid,
event,
targetId,
origin,
}: EntityShareActionProps): ReactElement {
const { logEvent } = useLogContext();

const onShare = (provider: ShareProvider) =>
logEvent({
event_name: event,
target_id: targetId,
extra: JSON.stringify({ provider, origin }),
});

return (
<>
{/* `self-center` because host rows don't all set `items-center`. */}
<Divider vertical className="self-center" />
<ShareActions
link={link}
text={text}
cid={cid}
label="Share"
emailTitle={text}
emailSummary={text}
buttonVariant={ButtonVariant.Tertiary}
buttonSize={ButtonSize.Small}
onShare={onShare}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { SourceActions } from './index';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import type { Source } from '../../../graphql/sources';
import { SourceType } from '../../../graphql/sources';
import { useSourceActions } from '../../../hooks';
import { useContentPreference } from '../../../hooks/contentPreference/useContentPreference';

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

jest.mock('../../../hooks/source/useSourceActions', () => ({
__esModule: true,
useSourceActions: jest.fn(),
default: jest.fn(),
}));

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

const useSourceActionsMock = useSourceActions as jest.Mock;
const useContentPreferenceMock = useContentPreference as jest.Mock;

const source: Source = {
id: 'tds',
name: 'Towards Data Science',
handle: 'tds',
permalink: 'https://app.daily.dev/sources/tds',
image: 'https://media.daily.dev/tds',
type: SourceType.Machine,
public: true,
};

beforeEach(() => {
jest.clearAllMocks();
useSourceActionsMock.mockReturnValue({
isBlocked: false,
toggleBlock: jest.fn(),
isFollowing: false,
toggleFollow: jest.fn(),
haveNotificationsOn: false,
toggleNotify: jest.fn(),
});
useContentPreferenceMock.mockReturnValue({
follow: jest.fn(),
unfollow: jest.fn(),
});
});

const getGrowthBook = (isEnabled: boolean): GrowthBook => {
const gb = new GrowthBook();
gb.setFeatures({
sharing_visibility: { defaultValue: isEnabled },
share_tags_sources: { defaultValue: isEnabled },
});

return gb;
};

const renderComponent = (
{ isEnabled = false, showShare = true } = {},
isFollowing = false,
): RenderResult => {
useSourceActionsMock.mockReturnValue({
isBlocked: false,
toggleBlock: jest.fn(),
isFollowing,
toggleFollow: jest.fn(),
haveNotificationsOn: false,
toggleNotify: jest.fn(),
});

return render(
<TestBootProvider client={new QueryClient()} gb={getGrowthBook(isEnabled)}>
<SourceActions source={source} {...(showShare && { showShare: true })} />
</TestBootProvider>,
);
};

describe('SourceActions share control', () => {
it('surfaces share next to Follow when the flag is on', async () => {
renderComponent({ isEnabled: true });

expect(await screen.findByLabelText('Share')).toBeInTheDocument();
expect(screen.getAllByText('Follow').length).toBeGreaterThan(0);
});

it('keeps the control next to Following once the source is followed', async () => {
renderComponent({ isEnabled: true }, true);

expect(await screen.findByLabelText('Share')).toBeInTheDocument();
expect(screen.getAllByText('Following').length).toBeGreaterThan(0);
});

it('drops the in-menu share entry when the visible control is shown', async () => {
renderComponent({ isEnabled: true });

// Radix's trigger opens on pointerdown/keydown, not click; keyboard also
// covers the a11y requirement for the menu.
fireEvent.keyDown(screen.getByLabelText('Options'), { key: 'Enter' });

expect(await screen.findByText('Add to custom feed')).toBeInTheDocument();
expect(screen.queryByText('Share')).not.toBeInTheDocument();
});

it('keeps share inside the options menu when the flag is off', async () => {
renderComponent({ isEnabled: false });

expect(screen.queryByLabelText('Share')).not.toBeInTheDocument();

// Radix's trigger opens on pointerdown/keydown, not click; keyboard also
// covers the a11y requirement for the menu.
fireEvent.keyDown(screen.getByLabelText('Options'), { key: 'Enter' });

expect(await screen.findByText('Share')).toBeInTheDocument();
expect(screen.getByText('Add to custom feed')).toBeInTheDocument();
});

it('renders identical markup to the untouched consumer when the flag is off', async () => {
// Radix mints an incrementing id per mount; it carries no behaviour.
const normalize = (html: string) =>
html.replace(/radix-:r[^:]*:/g, 'radix');

const { container: optedIn, unmount } = renderComponent({
isEnabled: false,
showShare: true,
});
const optedInHtml = normalize(optedIn.innerHTML);
unmount();

const { container: untouched } = renderComponent({
isEnabled: false,
showShare: false,
});

await waitFor(() =>
expect(normalize(untouched.innerHTML)).toEqual(optedInHtml),
);
});
});
Loading
Loading