From 516ee38834328d7ea7951562a046fb894922fa4a Mon Sep 17 00:00:00 2001 From: "Lydia Barclay (via MelvinBot)" Date: Tue, 17 Mar 2026 22:00:54 +0000 Subject: [PATCH 1/4] Decode category and tag strings before grouping in report layout Transactions with the same category but different HTML encoding (e.g., Uber & car washes vs Uber & car washes) were being grouped separately in the expense report view, despite displaying the same group name. This happened because the raw category string was used as the Map key for grouping, while the displayed name was HTML-decoded. Now we decode category/tag strings before using them as grouping keys, matching how OldDot already handles this in lib_report.js. Related Expensify/Expensify#612034 Co-authored-by: Lydia Barclay --- src/libs/ReportLayoutUtils.ts | 7 ++++--- tests/unit/ReportLayoutUtilsTest.ts | 30 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/libs/ReportLayoutUtils.ts b/src/libs/ReportLayoutUtils.ts index 3b9f2cceb4ac..407ac6c8a7cb 100644 --- a/src/libs/ReportLayoutUtils.ts +++ b/src/libs/ReportLayoutUtils.ts @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {GroupedTransactions} from '@src/types/onyx'; @@ -64,7 +65,7 @@ function groupTransactionsByCategory(transactions: Transaction[], report: OnyxEn for (const transaction of transactions) { const category = getCategory(transaction); - const categoryKey = isCategoryMissing(category) ? '' : category; + const categoryKey = isCategoryMissing(category) ? '' : getDecodedCategoryName(category); if (!groups.has(categoryKey)) { groups.set(categoryKey, []); @@ -75,7 +76,7 @@ function groupTransactionsByCategory(transactions: Transaction[], report: OnyxEn const result: GroupedTransactions[] = []; for (const [categoryKey, transactionList] of groups) { result.push({ - groupName: categoryKey ? getDecodedCategoryName(categoryKey) : categoryKey, + groupName: categoryKey, groupKey: categoryKey, transactions: transactionList, subTotalAmount: calculateGroupTotal(transactionList, reportCurrency), @@ -99,7 +100,7 @@ function groupTransactionsByTag(transactions: Transaction[], report: OnyxEntry { expect(travelGroup?.subTotalAmount).toBe(1000); expect(travelGroup?.transactions).toHaveLength(2); }); + + it('groups transactions with HTML-encoded and decoded category names into a single group', () => { + const report = createMockReport({currency: 'USD'}); + const transactions = [ + createMockTransaction({transactionID: '1', category: 'Auto (including Tolls & Parking)', amount: -1000, currency: 'USD'}), + createMockTransaction({transactionID: '2', category: 'Auto (including Tolls & Parking)', amount: -2000, currency: 'USD'}), + ]; + + const result = groupTransactionsByCategory(transactions, report, mockLocaleCompare); + + expect(result).toHaveLength(1); + expect(result.at(0)?.groupKey).toBe('Auto (including Tolls & Parking)'); + expect(result.at(0)?.transactions).toHaveLength(2); + expect(result.at(0)?.subTotalAmount).toBe(3000); + }); }); describe('groupTransactionsByTag', () => { @@ -434,4 +449,19 @@ describe('groupTransactionsByTag', () => { expect(projectAGroup?.subTotalAmount).toBe(1000); expect(projectAGroup?.transactions).toHaveLength(2); }); + + it('groups transactions with HTML-encoded and decoded tag names into a single group', () => { + const report = createMockReport({currency: 'USD'}); + const transactions = [ + createMockTransaction({transactionID: '1', tag: 'R&D', amount: -1000, currency: 'USD'}), + createMockTransaction({transactionID: '2', tag: 'R&D', amount: -2000, currency: 'USD'}), + ]; + + const result = groupTransactionsByTag(transactions, report, mockLocaleCompare); + + expect(result).toHaveLength(1); + expect(result.at(0)?.groupKey).toBe('R&D'); + expect(result.at(0)?.transactions).toHaveLength(2); + expect(result.at(0)?.subTotalAmount).toBe(3000); + }); }); From 0f104f251444756e36c76659815f65f6f9864b6b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 12 May 2026 03:21:34 +0300 Subject: [PATCH 2/4] Decode tag in TagCell so tag column matches group-by-tag dropdown Addresses review feedback on PR #85603: - cead22: tag column showed raw HTML-encoded value (e.g. `uno & dos`) while the group-by-tag dropdown decoded it. TagCell now decodes the display value, mirroring how CategoryCell already handles categories. - github-actions CONSISTENCY-3: extract `getDecodedTagName` into TagUtils (mirroring `getDecodedCategoryName` in CategoryUtils) and use it from ReportLayoutUtils instead of importing `Str` directly. Keeps the tag / category decoding pattern symmetric across the codebase. Adds unit tests for `getDecodedTagName`. --- .../TransactionItemRow/DataCells/TagCell.tsx | 9 ++++++-- src/libs/ReportLayoutUtils.ts | 5 ++--- src/libs/TagUtils.ts | 11 +++++++++- tests/unit/TagUtilsTest.ts | 21 ++++++++++++++++++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx index ebbabd194aec..5cccab38d2b5 100644 --- a/src/components/TransactionItemRow/DataCells/TagCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx @@ -3,23 +3,28 @@ import TextWithIconCell from '@components/Search/SearchList/ListItem/TextWithIco import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getDecodedTagName} from '@libs/TagUtils'; import {getTagForDisplay} from '@libs/TransactionUtils'; import type TransactionDataCellProps from './TransactionDataCellProps'; function TagCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem}: TransactionDataCellProps) { const icons = useMemoizedLazyExpensifyIcons(['Tag']); const styles = useThemeStyles(); + // Decode HTML entities so tags stored with encoding (e.g. `uno & dos`) display as `uno & dos`, + // matching the report's group-by-tag dropdown which already decodes the value. + const tagForDisplay = getDecodedTagName(getTagForDisplay(transactionItem)); + return shouldUseNarrowLayout ? ( ) : ( diff --git a/src/libs/ReportLayoutUtils.ts b/src/libs/ReportLayoutUtils.ts index 407ac6c8a7cb..f2057e0afe94 100644 --- a/src/libs/ReportLayoutUtils.ts +++ b/src/libs/ReportLayoutUtils.ts @@ -1,11 +1,10 @@ -import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {GroupedTransactions} from '@src/types/onyx'; import type Report from '@src/types/onyx/Report'; import type Transaction from '@src/types/onyx/Transaction'; import {getDecodedCategoryName, isCategoryMissing} from './CategoryUtils'; -import {isTagMissing} from './TagUtils'; +import {getDecodedTagName, isTagMissing} from './TagUtils'; import {getAmount, getCategory, getCurrency, getTag, isTransactionPendingDelete} from './TransactionUtils'; /** @@ -100,7 +99,7 @@ function groupTransactionsByTag(transactions: Transaction[], report: OnyxEntry { @@ -68,4 +68,23 @@ describe('TagUtils', () => { expect(trimTag('tag\\:name\\\\::')).toBe('tag\\:name\\\\:'); }); }); + + describe('getDecodedTagName', () => { + it('decodes & to &', () => { + expect(getDecodedTagName('R&D')).toBe('R&D'); + }); + + it('returns an unencoded string unchanged', () => { + expect(getDecodedTagName('R&D')).toBe('R&D'); + }); + + it('returns an empty string when input is empty', () => { + expect(getDecodedTagName('')).toBe(''); + }); + + it('decodes other common HTML entities', () => { + expect(getDecodedTagName('a < b > c')).toBe('a < b > c'); + expect(getDecodedTagName('"hello"')).toBe('"hello"'); + }); + }); }); From f875ff9e5e25722389019329c89afc9c397fc049 Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Sun, 17 May 2026 22:16:38 +0000 Subject: [PATCH 3/4] HTML-decode tag names in getCleanedTagName for consistent display Add Str.htmlDecode to getCleanedTagName so all tag display surfaces (picker, workspace list, edit screens, search filters, group headers, modified-expense messages, sidebar previews) render decoded entities like R&D instead of raw R&D. Mirrors how getDecodedCategoryName handles categories in CategoryUtils. Co-authored-by: Abdelrahman Khattab --- src/libs/PolicyUtils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0521d0912dfd..1d8030a65110 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -857,10 +857,11 @@ function getTagNamesFromTagsLists(policyTagLists: PolicyTagLists): string[] { } /** - * Cleans up escaping of colons (used to create multi-level tags, e.g. "Parent: Child") in the tag name we receive from the backend + * Cleans up escaping of colons (used to create multi-level tags, e.g. "Parent: Child") in the tag name we receive from the backend, + * and HTML-decodes the result so tags stored with encoded entities (e.g. `R&D`) render as `R&D`. */ function getCleanedTagName(tag: string) { - return tag?.replaceAll('\\:', CONST.COLON); + return Str.htmlDecode(tag?.replaceAll('\\:', CONST.COLON) ?? ''); } /** From bc02597664f7bae3e0c2a34a1426b51331fb9cbd Mon Sep 17 00:00:00 2001 From: "Abdelrahman Khattab (via MelvinBot)" Date: Mon, 18 May 2026 13:32:18 +0000 Subject: [PATCH 4/4] Clean up AI-generated comments per review feedback Simplify comment wording in TagCell.tsx, PolicyUtils.ts, and TagUtils.ts to be more concise and less verbose. Co-authored-by: Abdelrahman Khattab --- src/components/TransactionItemRow/DataCells/TagCell.tsx | 3 +-- src/libs/PolicyUtils.ts | 4 ++-- src/libs/TagUtils.ts | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx index 631cc973708e..bc4d5f339f43 100644 --- a/src/components/TransactionItemRow/DataCells/TagCell.tsx +++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx @@ -33,8 +33,7 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra cancelEditing(); }; - // Decode HTML entities so tags stored with encoding (e.g. `uno & dos`) display as `uno & dos`, - // matching the report's group-by-tag dropdown which already decodes the value. + // Decode HTML entities so tags stored with encoding are displayed properly (e.g. `uno & dos` display as `uno & dos`) const tagForDisplay = getDecodedTagName(getTagForDisplay(transactionItem)); const displayContent = shouldUseNarrowLayout ? ( diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index adf9797f9180..9b50d87f61d2 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -854,8 +854,8 @@ function getTagNamesFromTagsLists(policyTagLists: PolicyTagLists): string[] { } /** - * Cleans up escaping of colons (used to create multi-level tags, e.g. "Parent: Child") in the tag name we receive from the backend, - * and HTML-decodes the result so tags stored with encoded entities (e.g. `R&D`) render as `R&D`. + * Cleans up escaping of colons used to create multi-level tags (e.g. "Parent: Child"), + * and HTML-decodes the result so tags stored with encoded entities display correctly (e.g. `R&D`, renders as `R&D`) */ function getCleanedTagName(tag: string) { return Str.htmlDecode(tag?.replaceAll('\\:', CONST.COLON) ?? ''); diff --git a/src/libs/TagUtils.ts b/src/libs/TagUtils.ts index 08c4d79b2024..dfa1e2517c43 100644 --- a/src/libs/TagUtils.ts +++ b/src/libs/TagUtils.ts @@ -21,8 +21,8 @@ function trimTag(tag: string): string { } /** - * HTML-decodes a tag name so values stored with different encodings (e.g. `R&D` vs `R&D`) - * resolve to the same string. Mirrors getDecodedCategoryName in CategoryUtils. + * HTML-decodes a tag name so values stored with different encodings are displayed correctly (e.g. `R&D` vs `R&D`) + * Mirrors getDecodedCategoryName in CategoryUtils. */ function getDecodedTagName(tagName: string): string { return Str.htmlDecode(tagName);