Skip to content

Commit a800671

Browse files
committed
Keep multi-link cells out of row-link mode
1 parent a2c3acc commit a800671

3 files changed

Lines changed: 62 additions & 12 deletions

File tree

src/components/HTMLEngineProvider/HTMLRenderers/TableRowLink.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/**
2+
* Helpers for treating a table's link column as the link of each row: finding the single column whose cells hold
3+
* links, resolving each row's destination, and spotting the anchor that renders as plain text because of it.
4+
*/
15
import type {TNode} from 'react-native-render-html';
26

37
import {getElementChildren} from './TableChildrenRenderer';
@@ -8,31 +12,49 @@ function getBodyRows(tableTnode: TNode): TNode[] {
812
.flatMap((section) => getElementChildren(section));
913
}
1014

11-
function findAnchor(node: TNode): TNode | undefined {
15+
function getAnchors(node: TNode): TNode[] {
1216
if (node.tagName === 'a') {
13-
return node;
17+
return [node];
18+
}
19+
return (node.children ?? []).flatMap(getAnchors);
20+
}
21+
22+
/** The destination of a cell, defined only when the cell holds exactly one link and that link has an href. */
23+
function getCellLinkURL(cell: TNode): string | undefined {
24+
const anchors = getAnchors(cell);
25+
if (anchors.length !== 1) {
26+
return undefined;
1427
}
15-
return (node.children ?? []).reduce<TNode | undefined>((found, child) => found ?? findAnchor(child), undefined);
28+
29+
return anchors.at(0)?.attributes?.href || undefined;
1630
}
1731

1832
/**
1933
* The single column whose cells hold links, or undefined when the table has no such column.
2034
*
2135
* Concierge expense tables link one cell per row — the merchant — and that link points at the row's transaction, so
22-
* the row as a whole can navigate there. Requiring the links to sit in exactly one column leaves any other table
23-
* (no links, or links spread across columns) rendering as a plain table with its own per-cell anchors.
36+
* the row as a whole can navigate there. Requiring the links to sit in exactly one column, each cell holding a single
37+
* link with an href, leaves every other table (no links, links spread across columns, or a cell whose several links
38+
* one row destination could not stand in for) rendering as a plain table with its own per-cell anchors.
2439
*/
2540
function getLinkColumnIndex(tableTnode: TNode): number | undefined {
2641
const columnsWithLinks = new Set<number>();
42+
let hasCellWithoutSingleLink = false;
2743
for (const row of getBodyRows(tableTnode)) {
2844
for (const [columnIndex, cell] of getElementChildren(row).entries()) {
29-
if (findAnchor(cell)) {
30-
columnsWithLinks.add(columnIndex);
45+
if (getAnchors(cell).length === 0) {
46+
continue;
3147
}
48+
columnsWithLinks.add(columnIndex);
49+
hasCellWithoutSingleLink = hasCellWithoutSingleLink || !getCellLinkURL(cell);
3250
}
3351
}
3452

35-
return columnsWithLinks.size === 1 ? columnsWithLinks.values().next().value : undefined;
53+
if (hasCellWithoutSingleLink || columnsWithLinks.size !== 1) {
54+
return undefined;
55+
}
56+
57+
return columnsWithLinks.values().next().value;
3658
}
3759

3860
/**
@@ -76,7 +98,7 @@ function getRowLinkURL(rowTnode: TNode, linkColumnIndex: number | undefined): st
7698
}
7799

78100
const linkCell = getElementChildren(rowTnode).at(linkColumnIndex);
79-
return linkCell ? findAnchor(linkCell)?.attributes?.href : undefined;
101+
return linkCell ? getCellLinkURL(linkCell) : undefined;
80102
}
81103

82104
export {getLinkColumnIndex, getRowLinkURL, getTextContent, isLinkColumnAnchor};

src/components/HTMLEngineProvider/HTMLRenderers/TableRowRenderer.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed
33
import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuState} from '@components/ShowContextMenuContext';
44

55
import useEnvironment from '@hooks/useEnvironment';
6+
import useHover from '@hooks/useHover';
67
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
78
import useLocalize from '@hooks/useLocalize';
89
import useTheme from '@hooks/useTheme';
910
import useThemeStyles from '@hooks/useThemeStyles';
1011

1112
import {openLink} from '@libs/actions/Link';
1213

14+
import variables from '@styles/variables';
15+
1316
import CONST from '@src/CONST';
1417

1518
import type {CustomRendererProps, TBlock, TNode} from 'react-native-render-html';
@@ -19,7 +22,7 @@ import {View} from 'react-native';
1922

2023
import TableChildrenRenderer, {getElementChildren} from './TableChildrenRenderer';
2124
import TableLinkColumnContext from './TableLinkColumnContext';
22-
import {getRowLinkURL} from './TableRowLink';
25+
import {getRowLinkURL, getTextContent} from './TableRowLink';
2326

2427
function isLastRowOfTable(tnode: TNode): boolean {
2528
const section = tnode.parent;
@@ -32,12 +35,21 @@ function isLastRowOfTable(tnode: TNode): boolean {
3235
return sectionRows.at(-1) === tnode && tableSections.at(-1) === section;
3336
}
3437

38+
/** The row read out as its cells, e.g. `Airbnb, 2026-06-02, £404.60`. */
39+
function getRowCellsText(rowTnode: TNode): string {
40+
return getElementChildren(rowTnode)
41+
.map((cell) => getTextContent(cell).trim())
42+
.filter((cellText) => cellText.length > 0)
43+
.join(', ');
44+
}
45+
3546
function TableRowRenderer({tnode}: CustomRendererProps<TBlock>) {
3647
const styles = useThemeStyles();
3748
const theme = useTheme();
3849
const {translate} = useLocalize();
3950
const {environmentURL} = useEnvironment();
4051
const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']);
52+
const {hovered, bind} = useHover();
4153
const {anchor, report, action, originalReportID} = useShowContextMenuState();
4254
const {onShowContextMenu, checkIfContextMenuActive} = useShowContextMenuActions();
4355

@@ -57,7 +69,9 @@ function TableRowRenderer({tnode}: CustomRendererProps<TBlock>) {
5769
<Icon
5870
src={icons.ArrowRight}
5971
fill={theme.icon}
60-
size={CONST.ICON_SIZE.SMALL}
72+
additionalStyles={!hovered && styles.opacitySemiTransparent}
73+
width={variables.iconSizeNormal}
74+
height={variables.iconSizeNormal}
6175
/>
6276
)}
6377
</View>
@@ -79,10 +93,12 @@ function TableRowRenderer({tnode}: CustomRendererProps<TBlock>) {
7993
onPress={() => openLink(rowLinkURL, environmentURL)}
8094
onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, originalReportID))}
8195
role={CONST.ROLE.BUTTON}
82-
accessibilityLabel={translate('iou.viewDetails')}
96+
accessibilityLabel={getRowCellsText(tnode) || translate('iou.viewDetails')}
97+
accessibilityHint={translate('iou.viewDetails')}
8398
sentryLabel={CONST.SENTRY_LABEL.HTML_RENDERER.TABLE_ROW}
8499
shouldUseHapticsOnLongPress
85100
isNested
101+
{...bind}
86102
>
87103
<TableChildrenRenderer tnode={tnode} />
88104
{chevron}

tests/unit/TableRowLinkTest.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ describe('getLinkColumnIndex', () => {
7676

7777
expect(getLinkColumnIndex(mixedTable as unknown as TNode)).toBeUndefined();
7878
});
79+
80+
it('returns undefined when a cell holds more than one link, since one row destination cannot stand in for both', () => {
81+
const twoLinkCellTable = table([[node('span', [link('Airbnb', AIRBNB_URL), link('Uber', UBER_URL)]), text('£404.60')]]);
82+
83+
expect(getLinkColumnIndex(twoLinkCellTable as unknown as TNode)).toBeUndefined();
84+
});
85+
86+
it('returns undefined when the linked cell has an anchor without an href', () => {
87+
const hreflessTable = table([[node('a', [text('Airbnb')]), text('£404.60')]]);
88+
89+
expect(getLinkColumnIndex(hreflessTable as unknown as TNode)).toBeUndefined();
90+
});
7991
});
8092

8193
describe('getRowLinkURL', () => {

0 commit comments

Comments
 (0)