Skip to content

Commit cebef31

Browse files
committed
Preserve vendor names while lists hydrate
1 parent d10ae6a commit cebef31

3 files changed

Lines changed: 32 additions & 19 deletions

File tree

src/libs/MerchantTypeRulesUtils.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {clearPolicyCodingRuleErrors} from './actions/Policy/Rules';
1515
import {getDecodedCategoryName} from './CategoryUtils';
1616
import Parser from './Parser';
1717
import {getMccGroupDisplayName} from './PolicyRulesUtils';
18-
import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils';
18+
import {findVendorByID, getActiveVendorMatchingIntegration, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from './PolicyUtils';
1919

2020
const MERCHANT_TYPE_RULE_KEY_PREFIX = 'mcc-group:';
2121

@@ -161,18 +161,19 @@ function getMerchantCodingRulesTableData({
161161
// 1. Render the name from the active vendor-matching integration when it contains the vendor.
162162
// 2. Render "unavailable" when the loaded active list does not contain the vendor, so a stale or
163163
// inactive connection never surfaces a misleading name.
164-
// 3. When there is no active vendor-matching source, use `findVendorByID` to search every connection.
165-
// This preserves the historical name while connection data is hydrating. Render "unavailable" when
166-
// no connection knows the ID instead of leaking the raw external ID.
164+
// 3. Before an active source hydrates, preserve a historical name or the stored ID. When no source
165+
// remains, render "unavailable" if no connection knows the ID instead of leaking the raw ID.
167166
const activeVendorName = getMatchingVendorByID(policy, rule.vendorID)?.name;
168167
const unavailableLabel = translate(isOnXero ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable');
168+
const historicalVendorName = findVendorByID(policy, rule.vendorID)?.name;
169+
const hasActiveVendorMatchingSource = getActiveVendorMatchingIntegration(policy) !== undefined || isOnXero;
169170
let vendorValue: string;
170171
if (activeVendorName) {
171172
vendorValue = activeVendorName;
172173
} else if (isMatchingVendorListLoaded(policy)) {
173174
vendorValue = unavailableLabel;
174175
} else {
175-
vendorValue = findVendorByID(policy, rule.vendorID)?.name ?? unavailableLabel;
176+
vendorValue = historicalVendorName ?? (hasActiveVendorMatchingSource ? rule.vendorID : unavailableLabel);
176177
}
177178
actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', fieldLabels.vendor, vendorValue));
178179
}

src/pages/workspace/rules/MerchantRulesSection.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
1717
import {getDecodedCategoryName} from '@libs/CategoryUtils';
1818
import Navigation from '@libs/Navigation/Navigation';
1919
import Parser from '@libs/Parser';
20-
import {findVendorByID, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils';
20+
import {findVendorByID, getActiveVendorMatchingIntegration, getCommaSeparatedTagNameWithSanitizedColons, getMatchingVendorByID, isMatchingVendorListLoaded, isXeroActiveMatchingSource} from '@libs/PolicyUtils';
2121
import tokenizedSearch from '@libs/tokenizedSearch';
2222

2323
import variables from '@styles/variables';
@@ -74,20 +74,21 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType<typeof useLo
7474
// 1. Render the name from the active vendor-matching integration when it contains the vendor.
7575
// 2. Render "unavailable" when the loaded active list does not contain the vendor, so a stale or
7676
// inactive connection never surfaces a misleading name.
77-
// 3. When there is no active vendor-matching source, use `findVendorByID` to search every connection.
78-
// This preserves the historical name while connection data is hydrating. Render "unavailable" when
79-
// no connection knows the ID instead of leaking the raw external ID.
77+
// 3. Before an active source hydrates, preserve a historical name or the stored ID. When no source
78+
// remains, render "unavailable" if no connection knows the ID instead of leaking the raw ID.
8079
const activeVendorName = getMatchingVendorByID(policy, rule.vendorID)?.name;
8180
const unavailableLabel = translate(
8281
isXeroActiveMatchingSource(policy) ? 'workspace.rules.merchantRules.supplierUnavailable' : 'workspace.rules.merchantRules.vendorUnavailable',
8382
);
83+
const historicalVendorName = findVendorByID(policy, rule.vendorID)?.name;
84+
const hasActiveVendorMatchingSource = getActiveVendorMatchingIntegration(policy) !== undefined || isXeroActiveMatchingSource(policy);
8485
let vendorValue: string;
8586
if (activeVendorName) {
8687
vendorValue = activeVendorName;
8788
} else if (isMatchingVendorListLoaded(policy)) {
8889
vendorValue = unavailableLabel;
8990
} else {
90-
vendorValue = findVendorByID(policy, rule.vendorID)?.name ?? unavailableLabel;
91+
vendorValue = historicalVendorName ?? (hasActiveVendorMatchingSource ? rule.vendorID : unavailableLabel);
9192
}
9293
actions.push(translate('workspace.rules.merchantRules.ruleSummarySubtitleUpdateField', labels.vendor, vendorValue));
9394
}

tests/unit/VendorMatchingMerchantRulesTest.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ const buildQBOWithVendorBillExportPolicy = (vendors: Array<{id: string; name: st
6363
});
6464

6565
/** Xero policy whose supplier list scopes vendor matching to Xero (label flips vendor -> supplier). */
66-
const buildXeroPolicy = (contacts: Record<string, {id: string; name: string; email: string}>): Policy =>
66+
const buildXeroPolicy = (contacts: Record<string, {id: string; name: string; email: string}> | undefined): Policy =>
6767
createMock<Policy>({
6868
...createRandomPolicy(0),
6969
connections: createMock<Connections>({
7070
[CONST.POLICY.CONNECTIONS.NAME.XERO]: {
7171
config: {isConfigured: true},
72-
data: {contacts},
72+
data: contacts === undefined ? {} : {contacts},
7373
},
7474
}),
7575
});
@@ -147,12 +147,13 @@ describe('Vendor matching on merchant rules', () => {
147147
expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "Vendor unavailable"');
148148
});
149149

150-
it('renders "Vendor unavailable" instead of the raw external ID when no connection knows the vendor', () => {
151-
// No active matching list and no connection (active or stale) resolves the vendorID — e.g. the accounting
152-
// connection was disconnected, so its vendor data is gone from Onyx. The summary must surface the
153-
// "unavailable" copy rather than leaking the raw external ID. (A vendorID that a connection still knows
154-
// continues to render its name — see the export-mode-switch case, which exercises the same tier-3 branch.)
150+
it('preserves the raw external ID while the active vendor list is not hydrated', () => {
155151
const policy = withCodingRules(buildQBOPolicy(undefined), {rule1: buildVendorRule('v-1')});
152+
expect(buildTableData(policy).at(0)?.ruleDescription).toContain('Update vendor to "v-1"');
153+
});
154+
155+
it('renders "Vendor unavailable" when no matching integration remains', () => {
156+
const policy = withCodingRules(createRandomPolicy(0), {rule1: buildVendorRule('v-1')});
156157
const description = buildTableData(policy).at(0)?.ruleDescription;
157158
expect(description).toContain('Update vendor to "Vendor unavailable"');
158159
expect(description).not.toContain('"v-1"');
@@ -185,6 +186,9 @@ describe('Vendor matching on merchant rules', () => {
185186

186187
const missing = withCodingRules(buildXeroPolicy({}), {rule1: buildVendorRule('xc1')});
187188
expect(buildTableData(missing).at(0)?.ruleDescription).toContain('Update supplier to "Supplier unavailable"');
189+
190+
const unhydrated = withCodingRules(buildXeroPolicy(undefined), {rule1: buildVendorRule('xc1')});
191+
expect(buildTableData(unhydrated).at(0)?.ruleDescription).toContain('Update supplier to "xc1"');
188192
});
189193
});
190194

@@ -213,8 +217,12 @@ describe('Vendor matching on merchant rules', () => {
213217
expect(describeRule(buildQBOPolicy([]), 'v-1')).toContain('Update vendor to "Vendor unavailable"');
214218
});
215219

216-
it('renders "Vendor unavailable" instead of the raw external ID when no connection knows the vendor', () => {
217-
const description = describeRule(buildQBOPolicy(undefined), 'v-1');
220+
it('preserves the raw external ID while the active vendor list is not hydrated', () => {
221+
expect(describeRule(buildQBOPolicy(undefined), 'v-1')).toContain('Update vendor to "v-1"');
222+
});
223+
224+
it('renders "Vendor unavailable" when no matching integration remains', () => {
225+
const description = describeRule(createRandomPolicy(0), 'v-1');
218226
expect(description).toContain('Update vendor to "Vendor unavailable"');
219227
expect(description).not.toContain('"v-1"');
220228
});
@@ -237,6 +245,9 @@ describe('Vendor matching on merchant rules', () => {
237245

238246
const missing = buildXeroPolicy({});
239247
expect(describeRule(missing, 'xc1')).toContain('Update supplier to "Supplier unavailable"');
248+
249+
const unhydrated = buildXeroPolicy(undefined);
250+
expect(describeRule(unhydrated, 'xc1')).toContain('Update supplier to "xc1"');
240251
});
241252
});
242253

0 commit comments

Comments
 (0)