Skip to content
Merged
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
70 changes: 70 additions & 0 deletions packages/shared/src/components/CustomFeedOptionsMenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ComponentProps } from 'react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen } from '@testing-library/react';
import CustomFeedOptionsMenu from './CustomFeedOptionsMenu';
import AuthContext from '../contexts/AuthContext';
import type { AuthContextData } from '../contexts/AuthContext';

jest.mock('../hooks', () => ({
...jest.requireActual('../hooks'),
useFeeds: () => ({ feeds: { edges: [] } }),
}));

const shareProps = {
text: "Check out Ido Shamun's profile on daily.dev",
link: 'https://app.daily.dev/idoshamun',
};

const renderMenu = (
props: Partial<ComponentProps<typeof CustomFeedOptionsMenu>> = {},
) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});

return render(
<QueryClientProvider client={client}>
<AuthContext.Provider
value={
{
user: null,
isAuthReady: true,
tokenRefreshed: true,
squads: [],
} as unknown as AuthContextData
}
>
<CustomFeedOptionsMenu
onAdd={jest.fn()}
shareProps={shareProps}
{...props}
/>
</AuthContext.Provider>
</QueryClientProvider>,
);
};

describe('CustomFeedOptionsMenu', () => {
it('should list the share option by default', async () => {
renderMenu({ shareProps });

// Radix opens the menu on keydown; jsdom lacks the pointer-event support
// its click path relies on.
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });

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

it('should drop the share option when the surface promotes it elsewhere', async () => {
renderMenu({ shareProps, hideShare: true });

// Radix opens the menu on keydown; jsdom lacks the pointer-event support
// its click path relies on.
fireEvent.keyDown(screen.getByRole('button'), { key: 'Enter' });

expect(await screen.findByText('Add to custom feed')).toBeInTheDocument();
expect(screen.queryByText('Share')).not.toBeInTheDocument();
});
});
19 changes: 13 additions & 6 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,13 +40,14 @@ const CustomFeedOptionsMenu = ({
onCreateNewFeed,
additionalOptions = [],
buttonVariant = ButtonVariant.Float,
hideShare = false,
}: CustomFeedOptionsMenuProps): ReactElement => {
const { openModal } = useLazyModal();
const [, onShareOrCopyLink] = useShareOrCopyLink(shareProps);
const { feeds } = useFeeds();

const handleOpenModal = () => {
if (feeds?.edges?.length > 0) {
if ((feeds?.edges?.length ?? 0) > 0) {
return openModal({
type: LazyModal.AddToCustomFeed,
props: {
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
17 changes: 17 additions & 0 deletions packages/shared/src/components/profile/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import Link from '../utilities/Link';
import type { MenuItemProps } from '../dropdown/common';
import { ProfileMobileBackButton } from './ProfileBackButton';
import { ProfileShareButton } from './ProfileShareButton';

export interface HeaderProps {
user: PublicProfile;
Expand Down Expand Up @@ -218,6 +219,20 @@ export function Header({
variant={ButtonVariant.Float}
/>
)}
{/* Only while pinned: unpinned, the profile card right below owns the
share control, and two identical copy buttons on one screen read as
a mistake. `ml-1` keeps this utility icon out of the Follow group. */}
{sticky && (
<ProfileShareButton
user={user}
isSameUser={isSameUser}
// Float, not the header card's Subtle: in this bar the control
// sits among Float icons (award, options) and a bordered button
// would read as a different kind of action.
buttonVariant={ButtonVariant.Float}
className="ml-1"
/>
)}
{!isSameUser && (
<CustomFeedOptionsMenu
onAdd={(feedId) =>
Expand All @@ -241,6 +256,8 @@ export function Header({
`/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`,
)
}
// Promoted out of the menu into the dedicated control above.
hideShare
shareProps={{
text: `Check out ${user.name}'s profile on daily.dev`,
link: user.permalink,
Expand Down
12 changes: 7 additions & 5 deletions packages/shared/src/components/profile/ProfileActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => {
props: {
offendingUser: {
id: user.id,
username: user.username,
username: user.username || '',
},
defaultBlockUser: defaultBlocked,
},
Expand All @@ -87,12 +87,12 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => {
? unblock({
id: user.id,
entity: ContentPreferenceType.User,
entityName: user.username,
entityName: user.username || '',
})
: block({
id: user.id,
entity: ContentPreferenceType.User,
entityName: user.username,
entityName: user.username || '',
}),
},
{
Expand Down Expand Up @@ -178,15 +178,15 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => {
follow({
id: user.id,
entity: ContentPreferenceType.User,
entityName: user.username,
entityName: user.username || '',
feedId,
})
}
onUndo={(feedId) =>
unfollow({
id: user.id,
entity: ContentPreferenceType.User,
entityName: user.username,
entityName: user.username || '',
feedId,
})
}
Expand All @@ -195,6 +195,8 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => {
`/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`,
)
}
// Promoted out of the menu into the header's share control.
hideShare
shareProps={{
text: `Check out ${user.name}'s profile on daily.dev`,
link: user.permalink,
Expand Down
129 changes: 129 additions & 0 deletions packages/shared/src/components/profile/ProfileHeader.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProfileHeader from './ProfileHeader';
import AuthContext from '../../contexts/AuthContext';
import type { AuthContextData } from '../../contexts/AuthContext';
import { getLogContextStatic } from '../../contexts/LogContext';
import type { PublicProfile } from '../../lib/user';
import { LogEvent, Origin, TargetType } from '../../lib/log';
import { ShareProvider } from '../../lib/share';

jest.mock('./ProfileActions', () => ({
__esModule: true,
default: () => <div data-testid="profile-actions" />,
}));

const user = {
id: 'u1',
name: 'Ido Shamun',
username: 'idoshamun',
permalink: 'https://app.daily.dev/idoshamun',
reputation: 10,
createdAt: '2020-01-01T00:00:00.000Z',
bio: 'Building daily.dev',
image: 'https://daily.dev/image.jpg',
cover: 'https://daily.dev/cover.jpg',
} as PublicProfile;

const userStats = { upvotes: 1, numFollowers: 2, numFollowing: 3 };

const logEvent = jest.fn();

const renderHeader = (isSameUser: boolean) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
const LogContext = getLogContextStatic();

return render(
<QueryClientProvider client={client}>
<AuthContext.Provider
value={
{
user: null,
isAuthReady: true,
tokenRefreshed: true,
squads: [],
} as unknown as AuthContextData
}
>
<LogContext.Provider
value={{
logEvent,
logEventStart: jest.fn(),
logEventEnd: jest.fn(),
sendBeacon: () => false,
}}
>
<ProfileHeader
user={user}
userStats={userStats}
isSameUser={isSameUser}
/>
</LogContext.Provider>
</AuthContext.Provider>
</QueryClientProvider>,
);
};

describe('ProfileHeader share control', () => {
beforeEach(() => jest.clearAllMocks());

it('should fill the edit slot with the share control on a public profile', () => {
renderHeader(false);

expect(
screen.getByLabelText("Copy link to @idoshamun's profile"),
).toBeInTheDocument();
// The invisible edit placeholder that used to hold the row height is gone.
expect(screen.queryByLabelText('Edit profile')).not.toBeInTheDocument();
});

it('should sit next to the edit button on the owner profile', () => {
renderHeader(true);

expect(
screen.getByLabelText('Copy link to your profile'),
).toBeInTheDocument();
expect(screen.getByLabelText('Edit profile')).toBeInTheDocument();
});

it('should log share profile from the header control', async () => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: { writeText: jest.fn().mockResolvedValue(undefined) },
});

renderHeader(false);

await userEvent.click(
screen.getByLabelText("Copy link to @idoshamun's profile"),
);

await waitFor(() =>
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.ShareProfile,
target_id: 'u1',
target_type: TargetType.ProfilePage,
extra: JSON.stringify({
provider: ShareProvider.CopyLink,
origin: Origin.Profile,
}),
}),
);
});

it('should render both controls at the same size and variant', () => {
renderHeader(true);

[
screen.getByLabelText('Copy link to your profile'),
screen.getByLabelText('Edit profile'),
].forEach((control) => {
expect(control).toHaveClass('btn-subtle');
expect(control).toHaveClass('h-8');
});
});
});
Loading
Loading