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
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();
});
});
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
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,8 @@ import {
import Link from '../utilities/Link';
import type { MenuItemProps } from '../dropdown/common';
import { ProfileMobileBackButton } from './ProfileBackButton';
import { ProfileShareButton } from './ProfileShareButton';
import { useShareProfileEnabled } from '../../hooks/profile/useShareProfileEnabled';

export interface HeaderProps {
user: PublicProfile;
Expand Down Expand Up @@ -83,6 +85,7 @@ export function Header({
});
const hasCoresAccess = useHasAccessToCores();
const canPurchaseCores = useCanPurchaseCores();
const isShareEnabled = useShareProfileEnabled();

const onReportUser = React.useCallback(
(defaultBlocked = false) => {
Expand Down Expand Up @@ -218,6 +221,17 @@ 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. */}
{isShareEnabled && sticky && (
<ProfileShareButton
user={user}
isSameUser={isSameUser}
buttonSize={ButtonSize.Small}
className="ml-1"
/>
)}
{!isSameUser && (
<CustomFeedOptionsMenu
onAdd={(feedId) =>
Expand All @@ -241,6 +255,9 @@ export function Header({
`/feeds/new?entityId=${user.id}&entityType=${ContentPreferenceType.User}`,
)
}
// Promoted out of the menu into a dedicated control when the
// share-profile experiment is on.
hideShare={isShareEnabled}
shareProps={{
text: `Check out ${user.name}'s profile on daily.dev`,
link: user.permalink,
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/components/profile/ProfileActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { AwardButton } from '../award/AwardButton';
import { Tooltip } from '../tooltip/Tooltip';
import { useAuthContext } from '../../contexts/AuthContext';
import { useCanAwardUser } from '../../hooks/useCoresFeature';
import { useShareProfileEnabled } from '../../hooks/profile/useShareProfileEnabled';
import type { MenuItemProps } from '../dropdown/common';

export interface HeaderProps {
Expand All @@ -59,6 +60,7 @@ const ProfileActions = ({ user, isPreviewMode }: HeaderProps): ReactElement => {
sendingUser: loggedUser,
receivingUser: user as LoggedUser,
});
const isShareEnabled = useShareProfileEnabled();

const onReportUser = React.useCallback(
(defaultBlocked = false) => {
Expand Down Expand Up @@ -195,6 +197,9 @@ 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 when the
// share-profile experiment is on.
hideShare={isShareEnabled}
shareProps={{
text: `Check out ${user.name}'s profile on daily.dev`,
link: user.permalink,
Expand Down
127 changes: 127 additions & 0 deletions packages/shared/src/components/profile/ProfileHeader.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
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 { useConditionalFeature } from '../../hooks/useConditionalFeature';

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

const mockUseConditionalFeature = jest.mocked(useConditionalFeature);

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 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: jest.fn(),
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());

describe('when the flag is off', () => {
beforeEach(() => {
mockUseConditionalFeature.mockReturnValue({
value: false,
isLoading: false,
});
});

it('should not render a share control on a public profile', () => {
renderHeader(false);

expect(screen.queryByLabelText(/Share/)).not.toBeInTheDocument();
});

it('should keep the invisible edit placeholder on a public profile', () => {
renderHeader(false);

expect(screen.getByLabelText('Edit profile')).toHaveClass('invisible');
});

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

expect(screen.getByLabelText('Edit profile')).not.toHaveClass(
'invisible',
);
expect(screen.queryByLabelText(/Share/)).not.toBeInTheDocument();
});
});

describe('when the flag is on', () => {
beforeEach(() => {
mockUseConditionalFeature.mockReturnValue({
value: true,
isLoading: false,
});
});

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

expect(
screen.getByLabelText("Share @idoshamun's profile"),
).toBeInTheDocument();
expect(screen.queryByLabelText('Edit profile')).not.toBeInTheDocument();
});

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

expect(screen.getByLabelText('Share your profile')).toBeInTheDocument();
expect(screen.getByLabelText('Edit profile')).toBeInTheDocument();
});
});
});
38 changes: 25 additions & 13 deletions packages/shared/src/components/profile/ProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { locationToString } from '../../lib/utils';
import { IconSize } from '../Icon';
import { fallbackImages } from '../../lib/config';
import { ProfileDesktopPwaBackButton } from './ProfileBackButton';
import { ProfileShareButton } from './ProfileShareButton';
import { useShareProfileEnabled } from '../../hooks/profile/useShareProfileEnabled';

import { ElementPlaceholder } from '../ElementPlaceholder';

Expand Down Expand Up @@ -61,6 +63,7 @@ const ProfileHeader = ({
const { name, username, bio, image, cover, isPlus } = user;
const { user: loggedUser } = useAuthContext();
const isSameUser = propIsSameUser ?? loggedUser?.id === user.id;
const isShareEnabled = useShareProfileEnabled();

return (
<div className="relative w-full overflow-hidden laptop:rounded-t-16">
Expand All @@ -75,19 +78,28 @@ const ProfileHeader = ({
className="absolute left-6 top-16 h-[7.5rem] w-[7.5rem] rounded-16 object-cover"
/>
<div className="flex flex-col gap-3 px-6">
<Link passHref href={`${webappUrl}settings/profile`}>
<Button
className={classNames(
'mb-4 ml-auto mt-2 text-text-secondary',
!isSameUser && 'invisible',
)}
tag="a"
disabled={!isSameUser}
variant={ButtonVariant.Float}
icon={<EditIcon />}
aria-label="Edit profile"
/>
</Link>
<div className="mb-4 ml-auto mt-2 flex items-center gap-2">
{isShareEnabled && (
<ProfileShareButton user={user} isSameUser={isSameUser} />
)}
{/* On public profiles the edit button only reserves the slot's
height; the share button now fills it, so drop the placeholder. */}
{(isSameUser || !isShareEnabled) && (
<Link passHref href={`${webappUrl}settings/profile`}>
<Button
className={classNames(
'text-text-secondary',
!isSameUser && 'invisible',
)}
tag="a"
disabled={!isSameUser}
variant={ButtonVariant.Float}
icon={<EditIcon />}
aria-label="Edit profile"
/>
</Link>
)}
</div>
<div className="flex items-center gap-1">
<Typography type={TypographyType.Title2} bold>
{name}
Expand Down
Loading
Loading