Skip to content

Commit a2c3acc

Browse files
committed
Concierge HTML tables: make the whole row clickable and align styling with other tables
1 parent 4ff4738 commit a2c3acc

9 files changed

Lines changed: 344 additions & 10 deletions

File tree

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8430,6 +8430,7 @@ const CONST = {
84308430
IMAGE: 'HTMLRenderer-Image',
84318431
PRE: 'HTMLRenderer-Pre',
84328432
VICTORY_CHART_EXPAND_BUTTON: 'HTMLRenderer-VictoryChartExpandButton',
8433+
TABLE_ROW: 'HTMLRenderer-TableRow',
84338434
},
84348435
RECEIPT: {
84358436
IMAGE: 'Receipt-Image',

src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ import type {StyleProp, TextStyle} from 'react-native';
1919
import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html';
2020

2121
import {Str} from 'expensify-common';
22-
import React, {useMemo} from 'react';
22+
import React, {useContext, useMemo} from 'react';
2323
import {TNodeChildrenRenderer} from 'react-native-render-html';
2424

25+
import TableLinkColumnContext from './TableLinkColumnContext';
26+
import {getTextContent, isLinkColumnAnchor} from './TableRowLink';
27+
2528
type AnchorRendererProps = CustomRendererProps<TText | TPhrasing> & {
2629
/** Key of the element */
2730
key?: string;
@@ -47,6 +50,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) {
4750

4851
const isDeleted = HTMLEngineUtils.isDeletedNode(tnode);
4952
const isChildOfTaskTitle = HTMLEngineUtils.isChildOfTaskTitle(tnode);
53+
const linkColumnIndex = useContext(TableLinkColumnContext);
5054

5155
const textDecorationLineStyle = isDeleted ? styles.lineThrough : {};
5256

@@ -62,6 +66,12 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) {
6266
return undefined;
6367
}, [internalNewExpensifyPath, internalExpensifyPath, attrHref, environmentURL, isAttachment]);
6468

69+
// The table row already navigates to this link's destination, so the cell shows the link text as plain content
70+
// rather than a second target styled as a link.
71+
if (isLinkColumnAnchor(tnode, linkColumnIndex)) {
72+
return <Text>{getTextContent(tnode)}</Text>;
73+
}
74+
6575
if (!HTMLEngineUtils.isChildOfComment(tnode) && !isChildOfTaskTitle) {
6676
// This is not a comment from a chat, the AnchorForCommentsOnly uses a Pressable to create a context menu on right click.
6777
// We don't have this behaviour in other links in NewDot
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {createContext} from 'react';
2+
3+
/**
4+
* The column index whose links make each body row of the table currently being rendered navigable, or undefined when
5+
* the table has no such column. `TableRenderer` derives it and shares it so the row renderer can navigate and the cell
6+
* renderer can drop the now-redundant per-cell anchor.
7+
*/
8+
const TableLinkColumnContext = createContext<number | undefined>(undefined);
9+
10+
export default TableLinkColumnContext;

src/components/HTMLEngineProvider/HTMLRenderers/TableRenderer.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import HTMLTableScroll from './HTMLTableScroll';
1111
import TableChildrenRenderer, {getElementChildren} from './TableChildrenRenderer';
1212
import TableColumnAlignmentContext from './TableColumnAlignmentContext';
1313
import TableContentWidthContext from './TableContentWidthContext';
14+
import TableLinkColumnContext from './TableLinkColumnContext';
15+
import {getLinkColumnIndex} from './TableRowLink';
1416

1517
/**
1618
* Derives the horizontal alignment for each column from the table body. Concierge expense tables put the amount in
@@ -31,23 +33,27 @@ function getColumnAlignments(tableNode: TNode): CellHorizontalAlignment[] {
3133

3234
function TableRenderer({tnode}: CustomRendererProps<TBlock>) {
3335
const columnAlignments = useMemo(() => getColumnAlignments(tnode), [tnode]);
36+
const linkColumnIndex = useMemo(() => getLinkColumnIndex(tnode), [tnode]);
3437

3538
// The comment-level width fills the message exactly; fall back to the HTML content width when the table is rendered
3639
// outside a comment. A concrete number is required because the scroller needs a fixed viewport and content width.
3740
const measuredContentWidth = useContext(TableContentWidthContext);
3841
const fallbackContentWidth = useContentWidth();
3942
const viewportWidth = measuredContentWidth || fallbackContentWidth;
40-
const columnsWidth = columnAlignments.length * variables.htmlTableColumnMaxWidth + 2 * variables.tableRowPaddingHorizontal;
43+
const chevronWidth = linkColumnIndex === undefined ? 0 : variables.htmlTableChevronColumnWidth;
44+
const columnsWidth = columnAlignments.length * variables.htmlTableColumnMaxWidth + 2 * variables.tableRowPaddingHorizontal + chevronWidth;
4145
const contentWidth = Math.max(viewportWidth, columnsWidth);
4246

4347
return (
4448
<TableColumnAlignmentContext.Provider value={columnAlignments}>
45-
<HTMLTableScroll
46-
viewportWidth={viewportWidth}
47-
contentWidth={contentWidth}
48-
>
49-
<TableChildrenRenderer tnode={tnode} />
50-
</HTMLTableScroll>
49+
<TableLinkColumnContext.Provider value={linkColumnIndex}>
50+
<HTMLTableScroll
51+
viewportWidth={viewportWidth}
52+
contentWidth={contentWidth}
53+
>
54+
<TableChildrenRenderer tnode={tnode} />
55+
</HTMLTableScroll>
56+
</TableLinkColumnContext.Provider>
5157
</TableColumnAlignmentContext.Provider>
5258
);
5359
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {TNode} from 'react-native-render-html';
2+
3+
import {getElementChildren} from './TableChildrenRenderer';
4+
5+
function getBodyRows(tableTnode: TNode): TNode[] {
6+
return getElementChildren(tableTnode)
7+
.filter((section) => section.tagName === 'tbody')
8+
.flatMap((section) => getElementChildren(section));
9+
}
10+
11+
function findAnchor(node: TNode): TNode | undefined {
12+
if (node.tagName === 'a') {
13+
return node;
14+
}
15+
return (node.children ?? []).reduce<TNode | undefined>((found, child) => found ?? findAnchor(child), undefined);
16+
}
17+
18+
/**
19+
* The single column whose cells hold links, or undefined when the table has no such column.
20+
*
21+
* 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.
24+
*/
25+
function getLinkColumnIndex(tableTnode: TNode): number | undefined {
26+
const columnsWithLinks = new Set<number>();
27+
for (const row of getBodyRows(tableTnode)) {
28+
for (const [columnIndex, cell] of getElementChildren(row).entries()) {
29+
if (findAnchor(cell)) {
30+
columnsWithLinks.add(columnIndex);
31+
}
32+
}
33+
}
34+
35+
return columnsWithLinks.size === 1 ? columnsWithLinks.values().next().value : undefined;
36+
}
37+
38+
/**
39+
* Whether an anchor sits in the column whose links make the row navigable, in which case it renders as plain text
40+
* instead of a link. The anchor can be nested any number of levels below its cell.
41+
*/
42+
function isLinkColumnAnchor(anchorTnode: TNode, linkColumnIndex: number | undefined): boolean {
43+
if (linkColumnIndex === undefined) {
44+
return false;
45+
}
46+
47+
let cell = anchorTnode.parent;
48+
while (cell && cell.tagName !== 'td') {
49+
// A cell is never nested in another row, so reaching one means this anchor is not inside a body cell.
50+
if (cell.tagName === 'tr') {
51+
return false;
52+
}
53+
cell = cell.parent;
54+
}
55+
56+
const row = cell?.parent;
57+
if (!cell || row?.parent?.tagName !== 'tbody') {
58+
return false;
59+
}
60+
61+
return getElementChildren(row).indexOf(cell) === linkColumnIndex;
62+
}
63+
64+
/** The plain text of a node, used to render a link column's cell without any of the anchor's own styling. */
65+
function getTextContent(node: TNode): string {
66+
if ('data' in node && typeof node.data === 'string') {
67+
return node.data;
68+
}
69+
return (node.children ?? []).map(getTextContent).join('');
70+
}
71+
72+
/** The URL a row navigates to: the href of the link in the table's link column. */
73+
function getRowLinkURL(rowTnode: TNode, linkColumnIndex: number | undefined): string | undefined {
74+
if (linkColumnIndex === undefined || rowTnode.parent?.tagName !== 'tbody') {
75+
return undefined;
76+
}
77+
78+
const linkCell = getElementChildren(rowTnode).at(linkColumnIndex);
79+
return linkCell ? findAnchor(linkCell)?.attributes?.href : undefined;
80+
}
81+
82+
export {getLinkColumnIndex, getRowLinkURL, getTextContent, isLinkColumnAnchor};

src/components/HTMLEngineProvider/HTMLRenderers/TableRowRenderer.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1+
import Icon from '@components/Icon';
2+
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
3+
import {showContextMenuForReport, useShowContextMenuActions, useShowContextMenuState} from '@components/ShowContextMenuContext';
4+
5+
import useEnvironment from '@hooks/useEnvironment';
6+
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
7+
import useLocalize from '@hooks/useLocalize';
8+
import useTheme from '@hooks/useTheme';
19
import useThemeStyles from '@hooks/useThemeStyles';
210

11+
import {openLink} from '@libs/actions/Link';
12+
13+
import CONST from '@src/CONST';
14+
315
import type {CustomRendererProps, TBlock, TNode} from 'react-native-render-html';
416

17+
import {useContext} from 'react';
518
import {View} from 'react-native';
619

720
import TableChildrenRenderer, {getElementChildren} from './TableChildrenRenderer';
21+
import TableLinkColumnContext from './TableLinkColumnContext';
22+
import {getRowLinkURL} from './TableRowLink';
823

924
function isLastRowOfTable(tnode: TNode): boolean {
1025
const section = tnode.parent;
@@ -19,14 +34,59 @@ function isLastRowOfTable(tnode: TNode): boolean {
1934

2035
function TableRowRenderer({tnode}: CustomRendererProps<TBlock>) {
2136
const styles = useThemeStyles();
37+
const theme = useTheme();
38+
const {translate} = useLocalize();
39+
const {environmentURL} = useEnvironment();
40+
const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']);
41+
const {anchor, report, action, originalReportID} = useShowContextMenuState();
42+
const {onShowContextMenu, checkIfContextMenuActive} = useShowContextMenuActions();
2243

2344
// Header rows (inside <thead>) use header padding; body rows use the compact min-height.
2445
const isHeaderRow = tnode.parent?.tagName === 'thead';
46+
const linkColumnIndex = useContext(TableLinkColumnContext);
47+
const rowLinkURL = getRowLinkURL(tnode, linkColumnIndex);
48+
49+
const rowStyle = [isHeaderRow ? styles.htmlTableHeaderRow : styles.htmlTableRow, isLastRowOfTable(tnode) && styles.htmlTableLastRow];
50+
51+
// Every row of a table with a link column reserves the chevron width so the columns stay aligned, but only a row
52+
// that actually navigates shows the chevron.
53+
const chevron =
54+
linkColumnIndex === undefined ? null : (
55+
<View style={styles.htmlTableChevronCell}>
56+
{!!rowLinkURL && (
57+
<Icon
58+
src={icons.ArrowRight}
59+
fill={theme.icon}
60+
size={CONST.ICON_SIZE.SMALL}
61+
/>
62+
)}
63+
</View>
64+
);
65+
66+
if (!rowLinkURL) {
67+
return (
68+
<View style={rowStyle}>
69+
<TableChildrenRenderer tnode={tnode} />
70+
{chevron}
71+
</View>
72+
);
73+
}
2574

2675
return (
27-
<View style={[isHeaderRow ? styles.htmlTableHeaderRow : styles.htmlTableRow, isLastRowOfTable(tnode) && styles.htmlTableLastRow]}>
76+
<PressableWithoutFeedback
77+
style={rowStyle}
78+
hoverStyle={styles.htmlTableRowHovered}
79+
onPress={() => openLink(rowLinkURL, environmentURL)}
80+
onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, originalReportID))}
81+
role={CONST.ROLE.BUTTON}
82+
accessibilityLabel={translate('iou.viewDetails')}
83+
sentryLabel={CONST.SENTRY_LABEL.HTML_RENDERER.TABLE_ROW}
84+
shouldUseHapticsOnLongPress
85+
isNested
86+
>
2887
<TableChildrenRenderer tnode={tnode} />
29-
</View>
88+
{chevron}
89+
</PressableWithoutFeedback>
3090
);
3191
}
3292

src/styles/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2724,6 +2724,17 @@ const staticStyles = (theme: ThemeColors) =>
27242724
borderBottomWidth: 0,
27252725
},
27262726

2727+
// A step past the hover background the surrounding comment uses, so a hovered row stays distinguishable
2728+
// while the whole comment is also highlighted.
2729+
htmlTableRowHovered: {
2730+
backgroundColor: theme.activeComponentBG,
2731+
},
2732+
2733+
htmlTableChevronCell: {
2734+
width: variables.htmlTableChevronColumnWidth,
2735+
alignItems: 'flex-end',
2736+
},
2737+
27272738
htmlTableCell: {
27282739
// A definite flexBasis with flexShrink: 0 gives every column a fixed width so a wide table keeps its size
27292740
// and can be scrolled horizontally, and columns stay aligned across rows; flexGrow: 1 still lets columns

src/styles/variables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export default {
141141
htmlTableHeaderRowMinHeight: 30,
142142
htmlTableColumnMinWidth: 120,
143143
htmlTableColumnMaxWidth: 240,
144+
htmlTableChevronColumnWidth: 20,
144145
tableGroupRowPaddingVertical: 4,
145146
tableGroupRowHeight: 36,
146147
tableCheckboxColumnWidth: 20,

0 commit comments

Comments
 (0)