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
6 changes: 6 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,9 @@ export const featureSharingVisibility = new Feature(
// high-traffic icon, so it ramps on its own flag to watch share/copy metrics.
// Keep the default `false` (control = existing `LinkIcon`).
export const featureShareCopyIcon = new Feature('share_copy_icon', false);

// Adds a share/copy-link action next to "Download DevCard" in the DevCard
// customization step, so the card can be shared without downloading it first.
// Control keeps the download-only action row. Keep the default `false` —
// GrowthBook ramps it.
export const featureShareDevcard = new Feature('share_devcard', false);
164 changes: 164 additions & 0 deletions packages/storybook/stories/components/DevCardShareActions.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions';
import { Button } from '@dailydotdev/shared/src/components/buttons/Button';
import {
ButtonSize,
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/common';
import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext';
import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext';
import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral';
import type { LoggedUser } from '@dailydotdev/shared/src/lib/user';
import { fn } from 'storybook/test';

const permalink = 'https://app.daily.dev/testuser';

interface DevCardActionRowProps {
/**
* Mirrors `share_devcard` + the `sharing_visibility` master gate in
* `DevCardStep2`. Off = the download-only action row currently in production.
*/
canShare: boolean;
onShare: () => void;
}

// Mirrors the action row under the DevCard preview in
// `packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx`.
// The step itself lives in `webapp`, which storybook does not depend on, so the
// row is reproduced here to review spacing and the share affordance.
const DevCardActionRow = ({
canShare,
onShare,
}: DevCardActionRowProps): React.ReactElement => {
const downloadButton = (
<Button
className={canShare ? 'grow-0' : 'mx-auto mt-4 grow-0 self-start'}
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
>
Download DevCard
</Button>
);

if (!canShare) {
return <section className="flex flex-col">{downloadButton}</section>;
}

return (
<section className="flex flex-col">
<div className="mt-4 flex flex-row items-center justify-center gap-3">
{downloadButton}
<ShareActions
link={permalink}
text="Check out my DevCard on daily.dev"
emailTitle="My daily.dev DevCard"
cid={ReferralCampaignKey.ShareProfile}
label="Share DevCard"
buttonVariant={ButtonVariant.Secondary}
buttonSize={ButtonSize.Medium}
onShare={onShare}
/>
</div>
</section>
);
};

const meta: Meta<typeof DevCardActionRow> = {
title: 'Components/Share/DevCardShareActions',
component: DevCardActionRow,
args: {
canShare: true,
onShare: fn(),
},
argTypes: {
canShare: {
control: 'boolean',
description:
'share_devcard flag + sharing_visibility master gate. Off = download only.',
},
},
decorators: [
(Story) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Mock the short-URL resolution so copy/social actions don't hit network.
queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123');

const LogContext = getLogContextStatic();

const mockUser = {
id: '1',
name: 'Test User',
username: 'testuser',
email: 'test@example.com',
image:
'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg',
providers: ['google'],
createdAt: '2024-01-01T00:00:00.000Z',
permalink,
} as unknown as LoggedUser;

return (
<QueryClientProvider client={queryClient}>
<AuthContext.Provider
value={{
user: mockUser,
shouldShowLogin: false,
isLoggedIn: true,
isAuthReady: true,
showLogin: fn(),
closeLogin: fn(),
logout: fn(),
updateUser: fn(),
tokenRefreshed: true,
getRedirectUri: fn(),
loadingUser: false,
loadedUserFromCache: true,
refetchBoot: fn(),
squads: [],
isAndroidApp: false,
}}
>
<LogContext.Provider
value={{
logEvent: fn(),
logEventStart: fn(),
logEventEnd: fn(),
sendBeacon: () => false,
}}
>
<div className="flex min-h-60 items-center justify-center">
<Story />
</div>
</LogContext.Provider>
</AuthContext.Provider>
</QueryClientProvider>
);
},
],
};

export default meta;

type Story = StoryObj<typeof DevCardActionRow>;

// Flag on, desktop: primary Download plus a secondary share trigger that opens
// the share popover. Click the share icon, then "Copy link" for the copying
// state (the icon flips to its filled variant while the toast is up).
export const Desktop: Story = {};

// Flag off — the production action row: a single centered Download button.
export const FlagOff: Story = {
name: 'Flag off (download only)',
args: { canShare: false },
};

// Narrow canvas so the mobile breakpoint applies: a single tap goes straight to
// the native share sheet (or copies when native share is unavailable).
export const Mobile: Story = {
name: 'Mobile viewport (tap to share)',
parameters: { viewport: { defaultViewport: 'mobile2' } },
};
221 changes: 221 additions & 0 deletions packages/webapp/__tests__/DevCardStep2.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
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 nock from 'nock';
import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot';
import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser';
import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql';
import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral';
import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl';
import {
useViewSize,
ViewSize,
} from '@dailydotdev/shared/src/hooks/useViewSize';
import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification';
import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature';
import type { Feature } from '@dailydotdev/shared/src/lib/featureManagement';
import { downloadUrl } from '@dailydotdev/shared/src/lib/blob';
import { GENERATE_DEVCARD_MUTATION } from '../graphql/devcard';
import { DevCardStep2 } from '../components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2';

jest.mock('react-parallax-tilt', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

jest.mock(
'@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper',
() => ({
DevCardFetchWrapper: () => <div data-testid="devcard-preview" />,
}),
);

jest.mock('@dailydotdev/shared/src/hooks/profile/useDevCard', () => ({
useDevCard: () => ({
devcard: { theme: 'default', showBorder: true, isProfileCover: false },
isLoading: false,
coverImage: 'https://daily.dev/cover.png',
}),
}));

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

jest.mock('@dailydotdev/shared/src/hooks/useToastNotification', () => {
const actual = jest.requireActual(
'@dailydotdev/shared/src/hooks/useToastNotification',
);
return { __esModule: true, ...actual, useToastNotification: jest.fn() };
});

jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({
__esModule: true,
useConditionalFeature: jest.fn(),
}));

jest.mock('@dailydotdev/shared/src/lib/blob', () => {
const actual = jest.requireActual('@dailydotdev/shared/src/lib/blob');
return { __esModule: true, ...actual, downloadUrl: jest.fn() };
});

const useViewSizeMock = useViewSize as jest.Mock;
const useToastNotificationMock = useToastNotification as jest.Mock;
const useConditionalFeatureMock = useConditionalFeature as jest.Mock;
const displayToast = jest.fn();
const writeText = jest.fn().mockResolvedValue(undefined);
const shortUrl = 'https://dly.to/devcard';
const shareLabel = 'Share DevCard';
const shareText = 'Check out my DevCard on daily.dev';

const renderComponent = ({
isFlagOn = true,
isLaptop = true,
}: { isFlagOn?: boolean; isLaptop?: boolean } = {}): RenderResult => {
// The download button renders as an anchor on small screens; keep it a button
// so both flag states are compared on the same element.
useViewSizeMock.mockImplementation((size: ViewSize) =>
size === ViewSize.Laptop ? isLaptop : false,
);
useConditionalFeatureMock.mockImplementation(
({ feature }: { feature: Feature<boolean> }) => ({
value: isFlagOn ? true : feature.defaultValue,
isLoading: false,
}),
);

const client = new QueryClient();
// The share link is shortened through the referral campaign, so seed the
// resolved short URL instead of letting the copy path hit the network.
const { queryKey } = getShortLinkProps(
loggedUser.permalink,
ReferralCampaignKey.ShareProfile,
loggedUser,
);
client.setQueryData(queryKey, shortUrl);

return render(
<TestBootProvider client={client} auth={{ user: loggedUser }}>
<DevCardStep2 />
</TestBootProvider>,
);
};

beforeEach(() => {
jest.clearAllMocks();
nock.cleanAll();
useToastNotificationMock.mockReturnValue({
displayToast,
dismissToast: jest.fn(),
});
Object.assign(navigator, { clipboard: { writeText } });
// `shouldUseNativeShare` checks for the API's presence, so it has to be
// absent rather than undefined for the copy fallback.
const shareable: { share?: unknown } = navigator;
delete shareable.share;
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 0,
configurable: true,
});
});

describe('DevCardStep2 share action with the flag off', () => {
it('keeps the download-only action row', () => {
renderComponent({ isFlagOn: false });

expect(screen.getByText('Download DevCard')).toBeInTheDocument();
expect(screen.queryByLabelText(shareLabel)).not.toBeInTheDocument();
});

it('keeps the original standalone centering on the download button', () => {
renderComponent({ isFlagOn: false });

const download = screen.getByText('Download DevCard').closest('button');
expect(download).toHaveClass('mx-auto', 'mt-4', 'self-start');
});
});

describe('DevCardStep2 share action with the flag on', () => {
it('renders a labelled share control next to the download button', () => {
renderComponent();

expect(screen.getByText('Download DevCard')).toBeInTheDocument();
expect(screen.getByLabelText(shareLabel)).toBeInTheDocument();
});

it('keeps the download action working', async () => {
mockGraphQL({
request: {
query: GENERATE_DEVCARD_MUTATION,
variables: {
type: 'DEFAULT',
theme: 'DEFAULT',
showBorder: true,
isProfileCover: false,
},
},
result: () => ({
data: { devCard: { imageUrl: 'https://daily.dev/dc.png' } },
}),
});

renderComponent();

await act(async () => {
fireEvent.click(screen.getByText('Download DevCard'));
});

await waitFor(() =>
expect(downloadUrl).toHaveBeenCalledWith({
url: 'https://daily.dev/dc.png',
filename: `${loggedUser.username}.png`,
}),
);
});

it('copies the shortened profile link when native share is unavailable', async () => {
renderComponent({ isLaptop: false });

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

await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortUrl));
expect(displayToast).toHaveBeenCalledWith(
'✅ Copied link to clipboard',
expect.anything(),
);
});

it('opens the native share sheet on mobile when it is available', async () => {
const share = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, { share });
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});

renderComponent({ isLaptop: false });

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

await waitFor(() =>
expect(share).toHaveBeenCalledWith({
text: `${shareText}\n${shortUrl}`,
}),
);
expect(writeText).not.toHaveBeenCalled();
});
});
Loading
Loading