diff --git a/packages/shared/src/components/cards/squad/SquadDirectoryShareButton.spec.tsx b/packages/shared/src/components/cards/squad/SquadDirectoryShareButton.spec.tsx new file mode 100644 index 00000000000..02bacf19204 --- /dev/null +++ b/packages/shared/src/components/cards/squad/SquadDirectoryShareButton.spec.tsx @@ -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( + + + , + ); + +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)); + }); +}); diff --git a/packages/shared/src/components/cards/squad/SquadDirectoryShareButton.tsx b/packages/shared/src/components/cards/squad/SquadDirectoryShareButton.tsx new file mode 100644 index 00000000000..9850c8badb5 --- /dev/null +++ b/packages/shared/src/components/cards/squad/SquadDirectoryShareButton.tsx @@ -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 ( + + logEvent({ + event_name: LogEvent.ShareSource, + target_type: TargetType.Source, + target_id: squad.id, + extra: JSON.stringify({ + origin: Origin.SquadDirectory, + provider, + }), + }) + } + /> + ); +}; diff --git a/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx b/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx index d3885994cf5..0484fcdaad2 100644 --- a/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx +++ b/packages/shared/src/components/cards/squad/SquadGrid.spec.tsx @@ -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'; @@ -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(); @@ -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( + + + , + ); + }; + + 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()); + }); +}); diff --git a/packages/shared/src/components/cards/squad/SquadGrid.tsx b/packages/shared/src/components/cards/squad/SquadGrid.tsx index aa51af532c1..dfbe8240b47 100644 --- a/packages/shared/src/components/cards/squad/SquadGrid.tsx +++ b/packages/shared/src/components/cards/squad/SquadGrid.tsx @@ -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', @@ -68,7 +70,8 @@ export const SquadGrid = ({ }: PropsWithChildren): 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, @@ -81,14 +84,36 @@ export const SquadGrid = ({ } = source; const { data: members } = useQuery({ 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 = ( + + ); + const actionButton = canShare ? ( +
+ {joinButton} + +
+ ) : ( + joinButton ); return ( @@ -125,7 +150,7 @@ export const SquadGrid = ({ type={ImageType.Squad} /> {membersCount > 0 && ( - + )}
@@ -157,13 +182,7 @@ export const SquadGrid = ({ )}
- + {actionButton} {children} diff --git a/packages/shared/src/components/cards/squad/SquadList.spec.tsx b/packages/shared/src/components/cards/squad/SquadList.spec.tsx index bcf0976129c..58ad263993f 100644 --- a/packages/shared/src/components/cards/squad/SquadList.spec.tsx +++ b/packages/shared/src/components/cards/squad/SquadList.spec.tsx @@ -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'; @@ -21,6 +22,11 @@ import { CONTENT_PREFERENCE_STATUS_QUERY, ContentPreferenceType, } from '../../../graphql/contentPreference'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { + featureSharingVisibility, + featureShareSquadDirectory, +} from '../../../lib/featureManagement'; const squadsList = [generateTestSquad()]; const members = generateMembersList(); @@ -146,3 +152,45 @@ it('should render the component with a join squad button', async () => { await waitFor(() => expect(queryCalled).toBeTruthy()); }); }); + +describe('squad directory share', () => { + const renderWithSharing = (enabled: boolean): RenderResult => { + const gb = new GrowthBook(); + gb.setFeatures({ + [featureSharingVisibility.id]: { defaultValue: enabled }, + [featureShareSquadDirectory.id]: { defaultValue: enabled }, + }); + + return render( + + + , + ); + }; + + const getTextColumn = (): HTMLElement => + screen.getByText('Test').parentElement!; + + it('flag off: keeps the original text column width and no share control', () => { + renderWithSharing(false); + + expect(screen.queryByLabelText('Share Squad')).not.toBeInTheDocument(); + // Exact original class list, byte for byte. + expect(getTextColumn().className).toBe( + 'flex max-w-[calc(100%-10rem)] flex-1 flex-col', + ); + }); + + it('flag on: adds the copy-link control and reserves room for it', () => { + renderWithSharing(true); + + expect(screen.getByLabelText('Share Squad')).toBeInTheDocument(); + expect(getTextColumn().className).toBe( + 'flex max-w-[calc(100%-13.5rem)] flex-1 flex-col', + ); + }); +}); diff --git a/packages/shared/src/components/cards/squad/SquadList.tsx b/packages/shared/src/components/cards/squad/SquadList.tsx index a8b9294fa45..f8a5c7f71f2 100644 --- a/packages/shared/src/components/cards/squad/SquadList.tsx +++ b/packages/shared/src/components/cards/squad/SquadList.tsx @@ -1,5 +1,6 @@ import type { ComponentProps, ReactElement, ReactNode } from 'react'; import React from 'react'; +import classNames from 'classnames'; import Link from '../../utilities/Link'; import type { Squad } from '../../../graphql/sources'; import { @@ -17,6 +18,8 @@ import { ButtonVariant } from '../../buttons/common'; import type { Ad } from '../../../graphql/posts'; import { useSquadsDirectoryLogging } from './common/useSquadsDirectoryLogging'; import { useScrambler } from '../../../hooks/useScrambler'; +import { useSquadDirectoryShareEnabled } from '../../../hooks/squads/useSquadDirectoryShareEnabled'; +import { SquadDirectoryShareButton } from './SquadDirectoryShareButton'; interface SquadListProps extends ComponentProps<'div'> { squad: Squad; @@ -34,6 +37,7 @@ export const SquadList = ({ }: SquadListProps): ReactElement => { const { image, name, permalink } = squad; const campaignId = ad?.data?.source?.flags?.campaignId; + const canShare = useSquadDirectoryShareEnabled(); const { ref, onClickAd } = useSquadsDirectoryLogging(ad); const promotedText = useScrambler('Promoted'); @@ -56,7 +60,15 @@ export const SquadList = ({ alt={`${name} source`} type={ImageType.Squad} /> -
+
{name} @@ -87,6 +99,7 @@ export const SquadList = ({ data-testid="squad-action" buttonVariants={[ButtonVariant.Secondary, ButtonVariant.Float]} /> + {canShare && } {children}
); diff --git a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx index e92e18a2af0..99996778efe 100644 --- a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx +++ b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.spec.tsx @@ -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'; @@ -20,6 +21,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(); @@ -141,3 +147,61 @@ 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( + + + , + ); + }; + + it('flag off: keeps the join button alone in the header row', async () => { + mockContentPreference(); + renderWithSharing(false); + + const button = await screen.findByTestId('squad-action'); + expect(screen.queryByLabelText('Share Squad')).not.toBeInTheDocument(); + // No wrapper is added: the button stays a direct child of the original + // header row, with the exact original class list. + expect(button.parentElement!.className).toBe( + 'mb-3 flex items-center justify-between', + ); + }); + + it('flag on: renders the copy-link control next to the join button', async () => { + mockContentPreference(); + renderWithSharing(true); + + const button = await screen.findByTestId('squad-action'); + expect(screen.getByLabelText('Share Squad')).toBeInTheDocument(); + const wrapper = button.parentElement!; + expect(wrapper.className).toBe('z-0 flex flex-row items-center gap-2'); + expect(wrapper.parentElement!.className).toBe( + 'mb-3 flex items-center justify-between', + ); + }); +}); diff --git a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx index ef4f41d2b9d..2c65f469b9e 100644 --- a/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx +++ b/packages/shared/src/components/cards/squad/UnfeaturedSquadGrid.tsx @@ -15,12 +15,27 @@ import { Origin } from '../../../lib/log'; import { SquadActionButton } from '../../squads/SquadActionButton'; import { ButtonVariant } from '../../buttons/common'; import { Image, ImageType } from '../../image/Image'; +import { useSquadDirectoryShareEnabled } from '../../../hooks/squads/useSquadDirectoryShareEnabled'; +import { SquadDirectoryShareButton } from './SquadDirectoryShareButton'; export const UnfeaturedSquadGrid = ({ source, className, }: UnFeaturedSquadCardProps): ReactElement => { const title = source.name; + const canShare = useSquadDirectoryShareEnabled(); + + // Flag-off must keep the exact original DOM (Join alone in the header row); + // the share control and its wrapper only exist when the sharing flags are on. + const joinButton = ( + + ); return ( @@ -36,13 +51,14 @@ export const UnfeaturedSquadGrid = ({ className="size-16 rounded-full" type={ImageType.Squad} /> - + {canShare ? ( +
+ {joinButton} + +
+ ) : ( + joinButton + )}
({ + useConditionalFeature: jest.fn(), +})); + +const conditionalFeatureMock = useConditionalFeature as jest.Mock; +const evaluated: string[] = []; + +const mockFlags = (flags: Record) => + conditionalFeatureMock.mockImplementation( + ({ + feature, + shouldEvaluate, + }: { + feature: Feature; + shouldEvaluate?: boolean; + }) => { + if (shouldEvaluate !== false) { + evaluated.push(feature.id); + } + + return { + value: + shouldEvaluate === false ? feature.defaultValue : flags[feature.id], + isLoading: false, + }; + }, + ); + +beforeEach(() => { + jest.clearAllMocks(); + evaluated.length = 0; +}); + +describe('useSquadDirectoryShareEnabled', () => { + it('is off while the sharing-visibility kill switch is off', () => { + mockFlags({ + [featureSharingVisibility.id]: false, + [featureShareSquadDirectory.id]: true, + }); + + const { result } = renderHook(() => useSquadDirectoryShareEnabled()); + + expect(result.current).toBe(false); + // The per-topic flag must not be evaluated for control users. + expect(evaluated).not.toContain(featureShareSquadDirectory.id); + }); + + it('is off when the per-topic flag is off', () => { + mockFlags({ + [featureSharingVisibility.id]: true, + [featureShareSquadDirectory.id]: false, + }); + + const { result } = renderHook(() => useSquadDirectoryShareEnabled()); + + expect(result.current).toBe(false); + }); + + it('is on only when both flags are on', () => { + mockFlags({ + [featureSharingVisibility.id]: true, + [featureShareSquadDirectory.id]: true, + }); + + const { result } = renderHook(() => useSquadDirectoryShareEnabled()); + + expect(result.current).toBe(true); + }); +}); diff --git a/packages/shared/src/hooks/squads/useSquadDirectoryShareEnabled.ts b/packages/shared/src/hooks/squads/useSquadDirectoryShareEnabled.ts new file mode 100644 index 00000000000..04eef199c57 --- /dev/null +++ b/packages/shared/src/hooks/squads/useSquadDirectoryShareEnabled.ts @@ -0,0 +1,16 @@ +import { featureShareSquadDirectory } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { useSharingVisibility } from '../useSharingVisibility'; + +// Gate for the copy-link/share control on the squad directory cards. The +// per-topic flag is only evaluated once the sharing-visibility master switch +// is on, so control users never get bucketed into the experiment. +export const useSquadDirectoryShareEnabled = (): boolean => { + const { isEnabled: isSharingEnabled } = useSharingVisibility(); + const { value } = useConditionalFeature({ + feature: featureShareSquadDirectory, + shouldEvaluate: isSharingEnabled, + }); + + return isSharingEnabled && value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..f5c1e41dbfb 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,12 @@ 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); + +// Copy-link/share control on the squad directory cards (featured grid, +// unfeatured grid and the mobile list), rendered next to the Join Squad +// button. Also gated by the `sharing_visibility` master flag. Keep the default +// `false` — control is the directory as it ships today; GrowthBook ramps it. +export const featureShareSquadDirectory = new Feature( + 'share_squad_directory', + false, +); diff --git a/packages/storybook/stories/components/cards/Squad/SquadDirectoryShare.stories.tsx b/packages/storybook/stories/components/cards/Squad/SquadDirectoryShare.stories.tsx new file mode 100644 index 00000000000..c65aa793bcf --- /dev/null +++ b/packages/storybook/stories/components/cards/Squad/SquadDirectoryShare.stories.tsx @@ -0,0 +1,233 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { SquadGrid } from '@dailydotdev/shared/src/components/cards/squad/SquadGrid'; +import { SquadList } from '@dailydotdev/shared/src/components/cards/squad/SquadList'; +import { UnfeaturedSquadGrid } from '@dailydotdev/shared/src/components/cards/squad/UnfeaturedSquadGrid'; +import type { + BasicSourceMember, + Squad, +} from '@dailydotdev/shared/src/graphql/sources'; +import { + SourceMemberRole, + SourceType, +} from '@dailydotdev/shared/src/graphql/sources'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + FeaturesReadyContext, + GrowthBookProvider, +} from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { ContentPreferenceType } from '@dailydotdev/shared/src/graphql/contentPreference'; +import { getShortLinkProps } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; + +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: 'https://daily.dev/testuser', +} as unknown as LoggedUser; + +const buildSquad = (id: string, name: string, handle: string): Squad => + ({ + id, + name, + handle, + permalink: `https://app.daily.dev/squads/${handle}`, + active: true, + public: true, + type: SourceType.Squad, + membersCount: 23209, + description: + 'A place for developers who care about shipping fast without breaking prod. War stories, hot takes and the occasional postmortem.', + memberPostingRole: SourceMemberRole.Member, + memberInviteRole: SourceMemberRole.Member, + image: 'https://via.placeholder.com/150', + moderationRequired: false, + moderationPostCount: 0, + } as unknown as Squad); + +const featured = buildSquad('squad-1', 'Ship It', 'ship-it'); +const unfeatured = buildSquad('squad-2', 'Backend Bandits', 'backend-bandits'); +const listed = buildSquad('squad-3', 'Frontend Friends', 'frontend-friends'); +const squads = [featured, unfeatured, listed]; + +const members: BasicSourceMember[] = [1, 2, 3].map((i) => ({ + user: { + id: `member-${i}`, + name: `Member ${i}`, + image: + 'https://media.daily.dev/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', + permalink: `https://daily.dev/member-${i}`, + }, +})); + +const SHORT_LINK = 'https://dly.to/abc123'; + +// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` +// coerces every falsy default to the truthy string `'control'`, so a flag can't +// be evaluated as `false` here. Flag-off is therefore simulated by holding the +// features context as "not ready", which is the exact path +// `useConditionalFeature` takes to fall back to the (false) default value. +// Jest specs are the real flag-off guarantee. +const withProviders = + (enabled: boolean) => + (Story: React.ComponentType): React.ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + + squads.forEach((squad) => { + // Seed the follow status so the Join Squad button actually renders. + queryClient.setQueryData( + generateQueryKey(RequestKey.ContentPreference, mockUser, { + id: squad.id, + entity: ContentPreferenceType.Source, + }), + null, + ); + // Seed the member short-list used by the featured grid card. + queryClient.setQueryData( + generateQueryKey(RequestKey.SquadMembers, mockUser, squad.id), + members, + ); + // Seed the resolved short URL so copy actions don't hit the network. + const { queryKey } = getShortLinkProps( + squad.permalink, + ReferralCampaignKey.ShareSource, + mockUser, + ); + queryClient.setQueryData(queryKey, SHORT_LINK); + }); + + const LogContext = getLogContextStatic(); + + return ( + + + + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + feature.defaultValue as any, + }} + > + false, + }} + > + + + + + + + ); + }; + +// All three directory card variants side by side: featured grid, unfeatured +// grid, and the mobile list row. +const DirectoryShowcase = (): React.ReactElement => ( +
+
+ + +
+
+ +
+
+); + +const meta: Meta = { + title: 'Components/Cards/Squad/SquadDirectoryShare', + component: DirectoryShowcase, + parameters: { + docs: { + description: { + component: + 'Copy-link/share control on the squad directory cards, next to a slightly-narrowed Join Squad button. Gated by `share_squad_directory` AND the `sharing_visibility` master flag; control renders the directory exactly as it ships today.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// Flag on — every card variant gains the copy-link trigger next to Join. +export const FlagOn: Story = { + decorators: [withProviders(true)], +}; + +// Flag off — must render exactly what ships today (full-width Join on the +// featured grid, no share control anywhere). +export const FlagOff: Story = { + decorators: [withProviders(false)], +}; + +// Desktop copy flow: the trigger opens the share popover; Copy link flips to +// Copied! The clipboard is stubbed because the Storybook iframe isn't allowed +// to write to the real one. +export const FlagOnCopying: Story = { + decorators: [withProviders(true)], + play: async ({ canvasElement }) => { + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => undefined }, + }); + + const canvas = within(canvasElement); + const triggers = canvas.getAllByLabelText('Share Squad'); + await expect(triggers).toHaveLength(3); + await userEvent.click(triggers[0]); + + const body = within(globalThis.document.body); + await userEvent.click(await body.findByText('Copy link')); + await waitFor(() => expect(body.getByText('Copied!')).toBeInTheDocument()); + }, +};