Skip to content

Commit 7acf8b2

Browse files
authored
Merge pull request Expensify#85603 from Expensify/claude-fixCategoryGroupingEncoding
Decode category/tag strings before grouping in report layout
2 parents 93d3bdd + bc02597 commit 7acf8b2

6 files changed

Lines changed: 70 additions & 9 deletions

File tree

src/components/TransactionItemRow/DataCells/TagCell.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
88
import useOnyx from '@hooks/useOnyx';
99
import useThemeStyles from '@hooks/useThemeStyles';
1010
import {hasDependentTags} from '@libs/PolicyUtils';
11+
import {getDecodedTagName} from '@libs/TagUtils';
1112
import {getTagForDisplay} from '@libs/TransactionUtils';
1213
import ONYXKEYS from '@src/ONYXKEYS';
1314
import type TransactionDataCellProps from './TransactionDataCellProps';
@@ -32,7 +33,8 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra
3233
onSave,
3334
});
3435

35-
const tagForDisplay = getTagForDisplay(transactionItem);
36+
// Decode HTML entities so tags stored with encoding are displayed properly (e.g. `uno & dos` display as `uno & dos`)
37+
const tagForDisplay = getDecodedTagName(getTagForDisplay(transactionItem));
3638

3739
const displayContent = shouldUseNarrowLayout ? (
3840
<TextWithIconCell

src/libs/PolicyUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -854,10 +854,11 @@ function getTagNamesFromTagsLists(policyTagLists: PolicyTagLists): string[] {
854854
}
855855

856856
/**
857-
* Cleans up escaping of colons (used to create multi-level tags, e.g. "Parent: Child") in the tag name we receive from the backend
857+
* Cleans up escaping of colons used to create multi-level tags (e.g. "Parent: Child"),
858+
* and HTML-decodes the result so tags stored with encoded entities display correctly (e.g. `R&amp;D`, renders as `R&D`)
858859
*/
859860
function getCleanedTagName(tag: string) {
860-
return tag?.replaceAll('\\:', CONST.COLON);
861+
return Str.htmlDecode(tag?.replaceAll('\\:', CONST.COLON) ?? '');
861862
}
862863

863864
/**

src/libs/ReportLayoutUtils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {GroupedTransactions} from '@src/types/onyx';
44
import type Report from '@src/types/onyx/Report';
55
import type Transaction from '@src/types/onyx/Transaction';
66
import {getDecodedCategoryName, isCategoryMissing} from './CategoryUtils';
7-
import {isTagMissing} from './TagUtils';
7+
import {getDecodedTagName, isTagMissing} from './TagUtils';
88
import {getAmount, getCategory, getCurrency, getTag, isTransactionPendingDelete} from './TransactionUtils';
99

1010
/**
@@ -64,7 +64,7 @@ function groupTransactionsByCategory(transactions: Transaction[], report: OnyxEn
6464

6565
for (const transaction of transactions) {
6666
const category = getCategory(transaction);
67-
const categoryKey = isCategoryMissing(category) ? '' : category;
67+
const categoryKey = isCategoryMissing(category) ? '' : getDecodedCategoryName(category);
6868

6969
if (!groups.has(categoryKey)) {
7070
groups.set(categoryKey, []);
@@ -75,7 +75,7 @@ function groupTransactionsByCategory(transactions: Transaction[], report: OnyxEn
7575
const result: GroupedTransactions[] = [];
7676
for (const [categoryKey, transactionList] of groups) {
7777
result.push({
78-
groupName: categoryKey ? getDecodedCategoryName(categoryKey) : categoryKey,
78+
groupName: categoryKey,
7979
groupKey: categoryKey,
8080
transactions: transactionList,
8181
subTotalAmount: calculateGroupTotal(transactionList, reportCurrency),
@@ -99,7 +99,7 @@ function groupTransactionsByTag(transactions: Transaction[], report: OnyxEntry<R
9999

100100
for (const transaction of transactions) {
101101
const tag = getTag(transaction);
102-
const tagKey = isTagMissing(tag) ? '' : tag;
102+
const tagKey = isTagMissing(tag) ? '' : getDecodedTagName(tag);
103103

104104
if (!groups.has(tagKey)) {
105105
groups.set(tagKey, []);

src/libs/TagUtils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {Str} from 'expensify-common';
12
import CONST from '@src/CONST';
23

34
/**
@@ -19,4 +20,12 @@ function trimTag(tag: string): string {
1920
return tagWithoutEscapedColons.replace(/:*$/, '').replaceAll('☢', '\\:');
2021
}
2122

22-
export {isTagMissing, trimTag};
23+
/**
24+
* HTML-decodes a tag name so values stored with different encodings are displayed correctly (e.g. `R&amp;D` vs `R&D`)
25+
* Mirrors getDecodedCategoryName in CategoryUtils.
26+
*/
27+
function getDecodedTagName(tagName: string): string {
28+
return Str.htmlDecode(tagName);
29+
}
30+
31+
export {isTagMissing, trimTag, getDecodedTagName};

tests/unit/ReportLayoutUtilsTest.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,21 @@ describe('groupTransactionsByCategory', () => {
229229
expect(travelGroup?.subTotalAmount).toBe(1000);
230230
expect(travelGroup?.transactions).toHaveLength(2);
231231
});
232+
233+
it('groups transactions with HTML-encoded and decoded category names into a single group', () => {
234+
const report = createMockReport({currency: 'USD'});
235+
const transactions = [
236+
createMockTransaction({transactionID: '1', category: 'Auto (including Tolls &amp; Parking)', amount: -1000, currency: 'USD'}),
237+
createMockTransaction({transactionID: '2', category: 'Auto (including Tolls & Parking)', amount: -2000, currency: 'USD'}),
238+
];
239+
240+
const result = groupTransactionsByCategory(transactions, report, mockLocaleCompare);
241+
242+
expect(result).toHaveLength(1);
243+
expect(result.at(0)?.groupKey).toBe('Auto (including Tolls & Parking)');
244+
expect(result.at(0)?.transactions).toHaveLength(2);
245+
expect(result.at(0)?.subTotalAmount).toBe(3000);
246+
});
232247
});
233248

234249
describe('groupTransactionsByTag', () => {
@@ -434,4 +449,19 @@ describe('groupTransactionsByTag', () => {
434449
expect(projectAGroup?.subTotalAmount).toBe(1000);
435450
expect(projectAGroup?.transactions).toHaveLength(2);
436451
});
452+
453+
it('groups transactions with HTML-encoded and decoded tag names into a single group', () => {
454+
const report = createMockReport({currency: 'USD'});
455+
const transactions = [
456+
createMockTransaction({transactionID: '1', tag: 'R&amp;D', amount: -1000, currency: 'USD'}),
457+
createMockTransaction({transactionID: '2', tag: 'R&D', amount: -2000, currency: 'USD'}),
458+
];
459+
460+
const result = groupTransactionsByTag(transactions, report, mockLocaleCompare);
461+
462+
expect(result).toHaveLength(1);
463+
expect(result.at(0)?.groupKey).toBe('R&D');
464+
expect(result.at(0)?.transactions).toHaveLength(2);
465+
expect(result.at(0)?.subTotalAmount).toBe(3000);
466+
});
437467
});

tests/unit/TagUtilsTest.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {isTagMissing, trimTag} from '@libs/TagUtils';
1+
import {getDecodedTagName, isTagMissing, trimTag} from '@libs/TagUtils';
22
import CONST from '@src/CONST';
33

44
describe('TagUtils', () => {
@@ -68,4 +68,23 @@ describe('TagUtils', () => {
6868
expect(trimTag('tag\\:name\\\\::')).toBe('tag\\:name\\\\:');
6969
});
7070
});
71+
72+
describe('getDecodedTagName', () => {
73+
it('decodes &amp; to &', () => {
74+
expect(getDecodedTagName('R&amp;D')).toBe('R&D');
75+
});
76+
77+
it('returns an unencoded string unchanged', () => {
78+
expect(getDecodedTagName('R&D')).toBe('R&D');
79+
});
80+
81+
it('returns an empty string when input is empty', () => {
82+
expect(getDecodedTagName('')).toBe('');
83+
});
84+
85+
it('decodes other common HTML entities', () => {
86+
expect(getDecodedTagName('a &lt; b &gt; c')).toBe('a < b > c');
87+
expect(getDecodedTagName('&quot;hello&quot;')).toBe('"hello"');
88+
});
89+
});
7190
});

0 commit comments

Comments
 (0)