Skip to content

Commit 630d363

Browse files
authored
Merge pull request Expensify#65390 from linhvovan29546/fix/65325-onboarding-dropdown-button
fix: replace dropdown menu when only one option
2 parents 588ef3b + 39a34b7 commit 630d363

4 files changed

Lines changed: 281 additions & 5 deletions

File tree

src/components/ButtonWithDropdownMenu/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function ButtonWithDropdownMenu<IValueType>({
5151
secondLineText = '',
5252
icon,
5353
shouldUseModalPaddingStyle = true,
54+
shouldUseOptionIcon = false,
5455
}: ButtonWithDropdownMenuProps<IValueType>) {
5556
const theme = useTheme();
5657
const styles = useThemeStyles();
@@ -72,6 +73,7 @@ function ButtonWithDropdownMenu<IValueType>({
7273
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
7374
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
7475
const nullCheckRef = (ref: RefObject<View | null>) => ref ?? null;
76+
const shouldShowRightIcon = !!options.at(0)?.shouldShowRightIcon;
7577

7678
useEffect(() => {
7779
if (!dropdownAnchor.current) {
@@ -210,10 +212,13 @@ function ButtonWithDropdownMenu<IValueType>({
210212
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
211213
medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
212214
small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL}
213-
innerStyles={[innerStyleDropButton]}
215+
innerStyles={[innerStyleDropButton, shouldShowRightIcon && styles.dropDownButtonCartIconView]}
216+
iconRightStyles={shouldShowRightIcon && styles.ml2}
214217
enterKeyEventListenerPriority={enterKeyEventListenerPriority}
215218
secondLineText={secondLineText}
216-
icon={icon}
219+
icon={shouldUseOptionIcon && !shouldShowRightIcon ? options.at(0)?.icon : icon}
220+
iconRight={shouldShowRightIcon ? options.at(0)?.icon : undefined}
221+
shouldShowRightIcon={shouldShowRightIcon}
217222
/>
218223
)}
219224
{(shouldAlwaysShowDropdownMenu || options.length > 1) && !!popoverAnchorPosition && (
@@ -240,6 +245,7 @@ function ButtonWithDropdownMenu<IValueType>({
240245
headerText={menuHeaderText}
241246
menuItems={options.map((item, index) => ({
242247
...item,
248+
shouldShowRightIcon: undefined,
243249
onSelected: item.onSelected
244250
? () => item.onSelected?.()
245251
: () => {

src/components/ButtonWithDropdownMenu/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type DropdownOption<TValueType> = {
2525
value: TValueType;
2626
text: string;
2727
icon?: IconAsset;
28+
shouldShowRightIcon?: boolean;
2829
iconWidth?: number;
2930
iconHeight?: number;
3031
iconDescription?: string;
@@ -142,6 +143,9 @@ type ButtonWithDropdownMenuProps<TValueType> = {
142143

143144
/** Whether to use modal padding style for the popover menu */
144145
shouldUseModalPaddingStyle?: boolean;
146+
147+
/** Whether to display the option icon when only one option is available */
148+
shouldUseOptionIcon?: boolean;
145149
};
146150

147151
export type {

src/components/OnboardingHelpDropdownButton.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {addMinutes} from 'date-fns';
2-
import noop from 'lodash/noop';
32
import React from 'react';
43
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
54
import useLocalize from '@hooks/useLocalize';
@@ -106,6 +105,7 @@ function OnboardingHelpDropdownButton({reportID, shouldUseNarrowLayout, shouldSh
106105
options.push({
107106
text: translate('getAssistancePage.registerForWebinar'),
108107
icon: Monitor,
108+
shouldShowRightIcon: true,
109109
value: CONST.ONBOARDING_HELP.REGISTER_FOR_WEBINAR,
110110
onSelected: () => {
111111
openExternalLink(CONST.REGISTER_FOR_WEBINAR_URL);
@@ -119,12 +119,15 @@ function OnboardingHelpDropdownButton({reportID, shouldUseNarrowLayout, shouldSh
119119

120120
return (
121121
<ButtonWithDropdownMenu
122-
onPress={noop}
123-
shouldAlwaysShowDropdownMenu
122+
onPress={(_event, value) => {
123+
const option = options.find((opt) => opt.value === value);
124+
option?.onSelected?.();
125+
}}
124126
pressOnEnter
125127
success={!!hasActiveScheduledCall}
126128
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
127129
options={options}
130+
shouldUseOptionIcon
128131
isSplitButton={false}
129132
customText={hasActiveScheduledCall ? translate('scheduledCall.callScheduled') : translate('getAssistancePage.onboardingHelp')}
130133
wrapperStyle={shouldUseNarrowLayout && styles.earlyDiscountButton}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import {fireEvent, render, screen} from '@testing-library/react-native';
2+
import React from 'react';
3+
import Onyx from 'react-native-onyx';
4+
import ComposeProviders from '@components/ComposeProviders';
5+
import {LocaleContextProvider} from '@components/LocaleContextProvider';
6+
import OnboardingHelpDropdownButton from '@components/OnboardingHelpDropdownButton';
7+
import OnyxProvider from '@components/OnyxProvider';
8+
import {openExternalLink} from '@libs/actions/Link';
9+
import {cancelBooking, clearBookingDraft, rescheduleBooking} from '@libs/actions/ScheduleCall';
10+
import Navigation from '@libs/Navigation/Navigation';
11+
import CONST from '@src/CONST';
12+
import ONYXKEYS from '@src/ONYXKEYS';
13+
import ROUTES from '@src/ROUTES';
14+
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
15+
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';
16+
17+
// Mock the dependencies
18+
jest.mock('@libs/actions/Link', () => ({
19+
openExternalLink: jest.fn(),
20+
}));
21+
jest.mock('@libs/Navigation/Navigation', () => ({
22+
navigate: jest.fn(),
23+
}));
24+
jest.mock('@libs/actions/ScheduleCall', () => ({
25+
clearBookingDraft: jest.fn(),
26+
rescheduleBooking: jest.fn(),
27+
cancelBooking: jest.fn(),
28+
}));
29+
30+
const mockOpenExternalLink = jest.mocked(openExternalLink);
31+
const mockNavigate = jest.mocked(Navigation.navigate);
32+
const mockClearBookingDraft = jest.mocked(clearBookingDraft);
33+
const mockRescheduleBooking = jest.mocked(rescheduleBooking);
34+
const mockCancelBooking = jest.mocked(cancelBooking);
35+
36+
// Helper function to create mock events for PopoverMenuItem fireEvent.press
37+
function createMockPressEvent(target: unknown) {
38+
return {
39+
nativeEvent: {},
40+
type: 'press',
41+
target,
42+
currentTarget: target,
43+
};
44+
}
45+
46+
// Helper function to render OnboardingHelpDropdownButton with props
47+
function renderOnboardingHelpDropdownButton(props: {
48+
reportID: string;
49+
shouldUseNarrowLayout: boolean;
50+
shouldShowRegisterForWebinar: boolean;
51+
shouldShowGuideBooking: boolean;
52+
hasActiveScheduledCall: boolean;
53+
}) {
54+
return render(
55+
<ComposeProviders components={[OnyxProvider, LocaleContextProvider]}>
56+
<OnboardingHelpDropdownButton
57+
reportID={props.reportID}
58+
shouldUseNarrowLayout={props.shouldUseNarrowLayout}
59+
shouldShowRegisterForWebinar={props.shouldShowRegisterForWebinar}
60+
shouldShowGuideBooking={props.shouldShowGuideBooking}
61+
hasActiveScheduledCall={props.hasActiveScheduledCall}
62+
/>
63+
</ComposeProviders>,
64+
);
65+
}
66+
67+
const mockScheduledCall = {
68+
eventTime: '2025-07-05 10:00:00',
69+
id: 'call-id-123',
70+
status: CONST.SCHEDULE_CALL_STATUS.CREATED,
71+
host: 123,
72+
eventURI: 'test-uri',
73+
inserted: '2025-07-04 09:00:00',
74+
};
75+
const currentUserAccountID = 1;
76+
77+
describe('OnboardingHelpDropdownButton', () => {
78+
beforeAll(() => {
79+
Onyx.init({
80+
keys: ONYXKEYS,
81+
});
82+
});
83+
84+
beforeEach(() => {
85+
Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID});
86+
return waitForBatchedUpdates();
87+
});
88+
89+
afterEach(() => {
90+
jest.clearAllMocks();
91+
Onyx.clear();
92+
return waitForBatchedUpdates();
93+
});
94+
95+
it('should display the schedule call option when guide booking is enabled', async () => {
96+
// Given component configured to show schedule call option only
97+
const props = {
98+
reportID: '1',
99+
shouldUseNarrowLayout: false,
100+
shouldShowRegisterForWebinar: false,
101+
shouldShowGuideBooking: true,
102+
hasActiveScheduledCall: false,
103+
};
104+
105+
// When component is rendered
106+
renderOnboardingHelpDropdownButton(props);
107+
await waitForBatchedUpdatesWithAct();
108+
109+
// Then only schedule call option is visible
110+
const scheduleCallOption = screen.getByText('getAssistancePage.scheduleACall');
111+
expect(scheduleCallOption).toBeOnTheScreen();
112+
expect(screen.queryByText('getAssistancePage.registerForWebinar')).not.toBeOnTheScreen();
113+
expect(screen.queryByText('common.reschedule')).not.toBeOnTheScreen();
114+
expect(screen.queryByText('common.cancel')).not.toBeOnTheScreen();
115+
116+
// When schedule call option is pressed
117+
fireEvent.press(scheduleCallOption);
118+
119+
// Then booking draft is cleared and navigation occurs
120+
expect(mockClearBookingDraft).toHaveBeenCalledTimes(1);
121+
expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SCHEDULE_CALL_BOOK.getRoute(props.reportID));
122+
});
123+
124+
it('should only display the registerForWebinar option when webinar is enabled', async () => {
125+
// Given component configured to display the registerForWebinar
126+
const props = {
127+
reportID: '1',
128+
shouldUseNarrowLayout: false,
129+
shouldShowRegisterForWebinar: true,
130+
shouldShowGuideBooking: false,
131+
hasActiveScheduledCall: false,
132+
};
133+
134+
// When component is rendered
135+
renderOnboardingHelpDropdownButton(props);
136+
await waitForBatchedUpdatesWithAct();
137+
138+
// Then only webinar registration option is visible
139+
const registerOption = screen.getByText('getAssistancePage.registerForWebinar');
140+
expect(registerOption).toBeOnTheScreen();
141+
expect(screen.queryByText('getAssistancePage.scheduleACall')).not.toBeOnTheScreen();
142+
expect(screen.queryByText('common.reschedule')).not.toBeOnTheScreen();
143+
expect(screen.queryByText('common.cancel')).not.toBeOnTheScreen();
144+
145+
// When webinar registration option is pressed
146+
fireEvent.press(registerOption);
147+
148+
// Then webinar registration URL is opened
149+
expect(mockOpenExternalLink).toHaveBeenCalledTimes(1);
150+
expect(mockOpenExternalLink).toHaveBeenCalledWith(CONST.REGISTER_FOR_WEBINAR_URL);
151+
});
152+
153+
it('should display dropdown menu with all options when user has active scheduled call', async () => {
154+
// Given component configured with active scheduled call and webinar registration
155+
const props = {
156+
reportID: '1',
157+
shouldUseNarrowLayout: false,
158+
shouldShowRegisterForWebinar: true,
159+
shouldShowGuideBooking: false,
160+
hasActiveScheduledCall: true,
161+
};
162+
// Given scheduled call data exists in Onyx
163+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${props.reportID}`, {
164+
calendlyCalls: [mockScheduledCall],
165+
});
166+
167+
// When component is rendered
168+
renderOnboardingHelpDropdownButton(props);
169+
await waitForBatchedUpdatesWithAct();
170+
171+
// Then dropdown button displays "Call scheduled" text
172+
const dropdownButton = screen.getByText('scheduledCall.callScheduled');
173+
expect(dropdownButton).toBeOnTheScreen();
174+
175+
// When dropdown menu is opened
176+
fireEvent.press(dropdownButton);
177+
await waitForBatchedUpdatesWithAct();
178+
179+
// Then all expected menu options are present
180+
expect(screen.getByText('common.reschedule')).toBeOnTheScreen();
181+
expect(screen.getByText('common.cancel')).toBeOnTheScreen();
182+
expect(screen.getByText('getAssistancePage.registerForWebinar')).toBeOnTheScreen();
183+
expect(screen.queryByText('getAssistancePage.scheduleACall')).not.toBeOnTheScreen();
184+
});
185+
186+
describe('dropdown actions with active scheduled call', () => {
187+
// Given component configured with active scheduled call and webinar registration enabled
188+
const props = {
189+
reportID: '1',
190+
shouldUseNarrowLayout: false,
191+
shouldShowRegisterForWebinar: true,
192+
shouldShowGuideBooking: false,
193+
hasActiveScheduledCall: true,
194+
};
195+
beforeEach(() => {
196+
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${props.reportID}`, {
197+
calendlyCalls: [mockScheduledCall],
198+
});
199+
return waitForBatchedUpdates();
200+
});
201+
it('should open webinar registration URL when webinar option is pressed', async () => {
202+
// Given scheduled call data exists in Onyx
203+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${props.reportID}`, {
204+
calendlyCalls: [mockScheduledCall],
205+
});
206+
207+
// When component is rendered and dropdown is opened
208+
renderOnboardingHelpDropdownButton(props);
209+
await waitForBatchedUpdatesWithAct();
210+
211+
const dropdownButton = screen.getByText('scheduledCall.callScheduled');
212+
fireEvent.press(dropdownButton);
213+
await waitForBatchedUpdatesWithAct();
214+
215+
// When webinar menu item is pressed
216+
const webinarMenuItem = screen.getByText('getAssistancePage.registerForWebinar');
217+
fireEvent.press(webinarMenuItem, createMockPressEvent(webinarMenuItem));
218+
await waitForBatchedUpdatesWithAct();
219+
220+
// Then webinar registration URL is opened
221+
expect(mockOpenExternalLink).toHaveBeenCalledTimes(1);
222+
expect(mockOpenExternalLink).toHaveBeenCalledWith(CONST.REGISTER_FOR_WEBINAR_URL);
223+
});
224+
225+
it('should call reschedule booking when reschedule option is pressed', async () => {
226+
// When component is rendered and dropdown is opened
227+
renderOnboardingHelpDropdownButton(props);
228+
await waitForBatchedUpdatesWithAct();
229+
230+
const dropdownButton = screen.getByText('scheduledCall.callScheduled');
231+
fireEvent.press(dropdownButton);
232+
await waitForBatchedUpdatesWithAct();
233+
234+
// When reschedule option is pressed
235+
const rescheduleMenuItem = screen.getByText('common.reschedule');
236+
fireEvent.press(rescheduleMenuItem, createMockPressEvent(rescheduleMenuItem));
237+
await waitForBatchedUpdatesWithAct();
238+
239+
// Then reschedule booking action is called with scheduled call data
240+
expect(mockRescheduleBooking).toHaveBeenCalledTimes(1);
241+
expect(mockRescheduleBooking).toHaveBeenCalledWith(mockScheduledCall);
242+
});
243+
244+
it('should call cancel booking when cancel option is pressed', async () => {
245+
// When component is rendered and dropdown is opened
246+
renderOnboardingHelpDropdownButton(props);
247+
await waitForBatchedUpdatesWithAct();
248+
249+
const dropdownButton = screen.getByText('scheduledCall.callScheduled');
250+
fireEvent.press(dropdownButton);
251+
await waitForBatchedUpdatesWithAct();
252+
253+
// When cancel option is pressed
254+
const cancelMenuItem = screen.getByText('common.cancel');
255+
fireEvent.press(cancelMenuItem, createMockPressEvent(cancelMenuItem));
256+
await waitForBatchedUpdatesWithAct();
257+
258+
// Then cancel booking action is called with scheduled call data
259+
expect(mockCancelBooking).toHaveBeenCalledTimes(1);
260+
expect(mockCancelBooking).toHaveBeenCalledWith(mockScheduledCall);
261+
});
262+
});
263+
});

0 commit comments

Comments
 (0)