Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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.
Expand All @@ -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<PlatformStackRouteProp<WorkspaceSplitNavigatorParamList, typeof SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_COMPANY_CARDS_ADD_NEW>>();
Expand Down Expand Up @@ -89,13 +94,37 @@ function ImportFromFileStep() {
contentContainerStyle={styles.flexGrow1}
addBottomSafeAreaPadding
>
<Text style={[styles.ph5, styles.mv3, styles.textSupporting]}>
{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionStart')}
<TextLink onPress={downloadTemplate}>{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.templateLink')}</TextLink>
{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionMiddle')}
<TextLink href={CONST.COMPANY_CARDS_CREATE_FILE_FEED_HELP_URL}>{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.helpGuideLink')}</TextLink>
{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionEnd')}
</Text>
<View style={[styles.ph5, styles.mv3, styles.flexRow, styles.flexWrap, styles.alignItemsCenter]}>
<WrappingText text={translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionStart')} />
<PressableWithoutFeedback
testID="ImportFromFileStep-TemplateLink"
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('workspace.companyCards.addNewCard.createFileFeedHelpText.templateLink')}
sentryLabel="ImportFromFileStep-TemplateLink"
onPress={downloadTemplate}
style={styles.dInlineFlex}
>
<Text style={[styles.textSupporting, styles.link]}>{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.templateLink')}</Text>
</PressableWithoutFeedback>
<WrappingText text={translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionMiddle')} />
<PressableWithoutFeedback
testID="ImportFromFileStep-HelpGuideLink"
role={CONST.ROLE.LINK}
// Pass href so the link renders as a real anchor on web (native link behavior: hover URL, open in a new tab, etc.),
// while onPress preventDefault()s the anchor's default navigation and routes through openLink on every platform.
href={CONST.COMPANY_CARDS_CREATE_FILE_FEED_HELP_URL}
accessibilityLabel={translate('workspace.companyCards.addNewCard.createFileFeedHelpText.helpGuideLink')}
sentryLabel="ImportFromFileStep-HelpGuideLink"
onPress={(event) => {
event?.preventDefault();
openLink(CONST.COMPANY_CARDS_CREATE_FILE_FEED_HELP_URL, environmentURL);
}}
style={styles.dInlineFlex}
>
<Text style={[styles.textSupporting, styles.link]}>{translate('workspace.companyCards.addNewCard.createFileFeedHelpText.helpGuideLink')}</Text>
</PressableWithoutFeedback>
<WrappingText text={translate('workspace.companyCards.addNewCard.createFileFeedHelpText.instructionEnd')} />
</View>
<MenuItemWithTopDescription
description={translate('workspace.companyCards.addNewCard.companyCardLayoutName')}
title={companyCardLayoutName}
Expand Down
48 changes: 48 additions & 0 deletions src/pages/workspace/companyCards/addNew/WrappingText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Text from '@components/Text';

import useThemeStyles from '@hooks/useThemeStyles';

import React from 'react';

// Renders plain paragraph copy as per-word <Text> 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 = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: @MelvinBot the JSDoc documents the component, but the new file still has no header before WrappingTextProps. Please add a short file description above the type.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved ✅

/** 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 <Text> 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 <Text> 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 <Text>
* 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) => (
<Text
// The word list is derived synchronously from a fixed translation and is never reordered, inserted into, or
// filtered, so a word's index is a stable identity. wordIndex is only needed to disambiguate repeated words
// within a run (the word text alone can't); the array position is what makes it unique.
// eslint-disable-next-line react/no-array-index-key -- index is a stable identity for this static, never-reordered word list
key={`${text}-${word}-${wordIndex}`}
style={styles.textSupporting}
>
{word}
</Text>
));
}

WrappingText.displayName = 'WrappingText';

export default WrappingText;
107 changes: 107 additions & 0 deletions tests/unit/ImportFromFileStepTest.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<ImportFromFileStep />
</ComposeProviders>,
);
}

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 <Text>/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 <a> (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));
});
});
Loading