Skip to content

Commit b808499

Browse files
feat: menu on happening now card (#6086)
1 parent e1cff1e commit b808499

3 files changed

Lines changed: 227 additions & 26 deletions

File tree

packages/shared/src/components/cards/highlight/HighlightCardOptions.spec.tsx

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import React from 'react';
22
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import { HighlightCardOptions } from './HighlightCardOptions';
4+
import type { MenuItemProps } from '../../dropdown/common';
5+
import {
6+
HighlightsPlacement,
7+
SidebarSettingsFlags,
8+
} from '../../../graphql/settings';
49

510
const mockSubscribe = jest.fn().mockResolvedValue(undefined);
611
const mockUnsubscribe = jest.fn().mockResolvedValue(undefined);
@@ -9,15 +14,37 @@ const mockUseAuth = jest.fn();
914
const mockUseConditionalFeature = jest.fn();
1015
const mockUseMajorHeadlinesSubscription = jest.fn();
1116
const mockRouterPush = jest.fn();
17+
const mockUpdateFlag = jest.fn().mockResolvedValue(undefined);
18+
const mockUseSettingsContext = jest.fn();
19+
const mockLogEvent = jest.fn();
20+
const mockInvalidateQueries = jest.fn().mockResolvedValue(undefined);
21+
const mockUseActiveFeedContext = jest.fn();
1222

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

27+
jest.mock('@tanstack/react-query', () => ({
28+
...(jest.requireActual('@tanstack/react-query') as Iterable<unknown>),
29+
useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
30+
}));
31+
32+
jest.mock('../../../contexts/ActiveFeedContext', () => ({
33+
useActiveFeedContext: () => mockUseActiveFeedContext(),
34+
}));
35+
1736
jest.mock('../../../contexts/AuthContext', () => ({
1837
useAuthContext: () => mockUseAuth(),
1938
}));
2039

40+
jest.mock('../../../contexts/SettingsContext', () => ({
41+
useSettingsContext: () => mockUseSettingsContext(),
42+
}));
43+
44+
jest.mock('../../../contexts/LogContext', () => ({
45+
useLogContext: () => ({ logEvent: mockLogEvent }),
46+
}));
47+
2148
jest.mock('../../../hooks/useConditionalFeature', () => ({
2249
useConditionalFeature: () => mockUseConditionalFeature(),
2350
}));
@@ -30,8 +57,24 @@ jest.mock('../../../hooks/useToastNotification', () => ({
3057
useToastNotification: () => ({ displayToast: mockDisplayToast }),
3158
}));
3259

33-
jest.mock('../../tooltip/Tooltip', () => ({
34-
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
60+
jest.mock('../../dropdown/DropdownMenu', () => ({
61+
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
62+
<div>{children}</div>
63+
),
64+
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
65+
children,
66+
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
67+
<div>{children}</div>
68+
),
69+
DropdownMenuOptions: ({ options }: { options: MenuItemProps[] }) => (
70+
<div>
71+
{options.map(({ label, action, disabled }) => (
72+
<button key={label} type="button" onClick={action} disabled={disabled}>
73+
{label}
74+
</button>
75+
))}
76+
</div>
77+
),
3578
}));
3679

3780
const renderComponent = () => render(<HighlightCardOptions />);
@@ -47,11 +90,23 @@ describe('HighlightCardOptions', () => {
4790
subscribe: mockSubscribe,
4891
unsubscribe: mockUnsubscribe,
4992
});
93+
mockUseSettingsContext.mockReturnValue({
94+
flags: { highlightsPlacement: HighlightsPlacement.Default },
95+
updateFlag: mockUpdateFlag,
96+
});
97+
mockUseActiveFeedContext.mockReturnValue({
98+
queryKey: ['feed', 'main'],
99+
items: [],
100+
});
50101
});
51102

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

106+
expect(
107+
screen.getByRole('button', { name: 'Pin to top' }),
108+
).toBeInTheDocument();
109+
expect(screen.getByRole('button', { name: 'Disable' })).toBeInTheDocument();
55110
expect(
56111
screen.getByRole('button', { name: 'Get real-time alerts' }),
57112
).toBeInTheDocument();
@@ -77,6 +132,71 @@ describe('HighlightCardOptions', () => {
77132
).not.toBeInTheDocument();
78133
});
79134

135+
it('should pin to top by updating the placement flag and invalidating the feed', async () => {
136+
renderComponent();
137+
138+
fireEvent.click(screen.getByRole('button', { name: 'Pin to top' }));
139+
140+
await waitFor(() => {
141+
expect(mockUpdateFlag).toHaveBeenCalledWith(
142+
SidebarSettingsFlags.Highlights,
143+
HighlightsPlacement.Pinned,
144+
);
145+
});
146+
await waitFor(() => {
147+
expect(mockInvalidateQueries).toHaveBeenCalledWith({
148+
queryKey: ['feed', 'main'],
149+
});
150+
});
151+
expect(mockDisplayToast).toHaveBeenCalledWith(
152+
'Happening Now placement preference applied to all your feeds',
153+
);
154+
});
155+
156+
it('should skip feed invalidation when no active feed query key is set', async () => {
157+
mockUseActiveFeedContext.mockReturnValue({ items: [] });
158+
159+
renderComponent();
160+
161+
fireEvent.click(screen.getByRole('button', { name: 'Pin to top' }));
162+
163+
await waitFor(() => {
164+
expect(mockUpdateFlag).toHaveBeenCalled();
165+
});
166+
expect(mockInvalidateQueries).not.toHaveBeenCalled();
167+
});
168+
169+
it('should unpin by setting placement back to Default', async () => {
170+
mockUseSettingsContext.mockReturnValue({
171+
flags: { highlightsPlacement: HighlightsPlacement.Pinned },
172+
updateFlag: mockUpdateFlag,
173+
});
174+
175+
renderComponent();
176+
177+
fireEvent.click(screen.getByRole('button', { name: 'Unpin from top' }));
178+
179+
await waitFor(() => {
180+
expect(mockUpdateFlag).toHaveBeenCalledWith(
181+
SidebarSettingsFlags.Highlights,
182+
HighlightsPlacement.Default,
183+
);
184+
});
185+
});
186+
187+
it('should disable the card by setting placement to Disabled', async () => {
188+
renderComponent();
189+
190+
fireEvent.click(screen.getByRole('button', { name: 'Disable' }));
191+
192+
await waitFor(() => {
193+
expect(mockUpdateFlag).toHaveBeenCalledWith(
194+
SidebarSettingsFlags.Highlights,
195+
HighlightsPlacement.Disabled,
196+
);
197+
});
198+
});
199+
80200
it('should subscribe and show toast with settings action when not subscribed', async () => {
81201
renderComponent();
82202

packages/shared/src/components/cards/highlight/HighlightCardOptions.tsx

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
import type { ReactElement } from 'react';
2-
import React, { useState } from 'react';
2+
import React, { useMemo, useState } from 'react';
33
import classNames from 'classnames';
44
import { useRouter } from 'next/router';
5+
import { useQueryClient } from '@tanstack/react-query';
56
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
6-
import { BellAddIcon, BellSubscribedIcon } from '../../icons';
7-
import { Tooltip } from '../../tooltip/Tooltip';
7+
import {
8+
BellAddIcon,
9+
BellSubscribedIcon,
10+
EyeCancelIcon,
11+
MenuIcon as KebabIcon,
12+
PinIcon,
13+
} from '../../icons';
14+
import { MenuIcon } from '../../MenuIcon';
15+
import {
16+
DropdownMenu,
17+
DropdownMenuContent,
18+
DropdownMenuOptions,
19+
DropdownMenuTrigger,
20+
} from '../../dropdown/DropdownMenu';
21+
import type { MenuItemProps } from '../../dropdown/common';
22+
import { useActiveFeedContext } from '../../../contexts/ActiveFeedContext';
823
import { useAuthContext } from '../../../contexts/AuthContext';
24+
import { useSettingsContext } from '../../../contexts/SettingsContext';
925
import { useMajorHeadlinesSubscription } from '../../../hooks/notifications/useMajorHeadlinesSubscription';
1026
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
1127
import { featureMajorHeadlinesPush } from '../../../lib/featureManagement';
1228
import { useToastNotification } from '../../../hooks/useToastNotification';
29+
import { useLogContext } from '../../../contexts/LogContext';
30+
import {
31+
HighlightsPlacement,
32+
SidebarSettingsFlags,
33+
} from '../../../graphql/settings';
34+
import { LogEvent, Origin } from '../../../lib/log';
35+
import { labels } from '../../../lib';
1336

1437
const NOTIFICATION_SETTINGS_PATH = '/settings/notifications';
1538

@@ -23,10 +46,40 @@ const HighlightCardOptionsContent = ({
2346
const router = useRouter();
2447
const [isPending, setIsPending] = useState(false);
2548
const { displayToast } = useToastNotification();
49+
const { logEvent } = useLogContext();
50+
const { flags, updateFlag } = useSettingsContext();
51+
const queryClient = useQueryClient();
52+
const { queryKey: feedQueryKey } = useActiveFeedContext();
2653
const { isSubscribed, isLoading, subscribe, unsubscribe } =
2754
useMajorHeadlinesSubscription();
2855

29-
const handleToggle = async () => {
56+
const placement = flags?.highlightsPlacement ?? HighlightsPlacement.Default;
57+
const isPinned = placement === HighlightsPlacement.Pinned;
58+
59+
const updatePlacement = async (next: HighlightsPlacement) => {
60+
if (isPending) {
61+
return;
62+
}
63+
setIsPending(true);
64+
try {
65+
await updateFlag(SidebarSettingsFlags.Highlights, next);
66+
if (feedQueryKey) {
67+
await queryClient.invalidateQueries({ queryKey: feedQueryKey });
68+
}
69+
displayToast(
70+
labels.feed.settings.globalPreferenceNotice.highlightsPlacement,
71+
);
72+
logEvent({
73+
event_name: LogEvent.SetHighlightsPlacement,
74+
target_id: next,
75+
extra: JSON.stringify({ origin: Origin.FeedCard }),
76+
});
77+
} finally {
78+
setIsPending(false);
79+
}
80+
};
81+
82+
const toggleSubscription = async () => {
3083
if (isPending || isLoading) {
3184
return;
3285
}
@@ -49,27 +102,54 @@ const HighlightCardOptionsContent = ({
49102
}
50103
};
51104

52-
const label = isSubscribed
53-
? 'Turn off real-time alerts'
54-
: 'Get real-time alerts';
55-
const Icon = isSubscribed ? BellSubscribedIcon : BellAddIcon;
105+
const options = useMemo<MenuItemProps[]>(() => {
106+
const SubscribeIcon = isSubscribed ? BellSubscribedIcon : BellAddIcon;
107+
return [
108+
{
109+
label: isPinned ? 'Unpin from top' : 'Pin to top',
110+
icon: <MenuIcon Icon={PinIcon} />,
111+
action: () =>
112+
updatePlacement(
113+
isPinned ? HighlightsPlacement.Default : HighlightsPlacement.Pinned,
114+
),
115+
disabled: isPending,
116+
},
117+
{
118+
label: 'Disable',
119+
icon: <MenuIcon Icon={EyeCancelIcon} />,
120+
action: () => updatePlacement(HighlightsPlacement.Disabled),
121+
disabled: isPending,
122+
},
123+
{
124+
label: isSubscribed
125+
? 'Turn off real-time alerts'
126+
: 'Get real-time alerts',
127+
icon: <MenuIcon Icon={SubscribeIcon} />,
128+
action: toggleSubscription,
129+
disabled: isPending || isLoading,
130+
},
131+
];
132+
// eslint-disable-next-line react-hooks/exhaustive-deps
133+
}, [isPinned, isSubscribed, isPending, isLoading]);
56134

57135
return (
58-
<Tooltip content={label}>
59-
<Button
60-
type="button"
61-
variant={ButtonVariant.Tertiary}
62-
size={ButtonSize.Small}
63-
icon={<Icon />}
64-
className={classNames(
65-
'invisible my-auto group-hover:visible',
66-
className,
67-
)}
68-
aria-label={label}
69-
onClick={handleToggle}
70-
disabled={isPending || isLoading}
71-
/>
72-
</Tooltip>
136+
<DropdownMenu>
137+
<DropdownMenuTrigger tooltip={{ content: 'Options' }} asChild>
138+
<Button
139+
type="button"
140+
variant={ButtonVariant.Tertiary}
141+
size={ButtonSize.Small}
142+
icon={<KebabIcon />}
143+
className={classNames(
144+
'invisible z-1 my-auto group-hover:visible',
145+
className,
146+
)}
147+
/>
148+
</DropdownMenuTrigger>
149+
<DropdownMenuContent>
150+
<DropdownMenuOptions options={options} />
151+
</DropdownMenuContent>
152+
</DropdownMenu>
73153
);
74154
};
75155

packages/shared/src/lib/log.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export enum Origin {
5050
PostContent = 'post content',
5151
History = 'history',
5252
FeedbackCard = 'feedback card',
53+
FeedCard = 'feed card',
5354
InitializeRegistrationFlow = 'initialize registration flow',
5455
Onboarding = 'onboarding',
5556
ManageTag = 'manage_tag',

0 commit comments

Comments
 (0)