Repository navigation
[Payment due @situchan] Open a support ticket straight from the Talk to a human link #104572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8b33174
27bbef5
8bb3d5b
37a9c92
723f39a
7ccb87e
c49ecdd
ac3d8ec
99d3f02
81931b2
95e8693
46a2b72
fc9d1b6
bf98376
c03c70b
a7db5c6
535cd32
ad34328
262ce61
8b3c50a
67accc4
747e2ca
1f304e5
96f4fb4
14fa4ef
d0a50ee
38d3193
0d4342a
ed24c9b
ccb4a31
c2b47a3
93e9ef0
08d40a7
124bce8
a89e14c
e729a4a
24dbd1c
c87ad6f
2e8be9c
a6ae82e
79dbe49
1cb5ed9
b9957e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -86,6 +86,7 @@ const loadAttachmentModalScreen = () => require<ReactComponentModule>('../../../ | |
| const loadValidateLoginPage = () => require<ReactComponentModule>('../../../pages/ValidateLoginPage').default; | ||
| const loadLogOutPreviousUserPage = () => require<ReactComponentModule>('../../../pages/LogOutPreviousUserPage').default; | ||
| const loadConciergePage = () => require<ReactComponentModule>('../../../pages/ConciergePage').default; | ||
| const loadTalkToHumanPage = () => require<ReactComponentModule>('../../../pages/TalkToHumanPage').default; | ||
| const loadTrackExpensePage = () => require<ReactComponentModule>('../../../pages/TrackExpensePage').default; | ||
| const loadSubmitExpensePage = () => require<ReactComponentModule>('../../../pages/SubmitExpensePage').default; | ||
| const loadPreMountBufferPage = () => require<ReactComponentModule>('../../../pages/PreMountBufferPage').default; | ||
|
|
@@ -226,6 +227,11 @@ function AuthScreens() { | |
| options={defaultScreenOptions} | ||
| getComponent={loadConciergePage} | ||
| /> | ||
| <RootStack.Screen | ||
| name={SCREENS.TALK_TO_HUMAN} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this URL is delivered while the user is signed out (for example, an expired native session), linking resolves it to the new root Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TrackExpensePage is registered only in AuthScreens as well, so signed-out links to root utility pages already behave this way. Keeping the same pattern here rather than adding replay logic for one route. |
||
| options={defaultScreenOptions} | ||
| getComponent={loadTalkToHumanPage} | ||
| /> | ||
| <RootStack.Screen | ||
| name={SCREENS.TRACK_EXPENSE} | ||
| options={defaultScreenOptions} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <ScreenWrapper testID="TalkToHumanPage"> | ||
| <View style={[styles.borderBottom]}> | ||
| <ReportHeaderSkeletonView onBackButtonPress={Navigation.goBack} /> | ||
| </View> | ||
| <ReportActionsSkeletonView /> | ||
| </ScreenWrapper> | ||
| ); | ||
| } | ||
|
|
||
| export default TalkToHumanPage; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<TestNavigationContainer initialState={chatState} />); | ||
|
|
||
| // 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(<TestNavigationContainer initialState={chatState} />); | ||
| 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(<TestNavigationContainer initialState={getAdaptedStateFromPath(ROUTES.TALK_TO_HUMAN) as InitialState} />); | ||
| 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}`); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.