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,134 @@
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 { SquadDirectoryShareButton } from './SquadDirectoryShareButton';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import loggedUser from '../../../../__tests__/fixture/loggedUser';
import { generateTestSquad } from '../../../../__tests__/fixture/squads';
import { getShortLinkProps } from '../../../hooks/utils/useGetShortUrl';
import { ReferralCampaignKey } from '../../../lib/referral';
import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification';
import { useViewSize } from '../../../hooks/useViewSize';
import { LogEvent, Origin, TargetType } from '../../../lib/log';
import { ShareProvider } from '../../../lib/share';

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 squad = generateTestSquad();
const shortLink = 'https://dly.to/squad';

let client: QueryClient;

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

client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// Seed the resolved short URL so the copy path never hits the network.
const { queryKey } = getShortLinkProps(
squad.permalink,
ReferralCampaignKey.ShareSource,
loggedUser,
);
client.setQueryData(queryKey, shortLink);
});

afterEach(() => {
delete (navigator as { share?: unknown }).share;
});

const renderComponent = (): RenderResult =>
render(
<TestBootProvider
client={client}
auth={{ user: loggedUser }}
log={{ logEvent }}
>
<SquadDirectoryShareButton squad={squad} />
</TestBootProvider>,
);

const expectedLog = (provider: ShareProvider) => ({
event_name: LogEvent.ShareSource,
target_type: TargetType.Source,
target_id: squad.id,
extra: JSON.stringify({ origin: Origin.SquadDirectory, provider }),
});

describe('SquadDirectoryShareButton on desktop', () => {
it('copies the squad permalink, shows a toast and logs a source share', async () => {
renderComponent();

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

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

await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortLink));
expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({
message: '✅ Copied link to clipboard',
});
expect(logEvent).toHaveBeenCalledWith(expectedLog(ShareProvider.CopyLink));
});
});

describe('SquadDirectoryShareButton on mobile', () => {
beforeEach(() => {
useViewSizeMock.mockReturnValue(false);
});

it('opens the native share sheet on a single tap when available', async () => {
const share = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'share', {
configurable: true,
value: share,
});
// `shouldUseNativeShare` also requires a touch device.
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
value: 5,
});

renderComponent();

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

await waitFor(() =>
expect(share).toHaveBeenCalledWith({
text: `Check out ${squad.handle} on daily.dev\n${shortLink}`,
}),
);
expect(writeText).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(expectedLog(ShareProvider.Native));
});

it('falls back to copying when native share is unavailable', async () => {
renderComponent();

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

await waitFor(() => expect(writeText).toHaveBeenCalledWith(shortLink));
expect(logEvent).toHaveBeenCalledWith(expectedLog(ShareProvider.CopyLink));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { Squad } from '../../../graphql/sources';
import { ShareActions } from '../../share/ShareActions';
import { ButtonSize, ButtonVariant } from '../../buttons/common';
import { useLogContext } from '../../../contexts/LogContext';
import { LogEvent, Origin, TargetType } from '../../../lib/log';
import { ReferralCampaignKey } from '../../../lib/referral';

interface SquadDirectoryShareButtonProps {
squad: Squad;
className?: string;
}

// Squads are sources in the data model, so directory shares reuse the existing
// `ShareSource` event (same shape as `SourceActions`); this surface is
// distinguished by `origin: squad directory` in `extra`.
export const SquadDirectoryShareButton = ({
squad,
className,
}: SquadDirectoryShareButtonProps): ReactElement => {
const { logEvent } = useLogContext();

return (
<ShareActions
link={squad.permalink}
text={`Check out ${squad.handle} on daily.dev`}
cid={ReferralCampaignKey.ShareSource}
buttonVariant={ButtonVariant.Float}
buttonSize={ButtonSize.Medium}
label="Share Squad"
className={className}
onShare={(provider) =>
logEvent({
event_name: LogEvent.ShareSource,
target_type: TargetType.Source,
target_id: squad.id,
extra: JSON.stringify({
origin: Origin.SquadDirectory,
provider,
}),
})
}
/>
);
};
98 changes: 98 additions & 0 deletions packages/shared/src/components/cards/squad/SquadGrid.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RenderResult } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import React from 'react';
import nock from 'nock';
import { AuthContextProvider } from '../../../contexts/AuthContext';
Expand Down Expand Up @@ -28,6 +29,11 @@ import {
CONTENT_PREFERENCE_STATUS_QUERY,
ContentPreferenceType,
} from '../../../graphql/contentPreference';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import {
featureSharingVisibility,
featureShareSquadDirectory,
} from '../../../lib/featureManagement';

const squads = [generateTestSquad()];
const members = generateMembersList();
Expand Down Expand Up @@ -201,3 +207,95 @@ it('should render the component with a join squad button', async () => {
await waitFor(() => expect(queryCalled).toBeTruthy());
});
});

describe('squad directory share', () => {
const mockContentPreference = () =>
mockGraphQL({
request: {
query: CONTENT_PREFERENCE_STATUS_QUERY,
variables: {
id: admin.source.id,
entity: ContentPreferenceType.Source,
},
},
result: { data: { contentPreferenceStatus: null } },
});

const renderWithSharing = (enabled: boolean): RenderResult => {
const gb = new GrowthBook();
gb.setFeatures({
[featureSharingVisibility.id]: { defaultValue: enabled },
[featureShareSquadDirectory.id]: { defaultValue: enabled },
});

return render(
<TestBootProvider
client={new QueryClient()}
auth={{ user: loggedUser, squads }}
gb={gb}
>
<SquadGrid source={admin.source} />
</TestBootProvider>,
);
};

it('flag off: keeps the original full-width join button and no share control', async () => {
mockContentPreference();
renderWithSharing(false);

const button = await screen.findByTestId('squad-action');
expect(screen.queryByLabelText('Share Squad')).not.toBeInTheDocument();
expect(button).toHaveClass('w-full');
expect(button).not.toHaveClass('flex-1');
// No wrapper row is added: the button stays a direct child of the
// original column, with the exact original class list.
expect(button.parentElement!.className).toBe(
'flex flex-1 flex-col justify-between',
);
});

it('flag on: narrows the join button and adds the copy-link control', async () => {
mockContentPreference();
renderWithSharing(true);

const button = await screen.findByTestId('squad-action');
expect(screen.getByLabelText('Share Squad')).toBeInTheDocument();
expect(button).toHaveClass('flex-1');
expect(button).not.toHaveClass('w-full');
expect(button.parentElement!.className).toBe(
'z-0 flex w-full flex-row items-center gap-2',
);
});

it('flag on: joining the squad still works', async () => {
const currentMember = { ...admin.source.currentMember };
delete admin.source.currentMember;

mockContentPreference();
renderWithSharing(true);

let queryCalled = false;
mockGraphQL({
request: {
query: SQUAD_JOIN_MUTATION,
variables: { sourceId: admin.source.id },
},
result: () => {
queryCalled = true;
return { data: { source: { ...admin.source, currentMember } } };
},
});
mockGraphQL({
request: {
query: COMPLETE_ACTION_MUTATION,
variables: { type: ActionType.JoinSquad },
},
result: () => ({ data: { _: null } }),
});

const btn = await screen.findByText('Join Squad');
btn.click();
await waitForNock();
await waitFor(() => expect(queryCalled).toBeTruthy());
});
});
43 changes: 31 additions & 12 deletions packages/shared/src/components/cards/squad/SquadGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
} from '../../typography/Typography';
import { useSquadsDirectoryLogging } from './common/useSquadsDirectoryLogging';
import { useScrambler } from '../../../hooks/useScrambler';
import { useSquadDirectoryShareEnabled } from '../../../hooks/squads/useSquadDirectoryShareEnabled';
import { SquadDirectoryShareButton } from './SquadDirectoryShareButton';

export enum SourceCardBorderColor {
Avocado = 'avocado',
Expand Down Expand Up @@ -68,7 +70,8 @@ export const SquadGrid = ({
}: PropsWithChildren<UnFeaturedSquadCardProps>): ReactElement => {
const { user } = useAuthContext();
const campaignId = ad?.data?.source?.flags?.campaignId;
const { data: campaign } = useCampaignById(campaignId);
// The campaign query is disabled on a falsy id, so the fallback never runs.
const { data: campaign } = useCampaignById(campaignId ?? '');
const {
headerImage,
image,
Expand All @@ -81,14 +84,36 @@ export const SquadGrid = ({
} = source;
const { data: members } = useQuery<BasicSourceMember[]>({
queryKey: generateQueryKey(RequestKey.SquadMembers, user, source.id),
queryFn: () => getSquadMembers(source.id),
queryFn: () => getSquadMembers(source.id ?? ''),
staleTime: StaleTime.OneHour,
});
const borderColor = border || color || SourceCardBorderColor.Avocado;
const borderColor =
border || (color as SourceCardBorderColor) || SourceCardBorderColor.Avocado;
const canShare = useSquadDirectoryShareEnabled();
const { ref, onClickAd } = useSquadsDirectoryLogging(ad);
const promotedText = useScrambler('Promoted');
const promotedByTooltip = useScrambler(
campaign ? `Promoted by @${campaign.user.username}` : null,
campaign ? `Promoted by @${campaign.user.username}` : undefined,
);

// Flag-off must keep the exact original DOM (full-width Join, no wrapper);
// the share row only exists when the sharing flags are on.
const joinButton = (
<SquadActionButton
className={{ button: classNames('z-0', canShare ? 'flex-1' : 'w-full') }}
squad={source}
origin={Origin.SquadDirectory}
data-testid="squad-action"
buttonVariants={[ButtonVariant.Secondary, ButtonVariant.Float]}
/>
);
const actionButton = canShare ? (
<div className="z-0 flex w-full flex-row items-center gap-2">
{joinButton}
<SquadDirectoryShareButton squad={source} />
</div>
) : (
joinButton
);

return (
Expand Down Expand Up @@ -125,7 +150,7 @@ export const SquadGrid = ({
type={ImageType.Squad}
/>
{membersCount > 0 && (
<SquadMemberShortList squad={source} members={members} />
<SquadMemberShortList squad={source} members={members ?? []} />
)}
</div>
<div className="flex flex-1 flex-col justify-between">
Expand Down Expand Up @@ -157,13 +182,7 @@ export const SquadGrid = ({
)}
</div>

<SquadActionButton
className={{ button: 'z-0 w-full' }}
squad={source}
origin={Origin.SquadDirectory}
data-testid="squad-action"
buttonVariants={[ButtonVariant.Secondary, ButtonVariant.Float]}
/>
{actionButton}
</div>
</div>
{children}
Expand Down
Loading
Loading