Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/components/TransactionItemRow/DataCells/TagCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import {hasDependentTags} from '@libs/PolicyUtils';
import {getDecodedTagName} from '@libs/TagUtils';
import {getTagForDisplay} from '@libs/TransactionUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import type TransactionDataCellProps from './TransactionDataCellProps';
Expand All @@ -32,7 +33,8 @@ function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, tra
cancelEditing();
};

const tagForDisplay = getTagForDisplay(transactionItem);
// 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 ? (
<TextWithIconCell
Expand Down
5 changes: 3 additions & 2 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,10 +854,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"),
* and HTML-decodes the result so tags stored with encoded entities display correctly (e.g. `R&amp;D`, renders as `R&D`)
*/
function getCleanedTagName(tag: string) {
return tag?.replaceAll('\\:', CONST.COLON);
return Str.htmlDecode(tag?.replaceAll('\\:', CONST.COLON) ?? '');
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/libs/ReportLayoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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';

/**
Expand Down Expand Up @@ -64,7 +64,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, []);
Expand All @@ -75,7 +75,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),
Expand All @@ -99,7 +99,7 @@ function groupTransactionsByTag(transactions: Transaction[], report: OnyxEntry<R

for (const transaction of transactions) {
const tag = getTag(transaction);
const tagKey = isTagMissing(tag) ? '' : tag;
const tagKey = isTagMissing(tag) ? '' : getDecodedTagName(tag);

if (!groups.has(tagKey)) {
groups.set(tagKey, []);
Expand Down
11 changes: 10 additions & 1 deletion src/libs/TagUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Str} from 'expensify-common';
import CONST from '@src/CONST';

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

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

export {isTagMissing, trimTag, getDecodedTagName};
30 changes: 30 additions & 0 deletions tests/unit/ReportLayoutUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,21 @@ describe('groupTransactionsByCategory', () => {
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 &amp; 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', () => {
Expand Down Expand Up @@ -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&amp;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);
});
});
21 changes: 20 additions & 1 deletion tests/unit/TagUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {isTagMissing, trimTag} from '@libs/TagUtils';
import {getDecodedTagName, isTagMissing, trimTag} from '@libs/TagUtils';
import CONST from '@src/CONST';

describe('TagUtils', () => {
Expand Down Expand Up @@ -68,4 +68,23 @@ describe('TagUtils', () => {
expect(trimTag('tag\\:name\\\\::')).toBe('tag\\:name\\\\:');
});
});

describe('getDecodedTagName', () => {
it('decodes &amp; to &', () => {
expect(getDecodedTagName('R&amp;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 &lt; b &gt; c')).toBe('a < b > c');
expect(getDecodedTagName('&quot;hello&quot;')).toBe('"hello"');
});
});
});
Loading