diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index debea4119c4a..6a07d5261373 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -1,11 +1,12 @@ import Button from '@components/ButtonComposed'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import TextLink from '@components/TextLink'; +import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -19,6 +20,7 @@ import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/t import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; +import {openLink} from '@userActions/Link'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -29,6 +31,8 @@ import {useRoute} from '@react-navigation/native'; import React, {useState} from 'react'; import {View} from 'react-native'; +import WrappingText from './WrappingText'; + // cspell:disable // Example CSV shared with customers so they can see how to structure a company card import file. // Sourced from the Expensify Classic "Manage Company Cards" help article. @@ -47,6 +51,7 @@ const CSV_TEMPLATE_CONTENT = [ function ImportFromFileStep() { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {environmentURL} = useEnvironment(); const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['Download']); const route = useRoute>(); @@ -89,13 +94,37 @@ function ImportFromFileStep() { contentContainerStyle={styles.flexGrow1} addBottomSafeAreaPadding > - - {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionStart')} - {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.templateLink')} - {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionMiddle')} - {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.helpGuideLink')} - {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionEnd')} - + + + + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.templateLink')} + + + { + event?.preventDefault(); + openLink(CONST.COMPANY_CARDS_CREATE_FILE_FEED_HELP_URL, environmentURL); + }} + style={styles.dInlineFlex} + > + {translate('workspace.companyCards.addNewCard.createFileFeedHelpText.helpGuideLink')} + + + nodes so it flows and wraps naturally inside a flexWrap +// row alongside tappable link nodes. Used by the company-card CSV import help text (see JSDoc below). + +type WrappingTextProps = { + /** Plain copy to render, split into per-word nodes so it wraps naturally inside a flexWrap row. */ + text: string; +}; + +/** + * Renders plain copy as a sequence of per-word nodes so it flows and wraps naturally inside a + * flexWrap row alongside tappable link nodes. + * + * The company-card CSV import help text mixes plain copy with tappable links (a client-side template + * download and an external help guide). On Android, a link nested inline inside a becomes a + * ClickableSpan whose touch area is limited to the glyph bounds, which makes it unreliable to tap (e.g. + * at the minimum device font size). The links are therefore rendered as their own PressableWithoutFeedback + * nodes inside a flexWrap row, and this component renders the plain copy between them as per-word + * nodes so the paragraph still flows and wraps naturally across the row. + */ +function WrappingText({text}: WrappingTextProps) { + const styles = useThemeStyles(); + + // Keep each word (with its own leading/trailing whitespace) as a separate node so the paragraph wraps in the flexWrap row. + // Preserving the run's own spacing means locales that don't use spaces around the links (e.g. Japanese, Chinese) aren't + // given extra spaces the translation never intended. + return (text.match(/\s*\S+\s*/g) ?? []).map((word, wordIndex) => ( + + {word} + + )); +} + +WrappingText.displayName = 'WrappingText'; + +export default WrappingText; diff --git a/tests/unit/ImportFromFileStepTest.tsx b/tests/unit/ImportFromFileStepTest.tsx new file mode 100644 index 000000000000..d7212a7311a1 --- /dev/null +++ b/tests/unit/ImportFromFileStepTest.tsx @@ -0,0 +1,107 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import * as Link from '@libs/actions/Link'; +import localFileDownload from '@libs/localFileDownload'; + +import ImportFromFileStep from '@pages/workspace/companyCards/addNew/ImportFromFileStep'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const POLICY_ID = 'import-from-file-step-test-policy'; + +// The inline template download is a client-side file write, and the help guide opens an external link. +// Stub both so the test asserts the presses are wired up without touching the filesystem or the browser. +jest.mock('@libs/localFileDownload'); + +jest.mock('@react-navigation/native', () => { + // jest.requireActual returns `any` for the untyped React Navigation module + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualNav = jest.requireActual('@react-navigation/native'); + + // Spreading the untyped requireActual result is intentional for this navigation mock + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualNav, + useNavigation: () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + addListener: () => jest.fn(), + isFocused: () => true, + }), + useIsFocused: () => true, + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: () => ({key: 'test-route', name: 'Workspace_Company_Cards_Add_New', params: {policyID: POLICY_ID}}), + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + getTopmostReportId: jest.fn(() => undefined), + isNavigationReady: jest.fn(() => Promise.resolve()), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenFromNavigationState: jest.fn(), + dismissModal: jest.fn(), +})); + +function renderImportFromFileStep() { + return render( + + + , + ); +} + +describe('ImportFromFileStep inline help links', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + renderImportFromFileStep(); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the template link as a Pressable button that triggers the client-side CSV download', () => { + const templateLink = screen.getByTestId('ImportFromFileStep-TemplateLink'); + + // A Pressable-backed link (not a bare inline /ClickableSpan) so it gets a real native touch target on Android. + expect(templateLink).toHaveProp('role', CONST.ROLE.BUTTON); + + fireEvent.press(templateLink); + expect(localFileDownload).toHaveBeenCalledTimes(1); + }); + + it('renders the help guide link as a Pressable that keeps its href and opens the help guide', () => { + const openLinkSpy = jest.spyOn(Link, 'openLink').mockImplementation(() => {}); + const helpGuideLink = screen.getByTestId('ImportFromFileStep-HelpGuideLink'); + + // Retains href so web renders a real (native link behavior), while still routing through onPress on every platform. + expect(helpGuideLink).toHaveProp('role', CONST.ROLE.LINK); + expect(helpGuideLink).toHaveProp('href', CONST.COMPANY_CARDS_CREATE_FILE_FEED_HELP_URL); + + fireEvent.press(helpGuideLink); + expect(openLinkSpy).toHaveBeenCalledWith(CONST.COMPANY_CARDS_CREATE_FILE_FEED_HELP_URL, expect.any(String)); + }); +});