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
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { HighlightCardOptions } from './HighlightCardOptions';
import type { MenuItemProps } from '../../dropdown/common';
import {
HighlightsPlacement,
SidebarSettingsFlags,
} from '../../../graphql/settings';

const mockSubscribe = jest.fn().mockResolvedValue(undefined);
const mockUnsubscribe = jest.fn().mockResolvedValue(undefined);
Expand All @@ -9,15 +14,37 @@ const mockUseAuth = jest.fn();
const mockUseConditionalFeature = jest.fn();
const mockUseMajorHeadlinesSubscription = jest.fn();
const mockRouterPush = jest.fn();
const mockUpdateFlag = jest.fn().mockResolvedValue(undefined);
const mockUseSettingsContext = jest.fn();
const mockLogEvent = jest.fn();
const mockInvalidateQueries = jest.fn().mockResolvedValue(undefined);
const mockUseActiveFeedContext = jest.fn();

jest.mock('next/router', () => ({
useRouter: () => ({ push: mockRouterPush }),
}));

jest.mock('@tanstack/react-query', () => ({
...(jest.requireActual('@tanstack/react-query') as Iterable<unknown>),
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
}));

jest.mock('../../../contexts/ActiveFeedContext', () => ({
useActiveFeedContext: () => mockUseActiveFeedContext(),
}));

jest.mock('../../../contexts/AuthContext', () => ({
useAuthContext: () => mockUseAuth(),
}));

jest.mock('../../../contexts/SettingsContext', () => ({
useSettingsContext: () => mockUseSettingsContext(),
}));

jest.mock('../../../contexts/LogContext', () => ({
useLogContext: () => ({ logEvent: mockLogEvent }),
}));

jest.mock('../../../hooks/useConditionalFeature', () => ({
useConditionalFeature: () => mockUseConditionalFeature(),
}));
Expand All @@ -30,8 +57,24 @@ jest.mock('../../../hooks/useToastNotification', () => ({
useToastNotification: () => ({ displayToast: mockDisplayToast }),
}));

jest.mock('../../tooltip/Tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
jest.mock('../../dropdown/DropdownMenu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
children,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => (
<div>
{options.map(({ label, action, disabled }) => (
<button key={label} type="button" onClick={action} disabled={disabled}>
{label}
</button>
))}
</div>
),
}));

const renderComponent = () => render(<HighlightCardOptions />);
Expand All @@ -47,11 +90,23 @@ describe('HighlightCardOptions', () => {
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
});
mockUseSettingsContext.mockReturnValue({
flags: { highlightsPlacement: HighlightsPlacement.Default },
updateFlag: mockUpdateFlag,
});
mockUseActiveFeedContext.mockReturnValue({
queryKey: ['feed', 'main'],
items: [],
});
});

it('should render bell button when feature is on and user is logged in', () => {
it('should render the options menu with all items when feature is on and user is logged in', () => {
renderComponent();

expect(
screen.getByRole('button', { name: 'Pin to top' }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Disable' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Get real-time alerts' }),
).toBeInTheDocument();
Expand All @@ -77,6 +132,71 @@ describe('HighlightCardOptions', () => {
).not.toBeInTheDocument();
});

it('should pin to top by updating the placement flag and invalidating the feed', async () => {
renderComponent();

fireEvent.click(screen.getByRole('button', { name: 'Pin to top' }));

await waitFor(() => {
expect(mockUpdateFlag).toHaveBeenCalledWith(
SidebarSettingsFlags.Highlights,
HighlightsPlacement.Pinned,
);
});
await waitFor(() => {
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['feed', 'main'],
});
});
expect(mockDisplayToast).toHaveBeenCalledWith(
'Happening Now placement preference applied to all your feeds',
);
});

it('should skip feed invalidation when no active feed query key is set', async () => {
mockUseActiveFeedContext.mockReturnValue({ items: [] });

renderComponent();

fireEvent.click(screen.getByRole('button', { name: 'Pin to top' }));

await waitFor(() => {
expect(mockUpdateFlag).toHaveBeenCalled();
});
expect(mockInvalidateQueries).not.toHaveBeenCalled();
});

it('should unpin by setting placement back to Default', async () => {
mockUseSettingsContext.mockReturnValue({
flags: { highlightsPlacement: HighlightsPlacement.Pinned },
updateFlag: mockUpdateFlag,
});

renderComponent();

fireEvent.click(screen.getByRole('button', { name: 'Unpin from top' }));

await waitFor(() => {
expect(mockUpdateFlag).toHaveBeenCalledWith(
SidebarSettingsFlags.Highlights,
HighlightsPlacement.Default,
);
});
});

it('should disable the card by setting placement to Disabled', async () => {
renderComponent();

fireEvent.click(screen.getByRole('button', { name: 'Disable' }));

await waitFor(() => {
expect(mockUpdateFlag).toHaveBeenCalledWith(
SidebarSettingsFlags.Highlights,
HighlightsPlacement.Disabled,
);
});
});

it('should subscribe and show toast with settings action when not subscribed', async () => {
renderComponent();

Expand Down
126 changes: 103 additions & 23 deletions packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import type { ReactElement } from 'react';
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import { useQueryClient } from '@tanstack/react-query';
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { BellAddIcon, BellSubscribedIcon } from '../../icons';
import { Tooltip } from '../../tooltip/Tooltip';
import {
BellAddIcon,
BellSubscribedIcon,
EyeCancelIcon,
MenuIcon as KebabIcon,
PinIcon,
} from '../../icons';
import { MenuIcon } from '../../MenuIcon';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuOptions,
DropdownMenuTrigger,
} from '../../dropdown/DropdownMenu';
import type { MenuItemProps } from '../../dropdown/common';
import { useActiveFeedContext } from '../../../contexts/ActiveFeedContext';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useSettingsContext } from '../../../contexts/SettingsContext';
import { useMajorHeadlinesSubscription } from '../../../hooks/notifications/useMajorHeadlinesSubscription';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import { featureMajorHeadlinesPush } from '../../../lib/featureManagement';
import { useToastNotification } from '../../../hooks/useToastNotification';
import { useLogContext } from '../../../contexts/LogContext';
import {
HighlightsPlacement,
SidebarSettingsFlags,
} from '../../../graphql/settings';
import { LogEvent, Origin } from '../../../lib/log';
import { labels } from '../../../lib';

const NOTIFICATION_SETTINGS_PATH = '/settings/notifications';

Expand All @@ -23,10 +46,40 @@ const HighlightCardOptionsContent = ({
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const { displayToast } = useToastNotification();
const { logEvent } = useLogContext();
const { flags, updateFlag } = useSettingsContext();
const queryClient = useQueryClient();
const { queryKey: feedQueryKey } = useActiveFeedContext();
const { isSubscribed, isLoading, subscribe, unsubscribe } =
useMajorHeadlinesSubscription();

const handleToggle = async () => {
const placement = flags?.highlightsPlacement ?? HighlightsPlacement.Default;
const isPinned = placement === HighlightsPlacement.Pinned;

const updatePlacement = async (next: HighlightsPlacement) => {
if (isPending) {
return;
}
setIsPending(true);
try {
await updateFlag(SidebarSettingsFlags.Highlights, next);
if (feedQueryKey) {
await queryClient.invalidateQueries({ queryKey: feedQueryKey });
}
displayToast(
labels.feed.settings.globalPreferenceNotice.highlightsPlacement,
);
logEvent({
event_name: LogEvent.SetHighlightsPlacement,
target_id: next,
extra: JSON.stringify({ origin: Origin.FeedCard }),
});
} finally {
setIsPending(false);
}
};

const toggleSubscription = async () => {
if (isPending || isLoading) {
return;
}
Expand All @@ -49,27 +102,54 @@ const HighlightCardOptionsContent = ({
}
};

const label = isSubscribed
? 'Turn off real-time alerts'
: 'Get real-time alerts';
const Icon = isSubscribed ? BellSubscribedIcon : BellAddIcon;
const options = useMemo<MenuItemProps[]>(() => {
const SubscribeIcon = isSubscribed ? BellSubscribedIcon : BellAddIcon;
return [
{
label: isPinned ? 'Unpin from top' : 'Pin to top',
icon: <MenuIcon Icon={PinIcon} />,
action: () =>
updatePlacement(
isPinned ? HighlightsPlacement.Default : HighlightsPlacement.Pinned,
),
disabled: isPending,
},
{
label: 'Disable',
icon: <MenuIcon Icon={EyeCancelIcon} />,
action: () => updatePlacement(HighlightsPlacement.Disabled),
disabled: isPending,
},
{
label: isSubscribed
? 'Turn off real-time alerts'
: 'Get real-time alerts',
icon: <MenuIcon Icon={SubscribeIcon} />,
action: toggleSubscription,
disabled: isPending || isLoading,
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPinned, isSubscribed, isPending, isLoading]);

return (
<Tooltip content={label}>
<Button
type="button"
variant={ButtonVariant.Tertiary}
size={ButtonSize.Small}
icon={<Icon />}
className={classNames(
'invisible my-auto group-hover:visible',
className,
)}
aria-label={label}
onClick={handleToggle}
disabled={isPending || isLoading}
/>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger tooltip={{ content: 'Options' }} asChild>
<Button
type="button"
variant={ButtonVariant.Tertiary}
size={ButtonSize.Small}
icon={<KebabIcon />}
className={classNames(
'invisible z-1 my-auto group-hover:visible',
className,
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuOptions options={options} />
</DropdownMenuContent>
</DropdownMenu>
);
};

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export enum Origin {
PostContent = 'post content',
History = 'history',
FeedbackCard = 'feedback card',
FeedCard = 'feed card',
InitializeRegistrationFlow = 'initialize registration flow',
Onboarding = 'onboarding',
ManageTag = 'manage_tag',
Expand Down
Loading