Skip to content
20 changes: 20 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,22 @@ function validateObject<T extends Record<string, unknown>>(value: string, type:
}
}

/**
* Validates that a value is a flat object mapping string keys to string values (e.g. Record<string, string>).
*/
function validateStringRecord(value: string) {
if (isEmptyValue(value)) {
return;
}

const object = parseJSON(value);
if (typeof object !== 'object' || object === null || Array.isArray(object) || Object.values(object).some((val) => typeof val !== 'string')) {
throw new SyntaxError('debug.invalidValue', {
cause: {expectedValues: 'Record<string, string> | undefined'},
});
}
}

/**
* Validates if a string is a valid representation of a string.
*/
Expand Down Expand Up @@ -1032,6 +1048,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
return validateConstantEnum(value, CONST.IOU.REQUEST_TYPE);
case 'selectedTransactionIDs':
return validateArray(value, 'string');
case 'bulkEditTagChanges':
return validateStringRecord(value);
case 'participants':
return validateArray<ArrayElement<Transaction, 'participants'>>(value, {
accountID: 'number',
Expand Down Expand Up @@ -1132,6 +1150,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
routeDistanceMeters: CONST.RED_BRICK_ROAD_PENDING_ACTION,
transactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
selectedTransactionIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION,
bulkEditTagChanges: CONST.RED_BRICK_ROAD_PENDING_ACTION,
tag: CONST.RED_BRICK_ROAD_PENDING_ACTION,
transactionType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION,
Expand Down Expand Up @@ -1602,6 +1621,7 @@ const DebugUtils = {
validateDate,
validateConstantEnum,
validateArray,
validateStringRecord,
validateObject,
validateString,
validateReportDraftProperty,
Expand Down
33 changes: 31 additions & 2 deletions src/libs/actions/IOU/BulkEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isSelfDM,
shouldEnableNegative,
} from '@libs/ReportUtils';
import {getUpdatedTransactionTag} from '@libs/TagsOptionsListUtils';
import {
calculateTaxAmount,
getAmount,
Expand Down Expand Up @@ -88,6 +89,8 @@ function removeUnchangedBulkEditFields(
type UpdateMultipleMoneyRequestsParams = {
transactionIDs: string[];
changes: TransactionChanges;
/** Per-level tag edits from the bulk-edit draft, keyed by tag list index. */
bulkEditTagChanges?: Record<string, string>;
policy: OnyxEntry<OnyxTypes.Policy>;
reports: OnyxCollection<OnyxTypes.Report>;
transactions: OnyxCollection<OnyxTypes.Transaction>;
Expand All @@ -109,6 +112,7 @@ type UpdateMultipleMoneyRequestsParams = {
function updateMultipleMoneyRequests({
transactionIDs,
changes,
bulkEditTagChanges,
policy,
reports,
transactions,
Expand Down Expand Up @@ -235,8 +239,33 @@ function updateMultipleMoneyRequests({
if (changes.category !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) {
transactionChanges.category = changes.category;
}
if (changes.tag && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) {
transactionChanges.tag = changes.tag;
const editedTagIndexes = bulkEditTagChanges ? Object.keys(bulkEditTagChanges) : [];
if ((changes.tag || editedTagIndexes.length > 0) && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) {
if (editedTagIndexes.length > 0) {
// Rebuild the tag from THIS transaction's own tag so levels the user didn't touch are
// preserved, instead of overwriting every level with one shared common-prefix string.
// Apply each edited level in ascending order because editing a parent may clear its
// dependent children, and pass an empty currentTag so the selected value is always a
// fresh selection at that level rather than a per-transaction deselect.
const transactionPolicyTagList = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${transactionPolicy?.id}`];
const transactionHasDependentTags = hasDependentTags(transactionPolicy, transactionPolicyTagList);
const transactionHasMultipleTagLists = transactionPolicy?.hasMultipleTagLists ?? false;
let reconstructedTag = transaction.tag ?? '';
for (const editedIndex of editedTagIndexes.map(Number).sort((first, second) => first - second)) {
reconstructedTag = getUpdatedTransactionTag({
transactionTag: reconstructedTag,
selectedTagName: bulkEditTagChanges?.[editedIndex] ?? '',
currentTag: '',
tagListIndex: editedIndex,
policyTags: transactionPolicyTagList,
hasDependentTags: transactionHasDependentTags,
hasMultipleTagLists: transactionHasMultipleTagLists,
});
}
transactionChanges.tag = reconstructedTag;
} else {
transactionChanges.tag = changes.tag;
}
}
if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) {
transactionChanges.comment = getParsedComment(changes.comment);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ function SearchEditMultiplePage() {
updateMultipleMoneyRequests({
transactionIDs: selectedTransactionIDs,
changes,
bulkEditTagChanges: draftTransaction.bulkEditTagChanges,
policy,
reports: mergedReports,
transactions: mergedTransactions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,38 @@ function SearchEditMultipleTagPage() {
const headerTitle = tagListName || translate('common.tag');

const saveTag = (item: Partial<OptionData>) => {
const selectedTagName = item.searchText ?? '';

const updatedTag = getUpdatedTransactionTag({
transactionTag,
selectedTagName: item.searchText ?? '',
selectedTagName,
currentTag,
tagListIndex,
policyTags,
hasDependentTags,
hasMultipleTagLists: policy?.hasMultipleTagLists ?? false,
});

// Record the per-level edit intent. For dependent tags, editing this level invalidates every
// deeper (child) level, so drop any child intents previously recorded in the same draft. The
// draft is merged, so without this a stale child edit would be replayed after this parent change
// at apply time and re-add a child that no longer belongs under the newly selected parent, even
// though the displayed updatedTag above already cleared it. Independent tags keep every level.
const bulkEditTagChanges: Record<string, string | null> = {[tagListIndex]: selectedTagName};
if (hasDependentTags) {
for (const recordedIndex of Object.keys(draftTransaction?.bulkEditTagChanges ?? {})) {
if (Number(recordedIndex) <= tagListIndex) {
continue;
}
bulkEditTagChanges[recordedIndex] = null;
}
}

updateBulkEditDraftTransaction({
// Keep the flattened tag for the summary display, and record the per-level edit intent so
// apply time can merge it into each transaction's own tag instead of overwriting all levels.
tag: updatedTag,
bulkEditTagChanges,
});
Navigation.goBack();
};
Expand Down
7 changes: 7 additions & 0 deletions src/types/onyx/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,13 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Selected transaction IDs for bulk edit operations (only used in draft transactions) */
selectedTransactionIDs?: string[];

/**
* Per-level tag edits captured during a bulk edit, keyed by tag list index.
* Only used in the bulk-edit draft transaction so apply time can merge each edited level into
* every selected transaction's own tag instead of overwriting all levels with one shared string.
*/
bulkEditTagChanges?: Record<string, string>;

/** The transaction tag */
tag?: string;

Expand Down
179 changes: 179 additions & 0 deletions tests/actions/IOUTest/BulkEditTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,185 @@ describe('actions/IOU/BulkEdit', () => {
canEditFieldSpy.mockRestore();
});

it('merges a bulk parent-tag edit into each transaction, preserving their own untouched child levels (independent tags)', () => {
const firstTransactionID = 'transaction-independent-1';
const secondTransactionID = 'transaction-independent-2';
const iouReportID = 'iou-independent-1';
const policy = {
...createRandomPolicy(70, CONST.POLICY.TYPE.TEAM),
areTagsEnabled: true,
hasMultipleTagLists: true,
};

const iouReport: Report = {
...createRandomReport(70, undefined),
reportID: iouReportID,
policyID: policy.id,
type: CONST.REPORT.TYPE.EXPENSE,
};
const reports = {
[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport,
};

// Both expenses share the parent (CostCenterA) but differ on the child levels.
const firstTransaction: Transaction = {
...createRandomTransaction(1),
transactionID: firstTransactionID,
reportID: iouReportID,
transactionThreadReportID: 'thread-independent-1',
tag: 'CostCenterA:IndicationX:PhaseP',
};
const secondTransaction: Transaction = {
...createRandomTransaction(2),
transactionID: secondTransactionID,
reportID: iouReportID,
transactionThreadReportID: 'thread-independent-2',
tag: 'CostCenterA:IndicationY:PhaseQ',
};
const transactions = {
[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`]: firstTransaction,
[`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransactionID}`]: secondTransaction,
};

// Independent multi-level tags: no parentTagsFilter on any tag.
const policyTagList = {
CostCenter: {
name: 'CostCenter',
orderWeight: 0,
required: false,
tags: {CostCenterA: {name: 'CostCenterA', enabled: true}, CostCenterB: {name: 'CostCenterB', enabled: true}},
},
Indication: {
name: 'Indication',
orderWeight: 1,
required: false,
tags: {IndicationX: {name: 'IndicationX', enabled: true}, IndicationY: {name: 'IndicationY', enabled: true}},
},
Phase: {name: 'Phase', orderWeight: 2, required: false, tags: {PhaseP: {name: 'PhaseP', enabled: true}, PhaseQ: {name: 'PhaseQ', enabled: true}}},
};

const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true);
// eslint-disable-next-line rulesdir/no-multiple-api-calls
const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn());

updateMultipleMoneyRequests({
personalDetailsList: undefined,
transactionIDs: [firstTransactionID, secondTransactionID],
// The bulk-edit page pre-computes a common-prefix display string (parent only)...
changes: {tag: 'CostCenterB'},
// ...but apply time uses the resolved per-level edit intent (index 0 maps to CostCenterB) to
// merge into each expense's own tag. Build the index-keyed map programmatically because
// numeric-string object-literal keys trip the naming-convention lint rule.
bulkEditTagChanges: Object.fromEntries([[0, 'CostCenterB']]),
policy,
reports,
transactions,
reportActions: {},
policyCategories: undefined,
policyTags: {
[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: policyTagList,
},
violations: undefined,
hash: undefined,
currentUserAccountID: RORY_ACCOUNT_ID,
delegateAccountID: undefined,
getCurrencyDecimals,
getCurrencySymbol,
});

// Each transaction keeps its OWN Indication/Phase. Only the shared parent level changed.
expect(getBulkEditUpdates(writeSpy, 0).tag).toBe('CostCenterB:IndicationX:PhaseP');
expect(getBulkEditUpdates(writeSpy, 1).tag).toBe('CostCenterB:IndicationY:PhaseQ');

writeSpy.mockRestore();
canEditFieldSpy.mockRestore();
});

it('preserves parent levels but clears dependent child levels below the edited one when bulk-editing a middle level (dependent tags)', () => {
const transactionID = 'transaction-dep-1';
const iouReportID = 'iou-dep-1';
const policy = {
...createRandomPolicy(71, CONST.POLICY.TYPE.TEAM),
areTagsEnabled: true,
hasMultipleTagLists: true,
};

const iouReport: Report = {
...createRandomReport(71, undefined),
reportID: iouReportID,
policyID: policy.id,
type: CONST.REPORT.TYPE.EXPENSE,
};
const reports = {
[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport,
};

const transaction: Transaction = {
...createRandomTransaction(1),
transactionID,
reportID: iouReportID,
transactionThreadReportID: 'thread-dep-1',
tag: 'CostCenterA:IndicationX:PhaseP',
};
const transactions = {
[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction,
};

// Dependent multi-level tags: child tags declare a parentTagsFilter. Phase has 2 enabled
// tags so no single-tag auto-select fires after clearing.
const policyTagList = {
CostCenter: {name: 'CostCenter', orderWeight: 0, required: false, tags: {CostCenterA: {name: 'CostCenterA', enabled: true}}},
Indication: {
name: 'Indication',
orderWeight: 1,
required: false,
tags: {
IndicationX: {name: 'IndicationX', enabled: true, parentTagsFilter: 'CostCenterA'},
IndicationZ: {name: 'IndicationZ', enabled: true, parentTagsFilter: 'CostCenterA'},
},
},
Phase: {
name: 'Phase',
orderWeight: 2,
required: false,
tags: {PhaseP: {name: 'PhaseP', enabled: true, parentTagsFilter: 'IndicationX'}, PhaseR: {name: 'PhaseR', enabled: true, parentTagsFilter: 'IndicationZ'}},
},
};

const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true);
// eslint-disable-next-line rulesdir/no-multiple-api-calls
const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn());

updateMultipleMoneyRequests({
personalDetailsList: undefined,
transactionIDs: [transactionID],
changes: {tag: 'CostCenterA:IndicationZ'},
// Edit only the middle (Indication, index 1) level. Built programmatically because
// numeric-string object-literal keys trip the naming-convention lint rule.
bulkEditTagChanges: Object.fromEntries([[1, 'IndicationZ']]),
policy,
reports,
transactions,
reportActions: {},
policyCategories: undefined,
policyTags: {
[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: policyTagList,
},
violations: undefined,
hash: undefined,
currentUserAccountID: RORY_ACCOUNT_ID,
delegateAccountID: undefined,
getCurrencyDecimals,
getCurrencySymbol,
});

// Parent (CostCenter) is preserved. The edited Indication level is updated. The dependent Phase level is cleared.
expect(getBulkEditUpdates(writeSpy, 0).tag).toBe('CostCenterA:IndicationZ');

writeSpy.mockRestore();
canEditFieldSpy.mockRestore();
});

it('skips category, tag, tax, and billable changes for plain IOU transactions', async () => {
const transactionID = 'transaction-iou-1';
const transactionThreadReportID = 'thread-iou-1';
Expand Down
Loading
Loading