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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import {rand64} from '@libs/NumberUtils';
import Parser from '@libs/Parser';
import {escapeTagName} from '@libs/PolicyUtils';
import {trimTag} from '@libs/TagUtils';
import {getTagArrayFromName} from '@libs/TransactionUtils';

import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
Expand Down Expand Up @@ -60,6 +63,31 @@ function getRuleContentKey(rule: Pick<CodingRule, 'filters' | 'merchant' | 'cate
]);
}

/**
* Normalizes an imported tag cell into the encoding the manual "Add tag" flow stores.
*
* On a multi-level policy ":" separates levels, so the levels are trimmed and re-joined (e.g. "Parent: Child" →
* "Parent:Child"), matching trimTag(levels.join(':')). Without this, the cell would be persisted verbatim and later
* split into ["Parent", " Child"] on display, so the rule row would render "Parent, Child" and the tag field would
* only resolve the first level.
*
* On a single-level policy the whole cell is one literal tag name, so its colons are escaped instead (e.g. "ab:cd" →
* "ab\:cd"), matching how tag names are stored on the policy. Internal spaces are part of the name and are preserved.
*/
function normalizeImportedTag(tag: string, hasMultipleTagLists: boolean): string {
if (!tag) {
return '';
}
if (!hasMultipleTagLists) {
return escapeTagName(tag);
}
return trimTag(
getTagArrayFromName(tag)
.map((level) => level.trim())
.join(':'),
);
}

/** Parses a CSV cell into a boolean, or undefined when the cell is empty or unrecognized so the field is left unset */
function parseCsvBooleanValue(raw: string | undefined): boolean | undefined {
const trimmed = raw?.trim().toLowerCase() ?? '';
Expand Down Expand Up @@ -173,7 +201,7 @@ function ImportedMerchantRulesPage({route}: ImportedMerchantRulesPageProps) {

const updatedMerchant = getCellValue(updatedMerchantColumn, rowIndex);
const category = getCellValue(categoryColumn, rowIndex);
const tag = getCellValue(tagColumn, rowIndex);
const tag = normalizeImportedTag(getCellValue(tagColumn, rowIndex), !!policy?.hasMultipleTagLists);
const comment = getCellValue(commentColumn, rowIndex);
const reimbursable = parseCsvBooleanValue(getCellValue(reimbursableColumn, rowIndex));
const billable = parseCsvBooleanValue(getCellValue(billableColumn, rowIndex));
Expand Down Expand Up @@ -264,3 +292,4 @@ function ImportedMerchantRulesPage({route}: ImportedMerchantRulesPageProps) {
}

export default ImportedMerchantRulesPage;
export {normalizeImportedTag};
57 changes: 57 additions & 0 deletions tests/unit/ImportedMerchantRulesPageTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {normalizeImportedTag} from '@pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage';

describe('ImportedMerchantRulesPage', () => {
describe('normalizeImportedTag', () => {
describe('multi-level tag policy', () => {
it('collapses the space after the colon in a multi-level tag', () => {
expect(normalizeImportedTag('Parent: Child', true)).toBe('Parent:Child');
});

it('trims surrounding spaces around every level', () => {
expect(normalizeImportedTag(' Parent : Child : Grandchild ', true)).toBe('Parent:Child:Grandchild');
});

it('leaves a single-level tag unchanged', () => {
expect(normalizeImportedTag('Travel', true)).toBe('Travel');
});

it('preserves internal spaces within a level', () => {
expect(normalizeImportedTag('North America: New York', true)).toBe('North America:New York');
});

it('preserves an already canonical multi-level tag', () => {
expect(normalizeImportedTag('Parent:Child', true)).toBe('Parent:Child');
});

it('preserves escaped colons within a single level', () => {
expect(normalizeImportedTag('Time\\: Tracking', true)).toBe('Time\\: Tracking');
});

it('returns an empty string for an empty cell', () => {
expect(normalizeImportedTag('', true)).toBe('');
});
});

describe('single-level tag policy', () => {
it('escapes a colon so the cell stays one literal tag name', () => {
expect(normalizeImportedTag('ab:cd', false)).toBe('ab\\:cd');
});

it('preserves internal spaces around a colon in the tag name', () => {
expect(normalizeImportedTag('ab: cd', false)).toBe('ab\\: cd');
});

it('escapes every colon in the tag name', () => {
expect(normalizeImportedTag('a:b:c', false)).toBe('a\\:b\\:c');
});

it('leaves a tag without colons unchanged', () => {
expect(normalizeImportedTag('Travel', false)).toBe('Travel');
});

it('returns an empty string for an empty cell', () => {
expect(normalizeImportedTag('', false)).toBe('');
});
});
});
});
Loading