diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 55ed0e80ebd5..ee14d99770a8 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -8,6 +8,7 @@ const PROTECTED_SCREENS = { HOME: 'Home', INBOX: 'Inbox', CONCIERGE: 'Concierge', + TALK_TO_HUMAN: 'TalkToHuman', REPORT_ATTACHMENTS: 'ReportAttachments', REPORT_ADD_ATTACHMENT: 'ReportAddAttachment', TRACK_EXPENSE: 'TrackExpense', diff --git a/src/hooks/useOpenSupportTicket.ts b/src/hooks/useOpenSupportTicket.ts new file mode 100644 index 000000000000..fb9e815abd84 --- /dev/null +++ b/src/hooks/useOpenSupportTicket.ts @@ -0,0 +1,63 @@ +import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/Report'; +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; +import useOpenConciergeAnywhere from './useOpenConciergeAnywhere'; + +/** + * Returns a callback that opens a support ticket, falling back to Concierge when no support rep is free. + * Shared by the Help page row and the Talk to a human route so both behave the same. + */ +function useOpenSupportTicket() { + const {translate} = useLocalize(); + const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + + const openConcierge = () => { + // The customer is already in the Concierge chat, so opening it again would show it twice. + if (conciergeReportID && Navigation.getFocusedReportId() === conciergeReportID) { + return; + } + openConciergeAnywhere({forceConcierge: true}); + }; + + const openSupportTicketOrConcierge = () => { + const request = openSupportTicket(); + + // openSupportTicket opens the pending ticket before it resolves, so this is that screen's own route. + const pendingTicketRoute = Navigation.getActiveRoute(); + + // A ticket that was never created leaves its screen on a missing report. Leaving any other screen would pop + // the page the customer is on instead. + const dismissPendingTicket = (afterTransition?: () => void) => { + if (Navigation.getActiveRoute() !== pendingTicketRoute) { + afterTransition?.(); + return; + } + Navigation.goBack(undefined, {afterTransition}); + }; + + request + .then((response) => { + if (isNoSupportRepAvailableResponse(response)) { + Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); + dismissPendingTicket(openConcierge); + return; + } + + if (response?.jsonCode !== undefined && response.jsonCode !== CONST.JSON_CODE.SUCCESS) { + dismissPendingTicket(); + } + }) + .catch(() => dismissPendingTicket()); + }; + + return {openSupportTicketOrConcierge}; +} + +export default useOpenSupportTicket; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 3fa3462768a5..b2be2c3a6d05 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -86,6 +86,7 @@ const loadAttachmentModalScreen = () => require('../../../ const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default; const loadConciergePage = () => require('../../../pages/ConciergePage').default; +const loadTalkToHumanPage = () => require('../../../pages/TalkToHumanPage').default; const loadTrackExpensePage = () => require('../../../pages/TrackExpensePage').default; const loadSubmitExpensePage = () => require('../../../pages/SubmitExpensePage').default; const loadPreMountBufferPage = () => require('../../../pages/PreMountBufferPage').default; @@ -226,6 +227,11 @@ function AuthScreens() { options={defaultScreenOptions} getComponent={loadConciergePage} /> + ['config'] = { [SCREENS.CONNECTION_COMPLETE]: ROUTES.CONNECTION_COMPLETE, [SCREENS.BANK_CONNECTION_COMPLETE]: ROUTES.BANK_CONNECTION_COMPLETE, [SCREENS.CONCIERGE]: ROUTES.CONCIERGE, + [SCREENS.TALK_TO_HUMAN]: ROUTES.TALK_TO_HUMAN, [SCREENS.TRACK_EXPENSE]: ROUTES.TRACK_EXPENSE, [SCREENS.SUBMIT_EXPENSE]: ROUTES.SUBMIT_EXPENSE, [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN, @@ -2427,8 +2428,6 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS.HELP]: { path: ROUTES.SETTINGS_HELP, - // The Talk to a human link in Concierge replies lands on the Help page, where the Talk to a human row is. - alias: [{path: ROUTES.TALK_TO_HUMAN, exact: true}], exact: true, }, [SCREENS.SETTINGS.TROUBLESHOOT]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index acfab0524a36..cc10b53f170b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1478,6 +1478,7 @@ type AttachmentModalScreensParamList = { type AuthScreensParamList = SharedScreensParamList & AttachmentModalScreensParamList & { [SCREENS.CONCIERGE]: undefined; + [SCREENS.TALK_TO_HUMAN]: undefined; [SCREENS.TRACK_EXPENSE]: undefined; [SCREENS.SUBMIT_EXPENSE]: undefined; [SCREENS.WORKSPACE_JOIN_USER]: { diff --git a/src/pages/TalkToHumanPage.tsx b/src/pages/TalkToHumanPage.tsx new file mode 100644 index 000000000000..04d28ea4a07e --- /dev/null +++ b/src/pages/TalkToHumanPage.tsx @@ -0,0 +1,68 @@ +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useNetwork from '@hooks/useNetwork'; +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +// Utility page: goes back, then does what the Help page's Talk to a human row does (offline it shows Help instead). +// Anonymous sessions are sent to sign-in instead. +function TalkToHumanPage() { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {openSupportTicketOrConcierge} = useOpenSupportTicket(); + const hasHandledLink = useRef(false); + + // Not wrapped in useCallback on purpose: the effect re-runs when isOffline changes, and the cleanup cancels the earlier run, + // so only the run that saw the latest connection state handles the link. + useFocusEffect(() => { + let isCancelled = false; + + interceptAnonymousUser(() => { + Navigation.isNavigationReady().then(() => { + if (isCancelled || hasHandledLink.current) { + return; + } + hasHandledLink.current = true; + + // A wide to narrow resize leaves a pending pop to the sidebar, which targets the page underneath + // rather than this one. goBack would consume it here and leave this page open, so it is set aside + // while this page pops and handed back to the next Back. + const hadPendingPopToSidebar = Navigation.getShouldPopToSidebar(); + Navigation.setShouldPopToSidebar(false); + Navigation.goBack(); + Navigation.setShouldPopToSidebar(hadPendingPopToSidebar); + if (isOffline) { + Navigation.navigate(ROUTES.SETTINGS_HELP); + return; + } + openSupportTicketOrConcierge(); + }); + }); + + return () => { + isCancelled = true; + }; + }); + + return ( + + + + + + + ); +} + +export default TalkToHumanPage; diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index a6a43484c86c..8d6b32ad7704 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -97,6 +97,7 @@ function ReportNavigateAwayHandler() { // a temporary Onyx SET that wipes the report; by the time effects fire, report is undefined // so we must persist the value in a ref updated synchronously during render. See issue #84248. const isCurrentRouteOwnWorkspaceChatRef = useIsOwnWorkspaceChatRef(report, reportIDFromRoute); + const isPendingCreationFromRoute = route.name === SCREENS.REPORT ? route.params?.isPendingCreation === 'true' : false; const firstRender = useRef(true); @@ -230,6 +231,11 @@ function ReportNavigateAwayHandler() { return; } + // A report still pending creation was never created, so its removal is a failed create that the opener handles. + if (isPendingCreationFromRoute) { + return; + } + Navigation.isNavigationReady().then(() => { // Drop the deleted report from the split navigator stack before navigating away. Otherwise, it stays // mounted underneath the destination, and tapping back refocuses it and re-triggers this effect, @@ -251,6 +257,7 @@ function ReportNavigateAwayHandler() { reportWasDeleted, previousReportWasDeleted, isFocused, + isPendingCreationFromRoute, deletedReportParentID, conciergeReportID, introSelected, diff --git a/src/pages/settings/HelpPage/HelpPage.tsx b/src/pages/settings/HelpPage/HelpPage.tsx index e43420589013..0d61b35bcbd6 100644 --- a/src/pages/settings/HelpPage/HelpPage.tsx +++ b/src/pages/settings/HelpPage/HelpPage.tsx @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -21,8 +22,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {openHelpPage} from '@libs/actions/Help'; import {openExternalLink} from '@libs/actions/Link'; -import {isNoSupportRepAvailableResponse, navigateToAndOpenReportWithAccountIDs, openSupportTicket} from '@libs/actions/Report'; -import Growl from '@libs/Growl'; +import {navigateToAndOpenReportWithAccountIDs} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import colors from '@styles/theme/colors'; @@ -64,21 +64,9 @@ function HelpPage() { const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const {openSupportTicketOrConcierge} = useOpenSupportTicket(); const {isOffline} = useNetwork(); - const openSupportTicketOrConcierge = () => { - openSupportTicket() - .then((response) => { - if (!isNoSupportRepAvailableResponse(response)) { - return; - } - - Growl.error({bodyText: translate('supportTicket.noSupportRepAvailable')}); - Navigation.goBack(undefined, {afterTransition: () => openConciergeAnywhere({forceConcierge: true})}); - }) - .catch(() => undefined); - }; - // Remove the row's accessibility grouping so native (iOS/Android) screen readers can announce the nested // Book a call button as its own element; on web this prop is a no-op and the button is reached via keyboard Tab instead const shouldBeAccessibleWithBookCallButton = (calendarLink: string | undefined) => !calendarLink; @@ -217,7 +205,7 @@ function HelpPage() { icon: icons.ChatBubbles, iconType: CONST.ICON_TYPE_ICON, onPress: openSupportTicketOrConcierge, - isDisabled: isOffline, + disabled: isOffline, shouldShowRightIcon: true, wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.SUPPORT_TICKET, diff --git a/tests/navigation/TalkToHumanNavigationTests.tsx b/tests/navigation/TalkToHumanNavigationTests.tsx new file mode 100644 index 000000000000..e352a28b4696 --- /dev/null +++ b/tests/navigation/TalkToHumanNavigationTests.tsx @@ -0,0 +1,133 @@ +import {act, render} from '@testing-library/react-native'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {InitialState} from '@react-navigation/native'; + +import {findFocusedRoute} from '@react-navigation/native'; +import React from 'react'; + +import TestNavigationContainer from '../utils/TestNavigationContainer'; + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@libs/getIsNarrowLayout', () => jest.fn()); +jest.mock('@pages/inbox/sidebar/NavigationTabBarAvatar'); + +const CHAT_REPORT_ID = '100'; + +const chatState = { + index: 0, + routes: [ + { + name: NAVIGATORS.TAB_NAVIGATOR, + state: { + index: 1, + routes: [ + {name: SCREENS.HOME}, + { + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + state: {index: 1, routes: [{name: SCREENS.INBOX}, {name: SCREENS.REPORT, params: {reportID: CHAT_REPORT_ID}}]}, + }, + {name: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR}, + {name: SCREENS.INSIGHTS}, + {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}, + {name: NAVIGATORS.WORKSPACE_NAVIGATOR}, + ], + }, + }, + ], +}; + +/** + * The Talk to a human page pops itself before opening a support ticket, and the ticket stores the route it was + * opened from as its Back target. These pin that the pop leaves a real page behind, so the ticket never records + * the loader itself. + */ +describe.each([ + ['wide layout', false], + ['narrow layout', true], +])('Talk to a human route on a %s', (_name, isNarrow) => { + beforeEach(() => { + jest.mocked(getIsNarrowLayout).mockReturnValue(isNarrow); + jest.mocked(useResponsiveLayout).mockReturnValue({ + ...CONST.NAVIGATION_TESTS.DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE, + shouldUseNarrowLayout: isNarrow, + isSmallScreenWidth: isNarrow, + }); + }); + + it('returns to the chat the link was pressed in', () => { + // Given a chat open in the app + render(); + + // When the link is pressed and the page pops itself + act(() => { + Navigation.navigate(ROUTES.TALK_TO_HUMAN); + }); + expect(Navigation.getActiveRoute()).toBe(`/${ROUTES.TALK_TO_HUMAN}`); + act(() => { + Navigation.goBack(); + }); + + // Then the chat is the active route, so the ticket records it as its Back target + expect(Navigation.getActiveRoute()).toBe(`/r/${CHAT_REPORT_ID}`); + }); + + it('still pops when a wide to narrow resize left a pending pop to the sidebar', () => { + // Given a chat open in the app and the link pressed + render(); + act(() => { + Navigation.navigate(ROUTES.TALK_TO_HUMAN); + }); + + // When the resize flag is pending and the page clears it before leaving, as TalkToHumanPage does + act(() => { + Navigation.setShouldPopToSidebar(true); + }); + act(() => { + Navigation.setShouldPopToSidebar(false); + Navigation.goBack(); + }); + + // Then the page is gone, so the ticket does not record this route as its Back target + expect(Navigation.getActiveRoute()).toBe(`/r/${CHAT_REPORT_ID}`); + }); + + it('leaves a page behind when the link is opened directly', () => { + // Given the route opened from a cold link, which has no page of its own to return to + render(); + expect(Navigation.getActiveRoute()).toBe(`/${ROUTES.TALK_TO_HUMAN}`); + + // When the page pops itself + act(() => { + Navigation.goBack(); + }); + + // Then it lands on a real page instead of stranding the customer on the loader + expect(Navigation.getActiveRoute()).not.toBe(`/${ROUTES.TALK_TO_HUMAN}`); + expect(navigationRef.current?.getRootState().routes.at(-1)?.name).toBe(NAVIGATORS.TAB_NAVIGATOR); + }); +}); + +describe('Talk to a human deep link', () => { + it('resolves the link to the Talk to a human page', () => { + // Given the link that Concierge replies point to + // When it is opened as a URL + const state = getAdaptedStateFromPath(`/${ROUTES.TALK_TO_HUMAN}`); + + // Then the focused screen is the Talk to a human page, and the URL survives a round trip + expect(findFocusedRoute(state)?.name).toBe(SCREENS.TALK_TO_HUMAN); + expect(getPathFromState(state)).toBe(`/${ROUTES.TALK_TO_HUMAN}`); + }); +}); diff --git a/tests/navigation/talkToHumanLinkLandsOnHelpTests.ts b/tests/navigation/talkToHumanLinkLandsOnHelpTests.ts deleted file mode 100644 index c700c263e3c4..000000000000 --- a/tests/navigation/talkToHumanLinkLandsOnHelpTests.ts +++ /dev/null @@ -1,33 +0,0 @@ -import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; -import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; - -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; - -import {findFocusedRoute} from '@react-navigation/native'; - -function getFocusedScreenName(path: string): string | undefined { - return findFocusedRoute(getAdaptedStateFromPath(path, undefined))?.name; -} - -describe('Talk to a human link', () => { - it('lands on the Help page', () => { - // Given the link that Concierge replies point to - // When it is opened - // Then the focused screen is Account > Help, not Not Found - expect(getFocusedScreenName(`/${ROUTES.TALK_TO_HUMAN}`)).toBe(SCREENS.SETTINGS.HELP); - }); - - it('serializes back to the canonical Help path', () => { - // Given the link opened as a URL - const state = getAdaptedStateFromPath(`/${ROUTES.TALK_TO_HUMAN}`, undefined); - - // When the state is written back to a path - // Then it is the Help path, so refreshing or sharing the URL keeps working - expect(getPathFromState(state)).toBe(`/${ROUTES.SETTINGS_HELP}`); - }); - - it('keeps the Help page reachable under its own path', () => { - expect(getFocusedScreenName(`/${ROUTES.SETTINGS_HELP}`)).toBe(SCREENS.SETTINGS.HELP); - }); -}); diff --git a/tests/ui/HelpPageTest.tsx b/tests/ui/HelpPageTest.tsx index b6c318d9e42c..12a998f69a08 100644 --- a/tests/ui/HelpPageTest.tsx +++ b/tests/ui/HelpPageTest.tsx @@ -6,6 +6,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useNetwork from '@hooks/useNetwork'; import {openExternalLink} from '@libs/actions/Link'; @@ -32,10 +33,12 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@libs/actions/Help', () => ({openHelpPage: jest.fn()})); jest.mock('@libs/actions/Link', () => ({openExternalLink: jest.fn()})); jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: jest.fn()})); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); jest.mock('@hooks/useIsPaidPolicyAdmin', () => () => true); jest.mock('@hooks/useCurrentUserPersonalDetails'); const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); +const mockUseNetwork = jest.mocked(useNetwork); const mockOpenExternalLink = jest.mocked(openExternalLink); const ACCOUNT_MANAGER_ACCOUNT_ID = 42; @@ -49,6 +52,7 @@ describe('HelpPage', () => { beforeEach(() => { mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: 1}); + mockUseNetwork.mockReturnValue({isOffline: false}); }); afterEach(async () => { @@ -84,6 +88,28 @@ describe('HelpPage', () => { expect(mockOpenExternalLink).toHaveBeenCalledWith(CALENDAR_LINK); }); + it('disables the Talk to a human row while offline, so no ticket is queued to appear after reconnecting', async () => { + // Given the device is offline + mockUseNetwork.mockReturnValue({isOffline: true}); + + // When the Help page is shown + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Talk to a human row is disabled + expect(screen.getByText(translateLocal('initialSettingsPage.talkToAHuman'))).toBeDisabled(); + }); + + it('keeps the Talk to a human row enabled while online', async () => { + // Given the device is online + // When the Help page is shown + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the Talk to a human row can be pressed + expect(screen.getByText(translateLocal('initialSettingsPage.talkToAHuman'))).not.toBeDisabled(); + }); + it('hides the duplicate account manager row when the account manager is Concierge', async () => { // Given Concierge assigned as the account manager await Onyx.merge(ONYXKEYS.ACCOUNT, {accountManagerAccountID: CONST.ACCOUNT_ID.CONCIERGE}); diff --git a/tests/ui/TalkToHumanPageTest.tsx b/tests/ui/TalkToHumanPageTest.tsx new file mode 100644 index 000000000000..7f376243157c --- /dev/null +++ b/tests/ui/TalkToHumanPageTest.tsx @@ -0,0 +1,193 @@ +import {act, render} from '@testing-library/react-native'; + +import useNetwork from '@hooks/useNetwork'; + +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; + +import TalkToHumanPage from '@pages/TalkToHumanPage'; + +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +const mockOpenSupportTicketOrConcierge = jest.fn(); + +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), + setShouldPopToSidebar: jest.fn(), + getShouldPopToSidebar: jest.fn(() => false), + isNavigationReady: jest.fn(() => Promise.resolve()), +})); +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (effect: () => void) => jest.requireActual('react').useEffect(effect, [effect]), +})); +jest.mock('@libs/interceptAnonymousUser', () => jest.fn()); +jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@hooks/useThemeStyles', () => () => ({borderBottom: {}})); +jest.mock('@hooks/useOpenSupportTicket', () => () => ({openSupportTicketOrConcierge: mockOpenSupportTicketOrConcierge})); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/ReportHeaderSkeletonView', () => () => null); +jest.mock('@components/ReportActionsSkeletonView', () => () => null); + +const mockInterceptAnonymousUser = jest.mocked(interceptAnonymousUser); +const mockUseNetwork = jest.mocked(useNetwork); +const mockGoBack = jest.mocked(Navigation.goBack); +const mockSetShouldPopToSidebar = jest.mocked(Navigation.setShouldPopToSidebar); +const mockGetShouldPopToSidebar = jest.mocked(Navigation.getShouldPopToSidebar); +const mockNavigate = jest.mocked(Navigation.navigate); + +async function renderPage() { + const view = render(); + await act(async () => { + await Promise.resolve(); + }); + return view; +} + +describe('TalkToHumanPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInterceptAnonymousUser.mockImplementation((callback) => callback()); + mockUseNetwork.mockReturnValue({isOffline: false} as ReturnType); + mockGetShouldPopToSidebar.mockReturnValue(false); + }); + + it('goes back and then opens a support ticket', async () => { + // Given the link is opened while online + // When the page opens + await renderPage(); + + // Then it leaves the page first and opens the ticket from the page the link was pressed on + expect(mockGoBack).toHaveBeenCalledTimes(1); + + // And a pending pop to the sidebar is cleared first, or goBack would consume it and leave this page open + expect(mockSetShouldPopToSidebar).toHaveBeenCalledWith(false); + expect(mockSetShouldPopToSidebar.mock.invocationCallOrder.at(0)).toBeLessThan(mockGoBack.mock.invocationCallOrder.at(0) ?? 0); + expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); + expect(mockGoBack.mock.invocationCallOrder.at(0)).toBeLessThan(mockOpenSupportTicketOrConcierge.mock.invocationCallOrder.at(0) ?? 0); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('hands a pending pop to the sidebar back after it leaves, so the next Back still pops to the sidebar', async () => { + // Given a wide to narrow resize left a pending pop to the sidebar + mockGetShouldPopToSidebar.mockReturnValue(true); + + // When the page opens + await renderPage(); + + // Then the flag is cleared while the page pops, and set again afterwards + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockSetShouldPopToSidebar.mock.calls).toEqual([[false], [true]]); + const [clearCall, restoreCall] = mockSetShouldPopToSidebar.mock.invocationCallOrder; + const goBackCall = mockGoBack.mock.invocationCallOrder.at(0) ?? 0; + expect(clearCall).toBeLessThan(goBackCall); + expect(restoreCall).toBeGreaterThan(goBackCall); + }); + + it('does not arm a pop to the sidebar when none was pending', async () => { + // Given no pending pop to the sidebar + mockGetShouldPopToSidebar.mockReturnValue(false); + + // When the page opens + await renderPage(); + + // Then nothing is armed after the page leaves + expect(mockSetShouldPopToSidebar).not.toHaveBeenCalledWith(true); + }); + + it('goes back and shows the Help page without creating a ticket when offline', async () => { + // Given the link is opened while the device is offline + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + + // When the page opens + await renderPage(); + + // Then it leaves the page and shows the Help page, where the Talk to a human row is disabled + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + + it('does nothing when the customer leaves before navigation is ready', async () => { + // Given navigation that is not ready yet + let resolveNavigationReady = () => {}; + jest.mocked(Navigation.isNavigationReady).mockReturnValue( + new Promise((resolve) => { + resolveNavigationReady = resolve; + }), + ); + const {unmount} = render(); + + // When the customer leaves the page and navigation becomes ready afterwards + unmount(); + await act(async () => { + resolveNavigationReady(); + await Promise.resolve(); + }); + + // Then no ticket is opened and nothing navigates behind them + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + + it('uses the connection state from when navigation became ready, not a stale one', async () => { + // Given navigation that is not ready yet, and a page that rendered online + let resolveNavigationReady = () => {}; + jest.mocked(Navigation.isNavigationReady).mockReturnValue( + new Promise((resolve) => { + resolveNavigationReady = resolve; + }), + ); + const {rerender} = render(); + + // When the device turns out to be offline before navigation is ready + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + rerender(); + await act(async () => { + resolveNavigationReady(); + await Promise.resolve(); + }); + + // Then the page shows Help and does not try to create a ticket + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.SETTINGS_HELP); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); + + it('does not open a second ticket when it re-renders before it is removed', async () => { + // Given the page has already handled the link + const {rerender} = await renderPage(); + + // When it re-renders, for example because the network state changed + mockUseNetwork.mockReturnValue({isOffline: true} as ReturnType); + rerender(); + await act(async () => { + await Promise.resolve(); + }); + + // Then the link is only handled once + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockOpenSupportTicketOrConcierge).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not leave the page or open a ticket for an anonymous session', async () => { + // Given an anonymous session, which is sent to sign-in instead of running the callback + mockInterceptAnonymousUser.mockImplementation(() => {}); + + // When the page opens + await renderPage(); + + // Then nothing navigates and no ticket is opened + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockOpenSupportTicketOrConcierge).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useOpenSupportTicketTest.ts b/tests/unit/useOpenSupportTicketTest.ts new file mode 100644 index 000000000000..10df138d33c7 --- /dev/null +++ b/tests/unit/useOpenSupportTicketTest.ts @@ -0,0 +1,190 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useOpenSupportTicket from '@hooks/useOpenSupportTicket'; + +import {isNoSupportRepAvailableResponse, openSupportTicket} from '@libs/actions/Report'; +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; + +const mockOpenConciergeAnywhere = jest.fn(); +const CONCIERGE_REPORT_ID = '9'; + +jest.mock('@libs/actions/Report', () => ({ + openSupportTicket: jest.fn(), + isNoSupportRepAvailableResponse: jest.fn(), +})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn(), getActiveRoute: jest.fn(), getFocusedReportId: jest.fn()})); +jest.mock('@libs/Growl', () => ({error: jest.fn()})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useOnyx', () => () => [CONCIERGE_REPORT_ID]); +jest.mock('@hooks/useOpenConciergeAnywhere', () => () => ({openConciergeAnywhere: mockOpenConciergeAnywhere})); + +const mockOpenSupportTicket = jest.mocked(openSupportTicket); +const mockGetActiveRoute = jest.mocked(Navigation.getActiveRoute); +const mockGetFocusedReportId = jest.mocked(Navigation.getFocusedReportId); + +const CHAT_ROUTE = '/r/1'; +const PENDING_TICKET_ROUTE = '/r/2/?isPendingCreation=true'; +const mockIsNoSupportRepAvailableResponse = jest.mocked(isNoSupportRepAvailableResponse); + +describe('useOpenSupportTicket', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns nothing from the callback so a menu row does not wait on the ticket request', () => { + // Given a ticket request that has not finished + mockOpenSupportTicket.mockReturnValue(new Promise(() => {})); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs + const returnValue: unknown = result.current.openSupportTicketOrConcierge(); + + // Then it returns nothing, so single execution on native clears as soon as the transition ends + expect(returnValue).toBeUndefined(); + expect(mockOpenSupportTicket).toHaveBeenCalledTimes(1); + }); + + it('does not show an error or open Concierge when a support rep takes the ticket', async () => { + // Given a ticket request that a rep picks up + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(false); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then there is no error toast and no fallback navigation + expect(Growl.error).not.toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('shows an error and opens Concierge without navigating when the failed ticket screen is already gone', async () => { + // Given a ticket request that finds no rep, after its screen has taken itself down + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + // The ticket screen is open when the request starts, and gone by the time it resolves + mockGetActiveRoute.mockReturnValueOnce(PENDING_TICKET_ROUTE).mockReturnValue(CHAT_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the customer sees the error and stays put, because going back would pop the page behind the ticket + expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); + expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + }); + + it('leaves the failed ticket screen when it is still open, then opens Concierge', async () => { + // Given a ticket request that finds no rep while its screen is still open + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the ticket screen is left behind + expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + + // And Concierge opens once that transition has finished + const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); + if (typeof goBackOptions !== 'object') { + throw new Error('goBack should receive options with an afterTransition callback'); + } + expect(goBackOptions.afterTransition).toEqual(expect.any(Function)); + goBackOptions.afterTransition?.(); + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + }); + + it('does not open Concierge again when the customer is already in the Concierge chat', async () => { + // Given a ticket request that finds no rep while the customer is in the Concierge chat + mockOpenSupportTicket.mockResolvedValue(undefined); + mockIsNoSupportRepAvailableResponse.mockReturnValue(true); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + mockGetFocusedReportId.mockReturnValueOnce(CONCIERGE_REPORT_ID); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs, the request finishes and the ticket screen is left + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + const goBackOptions = jest.mocked(Navigation.goBack).mock.calls.at(0)?.at(1); + if (typeof goBackOptions !== 'object') { + throw new Error('goBack should receive options with an afterTransition callback'); + } + goBackOptions.afterTransition?.(); + + // Then the error is shown and Concierge is not opened a second time + expect(Growl.error).toHaveBeenCalledWith({bodyText: 'supportTicket.noSupportRepAvailable'}); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('leaves the pending ticket screen when the request fails', async () => { + // Given a ticket request that fails while its screen is still open + mockOpenSupportTicket.mockRejectedValue(new Error('network')); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request settles + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the ticket screen is left without an error toast or Concierge + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(Growl.error).not.toHaveBeenCalled(); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('leaves the pending ticket screen when the server returns another error', async () => { + // Given a ticket request answered with an error that is not the no-rep response + mockOpenSupportTicket.mockResolvedValue({jsonCode: 666, message: 'Something else'}); + mockIsNoSupportRepAvailableResponse.mockReturnValue(false); + mockGetActiveRoute.mockReturnValue(PENDING_TICKET_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request finishes + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then the ticket screen is left and Concierge is not opened + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(mockOpenConciergeAnywhere).not.toHaveBeenCalled(); + }); + + it('does not navigate when the request fails after the ticket screen is already gone', async () => { + // Given a ticket request that fails after the customer has left the ticket screen + mockOpenSupportTicket.mockRejectedValue(new Error('network')); + mockGetActiveRoute.mockReturnValueOnce(PENDING_TICKET_ROUTE).mockReturnValue(CHAT_ROUTE); + const {result} = renderHook(() => useOpenSupportTicket()); + + // When the callback runs and the request settles + await act(async () => { + result.current.openSupportTicketOrConcierge(); + await Promise.resolve(); + }); + + // Then nothing is popped from the page they are on + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index ec4183408b09..43127dce1c6d 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -282,6 +282,10 @@ function TestNavigationContainer({initialState, onBeforeRemove}: TestNavigationC name={SCREENS.VALIDATE_LOGIN} component={getEmptyComponent()} /> +