Skip to content

Commit 7b2887d

Browse files
authored
Merge pull request #89867 from wildan-m/wildan/89865-restore-non-default-distance-rates
Fix Distance expense - Undefault distance rate is not displayed when duplicate wp offline
2 parents 2f1e0f6 + 43f640b commit 7b2887d

3 files changed

Lines changed: 184 additions & 13 deletions

File tree

src/libs/PolicyUtils.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -298,21 +298,41 @@ function hasEligibleActiveAdminFromWorkspaces(policies: OnyxCollection<Policy> |
298298
return false;
299299
}
300300

301+
/**
302+
* Pick the rate that the expense flow will treat as default for a distance custom unit's rates
303+
* dictionary: filter enabled rates, sort by `index` (treating missing index as CONST.DEFAULT_NUMBER_ID),
304+
* and take the first. Mirrors `getDefaultMileageRate`'s selection so optimistic state stays aligned
305+
* with the rate the expense flow later picks.
306+
*/
307+
function getDefaultDistanceRate(rates: Record<string, Rate> | undefined): Rate | undefined {
308+
if (!rates) {
309+
return undefined;
310+
}
311+
return Object.values(rates)
312+
.filter((rate) => rate.enabled !== false)
313+
.sort((a, b) => (a.index ?? CONST.DEFAULT_NUMBER_ID) - (b.index ?? CONST.DEFAULT_NUMBER_ID))
314+
.at(0);
315+
}
316+
301317
function cloneCustomUnitWithNewIDs(unit: CustomUnit, newCustomUnitID: string, newDefaultRateID?: string): CustomUnit {
302318
if (newDefaultRateID) {
303-
// The server-side DUPLICATE_POLICY assigns newDefaultRateID to the source's default rate.
304-
// Mirror getDefaultMileageRate's selection (enabled rates, sorted by index with
305-
// CONST.DEFAULT_NUMBER_ID for missing indexes) so the optimistic clone aligns with the
306-
// rate the expense flow will later treat as default. Other source rates get fresh server
307-
// IDs, so we drop them from the optimistic state to avoid stale duplicates.
308-
const defaultRate = Object.values(unit.rates)
309-
.filter((rate) => rate.enabled !== false)
310-
.sort((a, b) => (a.index ?? CONST.DEFAULT_NUMBER_ID) - (b.index ?? CONST.DEFAULT_NUMBER_ID))
311-
.at(0);
319+
// Mirror DUPLICATE_POLICY: rebind the source default to newDefaultRateID, keep other rates
320+
// under their source IDs (the server preserves them). Preserve source iteration order so
321+
// getDefaultMileageRate's stable sort still picks the original default when rates tie on
322+
// index (e.g. a rate with no index falls back to CONST.DEFAULT_NUMBER_ID).
323+
const defaultRate = getDefaultDistanceRate(unit.rates);
324+
const rates: Record<string, Rate> = {};
325+
for (const rate of Object.values(unit.rates)) {
326+
if (rate.customUnitRateID === defaultRate?.customUnitRateID) {
327+
rates[newDefaultRateID] = {...rate, customUnitRateID: newDefaultRateID};
328+
} else {
329+
rates[rate.customUnitRateID] = rate;
330+
}
331+
}
312332
return {
313333
...unit,
314334
customUnitID: newCustomUnitID,
315-
rates: defaultRate ? {[newDefaultRateID]: {...defaultRate, customUnitRateID: newDefaultRateID}} : {},
335+
rates,
316336
};
317337
}
318338

@@ -2268,6 +2288,7 @@ export {
22682288
getManagerAccountID,
22692289
isPreferredExporter,
22702290
getCustomUnitsForDuplication,
2291+
getDefaultDistanceRate,
22712292
getCountOfRequiredTagLists,
22722293
getActiveEmployeeWorkspaces,
22732294
getPolicyRole,

tests/actions/PolicyTest.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,107 @@ describe('actions/Policy', () => {
773773
expect(messages?.at(0)?.text).toBe(callerEmail);
774774
});
775775

776+
it('duplicate workspace with distance rates clones all source rates in optimistic data with the default rebound to the API-known rate ID', async () => {
777+
await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID});
778+
779+
const sourceDistanceUnitID = 'srcDistUnit';
780+
const sourceDefaultRateID = 'srcDefaultRate';
781+
const sourceExtraRateID = 'srcExtraRate';
782+
const fakePolicy: PolicyType = {
783+
...createRandomPolicy(19, CONST.POLICY.TYPE.TEAM),
784+
customUnits: {
785+
[sourceDistanceUnitID]: {
786+
customUnitID: sourceDistanceUnitID,
787+
name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
788+
enabled: true,
789+
attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES},
790+
rates: {
791+
[sourceDefaultRateID]: {customUnitRateID: sourceDefaultRateID, name: 'Default Rate', rate: 72.5, currency: 'USD', enabled: true, index: 0},
792+
[sourceExtraRateID]: {customUnitRateID: sourceExtraRateID, name: 'Extra Rate', rate: 100, currency: 'USD', enabled: true, index: 1},
793+
},
794+
},
795+
},
796+
};
797+
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy);
798+
await waitForBatchedUpdates();
799+
800+
const policyID = Policy.generatePolicyID();
801+
const options = {
802+
currentUserAccountID: ESH_ACCOUNT_ID,
803+
currentUserEmail: ESH_EMAIL,
804+
policyName: 'Distance Rates Duplicate',
805+
policyID: fakePolicy.id,
806+
targetPolicyID: policyID,
807+
welcomeNote: 'Join my policy',
808+
parts: {
809+
people: false,
810+
reports: false,
811+
connections: false,
812+
categories: false,
813+
tags: false,
814+
taxes: false,
815+
perDiem: false,
816+
reimbursements: false,
817+
expenses: false,
818+
distance: true,
819+
invoices: false,
820+
exportLayouts: false,
821+
},
822+
localCurrency: 'USD',
823+
};
824+
825+
// Pause the API call so successData (which clears the optimistic source rate IDs) does not
826+
// run yet — this lets us inspect the optimistic state the user sees offline.
827+
mockFetch.pause();
828+
Policy.duplicateWorkspace(fakePolicy, options);
829+
await waitForBatchedUpdates();
830+
831+
const duplicatePolicy: OnyxEntry<PolicyType> = await new Promise((resolve) => {
832+
const connection = Onyx.connect({
833+
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
834+
callback: (workspace) => {
835+
Onyx.disconnect(connection);
836+
resolve(workspace);
837+
},
838+
});
839+
});
840+
841+
expect(duplicatePolicy?.areDistanceRatesEnabled).toBe(true);
842+
const distanceUnit = Object.values(duplicatePolicy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
843+
if (!distanceUnit) {
844+
throw new Error('Expected duplicated distance unit');
845+
}
846+
// The duplicated unit's customUnitID should differ from the source's (fresh ID).
847+
expect(distanceUnit.customUnitID).not.toBe(sourceDistanceUnitID);
848+
// Both rates should be present in optimistic data: the extra rate keeps its source ID,
849+
// and the default is rebound to a fresh API-known customUnitRateID.
850+
const rateKeys = Object.keys(distanceUnit.rates);
851+
expect(rateKeys).toContain(sourceExtraRateID);
852+
expect(rateKeys).not.toContain(sourceDefaultRateID);
853+
expect(rateKeys).toHaveLength(2);
854+
// The Default Rate should be the one picked by getDefaultDistanceRate (lowest enabled index).
855+
const defaultRate = Object.values(distanceUnit.rates)
856+
.filter((rate) => rate.enabled !== false)
857+
.sort((a, b) => (a.index ?? CONST.DEFAULT_NUMBER_ID) - (b.index ?? CONST.DEFAULT_NUMBER_ID))
858+
.at(0);
859+
expect(defaultRate?.name).toBe('Default Rate');
860+
expect(defaultRate?.rate).toBe(72.5);
861+
// The default rate's customUnitRateID matches its dictionary key (rebound).
862+
expect(defaultRate?.customUnitRateID).not.toBe(sourceDefaultRateID);
863+
const defaultRateID = defaultRate?.customUnitRateID;
864+
if (!defaultRateID) {
865+
throw new Error('Expected default rate to have a customUnitRateID');
866+
}
867+
expect(distanceUnit.rates[defaultRateID]).toBe(defaultRate);
868+
869+
// Resume the mocked fetch so the API call resolves; the duplicated workspace's optimistic
870+
// rates remain visible until a Pusher response (not mocked here) repopulates them with
871+
// the server's rate set. We rely on the server preserving the source's non-default rate
872+
// IDs in the duplicate, so the optimistic state and the eventual server state line up.
873+
await mockFetch.resume?.();
874+
await waitForBatchedUpdates();
875+
});
876+
776877
it('creates a new workspace with BASIC approval mode if the introSelected is MANAGE_TEAM', async () => {
777878
const policyID = Policy.generatePolicyID();
778879
// When a new workspace is created with introSelected set to MANAGE_TEAM

tests/unit/PolicyUtilsTest.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ describe('PolicyUtils', () => {
352352
).toBeUndefined();
353353
});
354354

355-
it('clones the source default rate (lowest enabled index) under the API-known customUnitRateID', () => {
355+
it('rebinds the default rate to the API-known customUnitRateID and keeps non-default rates with their source IDs', () => {
356356
const distanceUnitWithMultipleRates = {
357357
customUnitID: 'srcDist',
358358
name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
@@ -377,13 +377,14 @@ describe('PolicyUtils', () => {
377377
...distanceUnitWithMultipleRates,
378378
customUnitID: 'newDist',
379379
rates: {
380+
rateB: {customUnitRateID: 'rateB', name: 'New Rate 1', rate: 100, currency: 'USD', enabled: true, index: 1, attributes: {taxRateExternalID: 'tax_other'}},
380381
newRate: {customUnitRateID: 'newRate', name: 'Default Rate', rate: 70, currency: 'USD', enabled: true, index: 0, attributes: {taxRateExternalID: 'tax_default'}},
381382
},
382383
},
383384
});
384385
});
385386

386-
it('drops all rates when no enabled rate exists', () => {
387+
it('keeps source rates with their source IDs when no enabled rate exists', () => {
387388
const distanceUnitAllDisabled = {
388389
customUnitID: 'srcDist',
389390
name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
@@ -402,7 +403,9 @@ describe('PolicyUtils', () => {
402403
perDiemCustomUnitID: 'newPerDiem',
403404
customUnitRateID: 'newRate',
404405
});
405-
expect(result?.newDist.rates).toEqual({});
406+
expect(result?.newDist.rates).toEqual({
407+
rateA: {customUnitRateID: 'rateA', name: 'Disabled', rate: 50, currency: 'USD', enabled: false, index: 0},
408+
});
406409
});
407410

408411
it('treats missing index as 0 when picking the default rate', () => {
@@ -426,9 +429,55 @@ describe('PolicyUtils', () => {
426429
customUnitRateID: 'newRate',
427430
});
428431
expect(result?.newDist.rates).toEqual({
432+
rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1},
429433
newRate: {customUnitRateID: 'newRate', name: 'No-Index Rate', rate: 70, currency: 'USD', enabled: true},
430434
});
431435
});
436+
437+
it('preserves source iteration order so getDefaultMileageRate still picks the original default when a non-default rate has a missing index', () => {
438+
// Source has 3 rates: Default (index 0), Indexed (index 1), and No-Index (no index field).
439+
// No-Index ties with Default at the index-0 sort key after `?? CONST.DEFAULT_NUMBER_ID`.
440+
// The optimistic clone must keep the source's iteration order so JavaScript's stable
441+
// sort still places Default before No-Index — otherwise getDefaultMileageRate would
442+
// pick whichever tied rate appeared first in iteration order, swapping the default.
443+
const sourceUnit = {
444+
customUnitID: 'srcDist',
445+
name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
446+
enabled: true,
447+
attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES},
448+
rates: {
449+
rateA: {customUnitRateID: 'rateA', name: 'Default Rate', rate: 72.5, currency: 'USD', enabled: true, index: 0},
450+
rateB: {customUnitRateID: 'rateB', name: 'Indexed Rate', rate: 100, currency: 'USD', enabled: true, index: 1},
451+
rateC: {customUnitRateID: 'rateC', name: 'No-Index Rate', rate: 200, currency: 'USD', enabled: true},
452+
},
453+
};
454+
const policyWithTiedIndex: Policy = {
455+
...createRandomPolicy(0),
456+
customUnits: {[sourceUnit.customUnitID]: sourceUnit},
457+
};
458+
const result = getCustomUnitsForDuplication(policyWithTiedIndex, true, false, {
459+
distanceCustomUnitID: 'newDist',
460+
perDiemCustomUnitID: 'newPerDiem',
461+
customUnitRateID: 'newRate',
462+
});
463+
464+
const cloned = result?.newDist;
465+
if (!cloned) {
466+
throw new Error('Expected cloned distance unit');
467+
}
468+
// Iteration order: the rebound default should still be first, before the No-Index rate.
469+
const orderedKeys = Object.keys(cloned.rates);
470+
expect(orderedKeys).toEqual(['newRate', 'rateB', 'rateC']);
471+
472+
// Sanity-check that getDefaultMileageRate's selection (enabled, sorted by index ?? 0,
473+
// first) lands on the rebound default and not on No-Index Rate.
474+
const pickedDefault = Object.values(cloned.rates)
475+
.filter((rate) => rate.enabled !== false)
476+
.sort((a, b) => (a.index ?? CONST.DEFAULT_NUMBER_ID) - (b.index ?? CONST.DEFAULT_NUMBER_ID))
477+
.at(0);
478+
expect(pickedDefault?.customUnitRateID).toBe('newRate');
479+
expect(pickedDefault?.name).toBe('Default Rate');
480+
});
432481
});
433482
describe('getRateDisplayValue', () => {
434483
it('should return an empty string for NaN', () => {

0 commit comments

Comments
 (0)