From 7633947a0d6eb42a3f9c113d931934624e0b13b0 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 14:20:32 +0530 Subject: [PATCH 1/8] List the vendors from each eligible workspace's connections in the Search vendor filter --- .../FilterComponents/VendorSelector.tsx | 21 ++-- src/libs/PolicyUtils/index.ts | 15 +-- tests/ui/VendorSelectorTest.tsx | 117 ++++++++++++++++++ tests/unit/PolicyUtilsTest.ts | 18 +-- 4 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 tests/ui/VendorSelectorTest.tsx diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index 84fc959dd390..4e161b98e0b9 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -8,7 +8,7 @@ import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getVendorFeaturePolicyIDs} from '@libs/PolicyUtils'; +import {getMatchingVendors, getVendorFeaturePolicies} from '@libs/PolicyUtils'; import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; @@ -34,11 +34,11 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele const {isLoadingInitialVendors} = useLoadSearchVendorData({shouldRefresh: true}); const theme = useTheme(); const styles = useThemeStyles(); - const vendorFeaturePolicyIDsSelector = useCallback( - (allPolicies: OnyxCollection) => getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled), + const vendorFeaturePoliciesSelector = useCallback( + (allPolicies: OnyxCollection) => getVendorFeaturePolicies(allPolicies, isVendorMatchingBetaEnabled), [isVendorMatchingBetaEnabled], ); - const [vendorFeaturePolicyIDs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorFeaturePolicyIDsSelector}); + const [vendorFeaturePolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorFeaturePoliciesSelector}); const [allPolicyVendors = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_VENDORS); const noVendorLabel = translate('search.noVendor'); @@ -51,17 +51,22 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele // Vendor lists are only offered for the workspaces where the vendor feature is on, so stale lists left behind by a // disconnected integration never surface in the picker. - const eligiblePolicyIDs = new Set(vendorFeaturePolicyIDs); + const eligiblePolicyIDs = new Set(Object.values(vendorFeaturePolicies ?? {}).map((policy) => policy?.id)); const eligiblePolicyVendors: OnyxCollection = Object.fromEntries( Object.entries(allPolicyVendors).filter(([key]) => eligiblePolicyIDs.has(key.replace(ONYXKEYS.COLLECTION.POLICY_VENDORS, ''))), ); + // A workspace passes the vendor feature check only once its connections are loaded, and those connections carry the + // synced vendor list. Members only get vendor lists this way, since the bulk vendor load covers workspaces the + // user administers. + const selectedPolicyFilter = policyID?.value?.length ? policyID : undefined; const vendorItems = [{text: noVendorLabel, value: CONST.SEARCH.VENDOR_EMPTY_VALUE as string}]; - const uniqueVendorNames = new Set( - getAllPolicyValues(policyID?.value?.length ? policyID : undefined, ONYXKEYS.COLLECTION.POLICY_VENDORS, eligiblePolicyVendors).flatMap((policyVendors) => + const uniqueVendorNames = new Set([ + ...getAllPolicyValues(selectedPolicyFilter, ONYXKEYS.COLLECTION.POLICY_VENDORS, eligiblePolicyVendors).flatMap((policyVendors) => Object.values(policyVendors ?? {}).map((vendor) => vendor.name), ), - ); + ...getAllPolicyValues(selectedPolicyFilter, ONYXKEYS.COLLECTION.POLICY, vendorFeaturePolicies).flatMap((policy) => getMatchingVendors(policy).map((vendor) => vendor.name)), + ]); vendorItems.push( ...Array.from(uniqueVendorNames) .filter(Boolean) diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 4eb60739b272..7589d23726fb 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -2408,16 +2408,17 @@ function hasVendorFeatureOnAnyPolicy(policies: OnyxCollection, isVendorM } /** - * IDs of the workspaces that have the vendor feature, so the Search vendor filter only offers their vendor lists. + * The workspaces that have the vendor feature, keyed like the policy collection, so the Search vendor filter only offers + * their vendor lists. */ -function getVendorFeaturePolicyIDs(policies: OnyxCollection, isVendorMatchingBetaEnabled: boolean): string[] { - const policyIDs: string[] = []; - for (const policy of Object.values(policies ?? {})) { +function getVendorFeaturePolicies(policies: OnyxCollection, isVendorMatchingBetaEnabled: boolean): OnyxCollection { + const vendorFeaturePolicies: NonNullable> = {}; + for (const [key, policy] of Object.entries(policies ?? {})) { if (policy?.id && hasVendorFeature(policy, isVendorMatchingBetaEnabled)) { - policyIDs.push(policy.id); + vendorFeaturePolicies[key] = policy; } } - return policyIDs; + return vendorFeaturePolicies; } /** @@ -3282,7 +3283,7 @@ export { isXeroVendorMatchingActive, hasVendorFeature, hasVendorFeatureOnAnyPolicy, - getVendorFeaturePolicyIDs, + getVendorFeaturePolicies, isMatchingVendorListLoaded, getValidConnectedIntegration, getIneligibleInvitees, diff --git a/tests/ui/VendorSelectorTest.tsx b/tests/ui/VendorSelectorTest.tsx new file mode 100644 index 000000000000..662fe57e8954 --- /dev/null +++ b/tests/ui/VendorSelectorTest.tsx @@ -0,0 +1,117 @@ +import {render} from '@testing-library/react-native'; + +import MultiSelect from '@components/Search/FilterComponents/MultiSelect'; +import VendorSelector from '@components/Search/FilterComponents/VendorSelector'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyVendors} from '@src/types/onyx'; +import type {Connections} from '@src/types/onyx/Policy'; + +import React from 'react'; + +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; + +const mockOnyxData: Record = {}; + +jest.mock('@components/Search/FilterComponents/MultiSelect', () => jest.fn(() => null)); +jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); +jest.mock('@hooks/useLoadSearchVendorData', () => jest.fn(() => ({isLoadingInitialVendors: false}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useTheme', () => jest.fn(() => ({}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({flex1: {}, flexColumn: {}, justifyContentCenter: {}, alignItemsCenter: {}, pl3: {}}))); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + localeCompare: (a: string, b: string) => a.localeCompare(b), + })), +); +jest.mock('@hooks/useOnyx', () => + jest.fn((key: string, options?: {selector?: (value: unknown) => unknown}) => { + const value = mockOnyxData[key]; + return [options?.selector ? options.selector(value) : value, {status: 'loaded'}]; + }), +); + +/** QBO workspace exporting card expenses as credit card transactions, with its synced vendors in its connections. */ +const buildQBOPolicy = (policyID: string, vendorNames: string[]): Policy => + createMock({ + ...createRandomPolicy(0), + id: policyID, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + data: {vendors: vendorNames.map((name) => ({id: `${policyID}-${name}`, name, currency: 'USD'}))}, + }, + }), + }); + +const buildPolicyVendors = (vendorNames: string[]): PolicyVendors => + Object.fromEntries(vendorNames.map((name) => [name, {externalID: name, name, enabled: true, origin: CONST.POLICY.CONNECTIONS.NAME.QBO}])); + +describe('VendorSelector', () => { + const mockedMultiSelect = jest.mocked(MultiSelect); + const getItemTexts = () => mockedMultiSelect.mock.lastCall?.[0].items.map((item) => item.text); + + beforeEach(() => { + mockedMultiSelect.mockClear(); + for (const key of Object.keys(mockOnyxData)) { + delete mockOnyxData[key]; + } + }); + + it('lists the vendors in the connections of a workspace with no loaded vendor list', () => { + // Given a member's workspace whose connections carry synced vendors, while the vendor list load returned nothing for it + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Zeta Supplies', 'Acme Tools'])}; + mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {}; + + // When the vendor picker renders + render( + , + ); + + // Then the workspace's vendors are offered after "No vendor", because members only get vendor lists through the workspace connections + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supplies']); + }); + + it('merges the loaded vendor list with the connections without duplicating names', () => { + // Given an admin's workspace with a loaded vendor list that overlaps the vendors in its connections + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Acme Tools', 'Zeta Supplies'])}; + mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}qbo`]: buildPolicyVendors(['Acme Tools', 'Bravo Freight'])}; + + // When the vendor picker renders + render( + , + ); + + // Then every vendor name from both sources appears once + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Bravo Freight', 'Zeta Supplies']); + }); + + it('only lists the vendors of the selected workspaces', () => { + // Given two vendor workspaces and a workspace filter that selects only the first one + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = { + [`${ONYXKEYS.COLLECTION.POLICY}first`]: buildQBOPolicy('first', ['Acme Tools']), + [`${ONYXKEYS.COLLECTION.POLICY}second`]: buildQBOPolicy('second', ['Zeta Supplies']), + }; + mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {}; + + // When the vendor picker renders with that workspace filter + render( + , + ); + + // Then only the selected workspace's vendors are offered + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools']); + }); +}); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 93436784e970..1f5a5dcfce94 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -15,7 +15,7 @@ import { canSendInvoiceFromWorkspace, evaluateApprovalWorkflowRule, findVendorByID, - getVendorFeaturePolicyIDs, + getVendorFeaturePolicies, getVendorDisplayName, hasVendorFeatureOnAnyPolicy, getActivePolicies, @@ -5594,7 +5594,7 @@ describe('PolicyUtils', () => { }); }); - describe('hasVendorFeatureOnAnyPolicy and getVendorFeaturePolicyIDs', () => { + describe('hasVendorFeatureOnAnyPolicy and getVendorFeaturePolicies', () => { const qboPolicy: Policy = {...buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), id: 'qbo'}; const xeroPolicy: Policy = {...buildXeroPolicy(), id: 'xero'}; const businessCentralPolicy: Policy = {...buildBusinessCentralPolicy(), id: 'businessCentral'}; @@ -5645,17 +5645,17 @@ describe('PolicyUtils', () => { expect(isVendorFeatureAvailable).toBe(false); }); - it('lists the workspaces that have the vendor feature', () => { + it('returns the workspaces that have the vendor feature, keyed like the policy collection', () => { // Given QBO, Xero and Business Central workspaces next to one with no accounting connection const policies = {[qboKey]: qboPolicy, [xeroKey]: xeroPolicy, [businessCentralKey]: businessCentralPolicy, [plainKey]: plainPolicy}; - // When the workspace IDs are listed with and without the vendorMatching beta - const policyIDsWithBeta = getVendorFeaturePolicyIDs(policies, true); - const policyIDsWithoutBeta = getVendorFeaturePolicyIDs(policies, false); + // When the workspaces are filtered with and without the vendorMatching beta + const policiesWithBeta = getVendorFeaturePolicies(policies, true); + const policiesWithoutBeta = getVendorFeaturePolicies(policies, false); - // Then every connected workspace is listed with the beta, and Business Central is dropped without it because it still depends on the beta - expect(policyIDsWithBeta.toSorted()).toEqual(['businessCentral', 'qbo', 'xero']); - expect(policyIDsWithoutBeta.toSorted()).toEqual(['qbo', 'xero']); + // Then every connected workspace is kept with the beta, and Business Central is dropped without it because it still depends on the beta + expect(policiesWithBeta).toEqual({[qboKey]: qboPolicy, [xeroKey]: xeroPolicy, [businessCentralKey]: businessCentralPolicy}); + expect(policiesWithoutBeta).toEqual({[qboKey]: qboPolicy, [xeroKey]: xeroPolicy}); }); }); From b9d5e7b7546eca497510a051749a1431628b87f8 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 14:21:41 +0530 Subject: [PATCH 2/8] Pass the required value prop in the VendorSelector tests --- tests/ui/VendorSelectorTest.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ui/VendorSelectorTest.tsx b/tests/ui/VendorSelectorTest.tsx index 662fe57e8954..3ba3c9be2fb4 100644 --- a/tests/ui/VendorSelectorTest.tsx +++ b/tests/ui/VendorSelectorTest.tsx @@ -70,6 +70,7 @@ describe('VendorSelector', () => { render( , ); @@ -87,6 +88,7 @@ describe('VendorSelector', () => { render( , ); @@ -107,6 +109,7 @@ describe('VendorSelector', () => { render( , ); From 86017a4b2772561b992ec80626f5b25feb9fadb2 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 14:30:30 +0530 Subject: [PATCH 3/8] Keep only vendor names per workspace in the VendorSelector selector --- .../FilterComponents/VendorSelector.tsx | 27 ++++++++++++------- src/libs/PolicyUtils/index.ts | 15 +++++------ tests/ui/VendorSelectorTest.tsx | 21 +++++++++++++++ tests/unit/PolicyUtilsTest.ts | 18 ++++++------- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index 4e161b98e0b9..0943abd305d6 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -8,7 +8,7 @@ import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getMatchingVendors, getVendorFeaturePolicies} from '@libs/PolicyUtils'; +import {getMatchingVendors, getVendorFeaturePolicyIDs} from '@libs/PolicyUtils'; import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; @@ -34,11 +34,20 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele const {isLoadingInitialVendors} = useLoadSearchVendorData({shouldRefresh: true}); const theme = useTheme(); const styles = useThemeStyles(); - const vendorFeaturePoliciesSelector = useCallback( - (allPolicies: OnyxCollection) => getVendorFeaturePolicies(allPolicies, isVendorMatchingBetaEnabled), + // A workspace passes the vendor feature check only once its connections are loaded, and those connections carry the + // synced vendor list. Members only get vendor lists this way, since the bulk vendor load covers workspaces the user + // administers. Only the names are kept so the selector result stays small. + const connectionVendorNamesSelector = useCallback( + (allPolicies: OnyxCollection) => + Object.fromEntries( + getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled).map((id) => [ + id, + getMatchingVendors(allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${id}`]).map((vendor) => vendor.name), + ]), + ), [isVendorMatchingBetaEnabled], ); - const [vendorFeaturePolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorFeaturePoliciesSelector}); + const [connectionVendorNamesByPolicyID = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: connectionVendorNamesSelector}); const [allPolicyVendors = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_VENDORS); const noVendorLabel = translate('search.noVendor'); @@ -51,21 +60,21 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele // Vendor lists are only offered for the workspaces where the vendor feature is on, so stale lists left behind by a // disconnected integration never surface in the picker. - const eligiblePolicyIDs = new Set(Object.values(vendorFeaturePolicies ?? {}).map((policy) => policy?.id)); + const eligiblePolicyIDs = new Set(Object.keys(connectionVendorNamesByPolicyID)); const eligiblePolicyVendors: OnyxCollection = Object.fromEntries( Object.entries(allPolicyVendors).filter(([key]) => eligiblePolicyIDs.has(key.replace(ONYXKEYS.COLLECTION.POLICY_VENDORS, ''))), ); - // A workspace passes the vendor feature check only once its connections are loaded, and those connections carry the - // synced vendor list. Members only get vendor lists this way, since the bulk vendor load covers workspaces the - // user administers. const selectedPolicyFilter = policyID?.value?.length ? policyID : undefined; + const isPolicySelected = (id: string) => !selectedPolicyFilter?.value || selectedPolicyFilter.isNegated !== selectedPolicyFilter.value.includes(id); const vendorItems = [{text: noVendorLabel, value: CONST.SEARCH.VENDOR_EMPTY_VALUE as string}]; const uniqueVendorNames = new Set([ ...getAllPolicyValues(selectedPolicyFilter, ONYXKEYS.COLLECTION.POLICY_VENDORS, eligiblePolicyVendors).flatMap((policyVendors) => Object.values(policyVendors ?? {}).map((vendor) => vendor.name), ), - ...getAllPolicyValues(selectedPolicyFilter, ONYXKEYS.COLLECTION.POLICY, vendorFeaturePolicies).flatMap((policy) => getMatchingVendors(policy).map((vendor) => vendor.name)), + ...Object.entries(connectionVendorNamesByPolicyID) + .filter(([id]) => isPolicySelected(id)) + .flatMap(([, vendorNames]) => vendorNames), ]); vendorItems.push( ...Array.from(uniqueVendorNames) diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 7589d23726fb..4eb60739b272 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -2408,17 +2408,16 @@ function hasVendorFeatureOnAnyPolicy(policies: OnyxCollection, isVendorM } /** - * The workspaces that have the vendor feature, keyed like the policy collection, so the Search vendor filter only offers - * their vendor lists. + * IDs of the workspaces that have the vendor feature, so the Search vendor filter only offers their vendor lists. */ -function getVendorFeaturePolicies(policies: OnyxCollection, isVendorMatchingBetaEnabled: boolean): OnyxCollection { - const vendorFeaturePolicies: NonNullable> = {}; - for (const [key, policy] of Object.entries(policies ?? {})) { +function getVendorFeaturePolicyIDs(policies: OnyxCollection, isVendorMatchingBetaEnabled: boolean): string[] { + const policyIDs: string[] = []; + for (const policy of Object.values(policies ?? {})) { if (policy?.id && hasVendorFeature(policy, isVendorMatchingBetaEnabled)) { - vendorFeaturePolicies[key] = policy; + policyIDs.push(policy.id); } } - return vendorFeaturePolicies; + return policyIDs; } /** @@ -3283,7 +3282,7 @@ export { isXeroVendorMatchingActive, hasVendorFeature, hasVendorFeatureOnAnyPolicy, - getVendorFeaturePolicies, + getVendorFeaturePolicyIDs, isMatchingVendorListLoaded, getValidConnectedIntegration, getIneligibleInvitees, diff --git a/tests/ui/VendorSelectorTest.tsx b/tests/ui/VendorSelectorTest.tsx index 3ba3c9be2fb4..dfc5b2cc3d09 100644 --- a/tests/ui/VendorSelectorTest.tsx +++ b/tests/ui/VendorSelectorTest.tsx @@ -117,4 +117,25 @@ describe('VendorSelector', () => { // Then only the selected workspace's vendors are offered expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools']); }); + + it('leaves out the vendors of excluded workspaces', () => { + // Given two vendor workspaces and a workspace filter that excludes the first one + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = { + [`${ONYXKEYS.COLLECTION.POLICY}first`]: buildQBOPolicy('first', ['Acme Tools']), + [`${ONYXKEYS.COLLECTION.POLICY}second`]: buildQBOPolicy('second', ['Zeta Supplies']), + }; + mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {}; + + // When the vendor picker renders with that negated workspace filter + render( + , + ); + + // Then only the vendors of the workspaces that are not excluded are offered + expect(getItemTexts()).toEqual(['search.noVendor', 'Zeta Supplies']); + }); }); diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 1f5a5dcfce94..93436784e970 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -15,7 +15,7 @@ import { canSendInvoiceFromWorkspace, evaluateApprovalWorkflowRule, findVendorByID, - getVendorFeaturePolicies, + getVendorFeaturePolicyIDs, getVendorDisplayName, hasVendorFeatureOnAnyPolicy, getActivePolicies, @@ -5594,7 +5594,7 @@ describe('PolicyUtils', () => { }); }); - describe('hasVendorFeatureOnAnyPolicy and getVendorFeaturePolicies', () => { + describe('hasVendorFeatureOnAnyPolicy and getVendorFeaturePolicyIDs', () => { const qboPolicy: Policy = {...buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), id: 'qbo'}; const xeroPolicy: Policy = {...buildXeroPolicy(), id: 'xero'}; const businessCentralPolicy: Policy = {...buildBusinessCentralPolicy(), id: 'businessCentral'}; @@ -5645,17 +5645,17 @@ describe('PolicyUtils', () => { expect(isVendorFeatureAvailable).toBe(false); }); - it('returns the workspaces that have the vendor feature, keyed like the policy collection', () => { + it('lists the workspaces that have the vendor feature', () => { // Given QBO, Xero and Business Central workspaces next to one with no accounting connection const policies = {[qboKey]: qboPolicy, [xeroKey]: xeroPolicy, [businessCentralKey]: businessCentralPolicy, [plainKey]: plainPolicy}; - // When the workspaces are filtered with and without the vendorMatching beta - const policiesWithBeta = getVendorFeaturePolicies(policies, true); - const policiesWithoutBeta = getVendorFeaturePolicies(policies, false); + // When the workspace IDs are listed with and without the vendorMatching beta + const policyIDsWithBeta = getVendorFeaturePolicyIDs(policies, true); + const policyIDsWithoutBeta = getVendorFeaturePolicyIDs(policies, false); - // Then every connected workspace is kept with the beta, and Business Central is dropped without it because it still depends on the beta - expect(policiesWithBeta).toEqual({[qboKey]: qboPolicy, [xeroKey]: xeroPolicy, [businessCentralKey]: businessCentralPolicy}); - expect(policiesWithoutBeta).toEqual({[qboKey]: qboPolicy, [xeroKey]: xeroPolicy}); + // Then every connected workspace is listed with the beta, and Business Central is dropped without it because it still depends on the beta + expect(policyIDsWithBeta.toSorted()).toEqual(['businessCentral', 'qbo', 'xero']); + expect(policyIDsWithoutBeta.toSorted()).toEqual(['qbo', 'xero']); }); }); From 902aedaa0b590ad72420eea1ff611b0eb34e94bb Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 15:55:22 +0530 Subject: [PATCH 4/8] Use the loaded vendor list per workspace and fall back to the connections --- .../FilterComponents/VendorSelector.tsx | 35 ++++++++----------- tests/ui/VendorSelectorTest.tsx | 10 +++--- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index 0943abd305d6..7ae48005326e 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -9,7 +9,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getMatchingVendors, getVendorFeaturePolicyIDs} from '@libs/PolicyUtils'; -import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -34,9 +34,9 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele const {isLoadingInitialVendors} = useLoadSearchVendorData({shouldRefresh: true}); const theme = useTheme(); const styles = useThemeStyles(); - // A workspace passes the vendor feature check only once its connections are loaded, and those connections carry the - // synced vendor list. Members only get vendor lists this way, since the bulk vendor load covers workspaces the user - // administers. Only the names are kept so the selector result stays small. + // Vendor lists are only offered for the workspaces where the vendor feature is on, so stale lists left behind by a + // disconnected integration never surface in the picker. A workspace passes that check only once its connections are + // loaded, and those connections carry the synced vendor list. Only the names are kept so the selector result stays small. const connectionVendorNamesSelector = useCallback( (allPolicies: OnyxCollection) => Object.fromEntries( @@ -58,24 +58,19 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele return {text: vendor, value: vendor}; }); - // Vendor lists are only offered for the workspaces where the vendor feature is on, so stale lists left behind by a - // disconnected integration never surface in the picker. - const eligiblePolicyIDs = new Set(Object.keys(connectionVendorNamesByPolicyID)); - const eligiblePolicyVendors: OnyxCollection = Object.fromEntries( - Object.entries(allPolicyVendors).filter(([key]) => eligiblePolicyIDs.has(key.replace(ONYXKEYS.COLLECTION.POLICY_VENDORS, ''))), - ); - - const selectedPolicyFilter = policyID?.value?.length ? policyID : undefined; - const isPolicySelected = (id: string) => !selectedPolicyFilter?.value || selectedPolicyFilter.isNegated !== selectedPolicyFilter.value.includes(id); + // The loaded vendor list is rebuilt and pushed after every sync, so it wins over the cached connections. The connections + // are the fallback for workspaces without one, which is how members get their vendors since the bulk vendor load only + // covers workspaces the user administers. + const isPolicySelected = (id: string) => !policyID?.value?.length || policyID.isNegated !== policyID.value.includes(id); const vendorItems = [{text: noVendorLabel, value: CONST.SEARCH.VENDOR_EMPTY_VALUE as string}]; - const uniqueVendorNames = new Set([ - ...getAllPolicyValues(selectedPolicyFilter, ONYXKEYS.COLLECTION.POLICY_VENDORS, eligiblePolicyVendors).flatMap((policyVendors) => - Object.values(policyVendors ?? {}).map((vendor) => vendor.name), - ), - ...Object.entries(connectionVendorNamesByPolicyID) + const uniqueVendorNames = new Set( + Object.entries(connectionVendorNamesByPolicyID) .filter(([id]) => isPolicySelected(id)) - .flatMap(([, vendorNames]) => vendorNames), - ]); + .flatMap(([id, connectionVendorNames]) => { + const loadedVendors = allPolicyVendors[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${id}`]; + return loadedVendors ? Object.values(loadedVendors).map((vendor) => vendor.name) : connectionVendorNames; + }), + ); vendorItems.push( ...Array.from(uniqueVendorNames) .filter(Boolean) diff --git a/tests/ui/VendorSelectorTest.tsx b/tests/ui/VendorSelectorTest.tsx index dfc5b2cc3d09..1a4b543af01a 100644 --- a/tests/ui/VendorSelectorTest.tsx +++ b/tests/ui/VendorSelectorTest.tsx @@ -79,10 +79,10 @@ describe('VendorSelector', () => { expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supplies']); }); - it('merges the loaded vendor list with the connections without duplicating names', () => { - // Given an admin's workspace with a loaded vendor list that overlaps the vendors in its connections + it('uses the loaded vendor list over the vendors cached in the connections', () => { + // Given a workspace whose loaded vendor list has a vendor renamed since its connections were cached mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Acme Tools', 'Zeta Supplies'])}; - mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}qbo`]: buildPolicyVendors(['Acme Tools', 'Bravo Freight'])}; + mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}qbo`]: buildPolicyVendors(['Acme Tools', 'Zeta Supply Co'])}; // When the vendor picker renders render( @@ -93,8 +93,8 @@ describe('VendorSelector', () => { />, ); - // Then every vendor name from both sources appears once - expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Bravo Freight', 'Zeta Supplies']); + // Then only the loaded list is offered, because it is rebuilt after every sync while the connections can be stale + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supply Co']); }); it('only lists the vendors of the selected workspaces', () => { From a86e573e0508ecd8769e91c525b66de05dc6bf3f Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 16:49:52 +0530 Subject: [PATCH 5/8] Fall back to the connections when the loaded vendor list is empty --- .../Search/FilterComponents/VendorSelector.tsx | 7 ++++--- tests/ui/VendorSelectorTest.tsx | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index 7ae48005326e..db991c17e03d 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -60,15 +60,16 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele // The loaded vendor list is rebuilt and pushed after every sync, so it wins over the cached connections. The connections // are the fallback for workspaces without one, which is how members get their vendors since the bulk vendor load only - // covers workspaces the user administers. + // covers workspaces the user administers. An empty loaded list also falls back, because a list built while the vendor + // feature was off stays empty after the export type is switched on. const isPolicySelected = (id: string) => !policyID?.value?.length || policyID.isNegated !== policyID.value.includes(id); const vendorItems = [{text: noVendorLabel, value: CONST.SEARCH.VENDOR_EMPTY_VALUE as string}]; const uniqueVendorNames = new Set( Object.entries(connectionVendorNamesByPolicyID) .filter(([id]) => isPolicySelected(id)) .flatMap(([id, connectionVendorNames]) => { - const loadedVendors = allPolicyVendors[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${id}`]; - return loadedVendors ? Object.values(loadedVendors).map((vendor) => vendor.name) : connectionVendorNames; + const loadedVendorNames = Object.values(allPolicyVendors[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${id}`] ?? {}).map((vendor) => vendor.name); + return loadedVendorNames.length > 0 ? loadedVendorNames : connectionVendorNames; }), ); vendorItems.push( diff --git a/tests/ui/VendorSelectorTest.tsx b/tests/ui/VendorSelectorTest.tsx index 1a4b543af01a..5766370dcf21 100644 --- a/tests/ui/VendorSelectorTest.tsx +++ b/tests/ui/VendorSelectorTest.tsx @@ -97,6 +97,24 @@ describe('VendorSelector', () => { expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supply Co']); }); + it('falls back to the connections when the loaded vendor list is empty', () => { + // Given a workspace whose loaded vendor list was built empty before the vendor feature was turned on, while its connections carry the synced vendors + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Acme Tools', 'Zeta Supplies'])}; + mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}qbo`]: {}}; + + // When the vendor picker renders + render( + , + ); + + // Then the vendors from the connections are offered, because the empty list is stale until the next sync rebuilds it + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supplies']); + }); + it('only lists the vendors of the selected workspaces', () => { // Given two vendor workspaces and a workspace filter that selects only the first one mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = { From 69301c362196bf04b5fa55d073f550e1b20d7dc9 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 17:18:06 +0530 Subject: [PATCH 6/8] List vendors from each workspace's connections and remove the vendor filter bulk load --- src/ONYXKEYS.ts | 4 -- .../FilterComponents/VendorSelector.tsx | 45 +++----------- src/hooks/useLoadSearchVendorData.ts | 59 ------------------- src/libs/API/types.ts | 2 - src/libs/ExportOnyxState/masking.ts | 2 - src/libs/actions/Search.ts | 29 --------- src/setup/index.ts | 1 - tests/ui/VendorSelectorTest.tsx | 57 +++++++++--------- 8 files changed, 36 insertions(+), 163 deletions(-) delete mode 100644 src/hooks/useLoadSearchVendorData.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5fa8917f1f3b..5f4b4bb9a680 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -519,10 +519,8 @@ const ONYXKEYS = { RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA: 'isLoadingSearchFiltersCategoryData', /** Set whether the search filters vendor data has loaded */ - IS_SEARCH_FILTERS_VENDOR_DATA_LOADED: 'isSearchFiltersVendorDataLoaded', /** Set while search filter vendor data is loading */ - RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA: 'isLoadingSearchFiltersVendorData', HAS_LOADED_APP: 'hasLoadedApp', @@ -1792,8 +1790,6 @@ type OnyxValuesMapping = { [ONYXKEYS.RAM_ONLY_HAS_RULES_DATA_BEEN_FETCHED]: boolean; [ONYXKEYS.RAM_ONLY_IS_LOADING_RULES]: boolean; [ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA]: boolean; - [ONYXKEYS.IS_SEARCH_FILTERS_VENDOR_DATA_LOADED]: boolean; - [ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA]: boolean; [ONYXKEYS.IS_LOADING_SUBSCRIPTION_DATA]: boolean; [ONYXKEYS.IS_PENDING_UPDATE_PERSONAL_KARMA]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index db991c17e03d..8c2ccd0467a8 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -1,25 +1,20 @@ -import ActivityIndicator from '@components/ActivityIndicator'; import type {Filter, SearchFilterCommonProps} from '@components/Search/types'; -import useLoadSearchVendorData from '@hooks/useLoadSearchVendorData'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; import {getMatchingVendors, getVendorFeaturePolicyIDs} from '@libs/PolicyUtils'; import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyVendors} from '@src/types/onyx'; +import type {Policy} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection} from 'react-native-onyx'; import React, {useCallback} from 'react'; -import {View} from 'react-native'; import MultiSelect from './MultiSelect'; @@ -31,13 +26,11 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele const {translate, localeCompare} = useLocalize(); const {isBetaEnabled} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); - const {isLoadingInitialVendors} = useLoadSearchVendorData({shouldRefresh: true}); - const theme = useTheme(); - const styles = useThemeStyles(); - // Vendor lists are only offered for the workspaces where the vendor feature is on, so stale lists left behind by a - // disconnected integration never surface in the picker. A workspace passes that check only once its connections are - // loaded, and those connections carry the synced vendor list. Only the names are kept so the selector result stays small. - const connectionVendorNamesSelector = useCallback( + + // Only workspaces with the vendor feature on are offered, so a disconnected integration's leftover list never shows. + // Those workspaces always have their connections loaded, which carry the synced vendors, the same list the expense + // Vendor field offers. Only the names are kept so the selector result stays small. + const vendorNamesSelector = useCallback( (allPolicies: OnyxCollection) => Object.fromEntries( getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled).map((id) => [ @@ -47,8 +40,7 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele ), [isVendorMatchingBetaEnabled], ); - const [connectionVendorNamesByPolicyID = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: connectionVendorNamesSelector}); - const [allPolicyVendors = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_VENDORS); + const [vendorNamesByPolicyID = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorNamesSelector}); const noVendorLabel = translate('search.noVendor'); const selectedVendorItems = value.map((vendor) => { @@ -58,19 +50,12 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele return {text: vendor, value: vendor}; }); - // The loaded vendor list is rebuilt and pushed after every sync, so it wins over the cached connections. The connections - // are the fallback for workspaces without one, which is how members get their vendors since the bulk vendor load only - // covers workspaces the user administers. An empty loaded list also falls back, because a list built while the vendor - // feature was off stays empty after the export type is switched on. const isPolicySelected = (id: string) => !policyID?.value?.length || policyID.isNegated !== policyID.value.includes(id); const vendorItems = [{text: noVendorLabel, value: CONST.SEARCH.VENDOR_EMPTY_VALUE as string}]; const uniqueVendorNames = new Set( - Object.entries(connectionVendorNamesByPolicyID) + Object.entries(vendorNamesByPolicyID) .filter(([id]) => isPolicySelected(id)) - .flatMap(([id, connectionVendorNames]) => { - const loadedVendorNames = Object.values(allPolicyVendors[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${id}`] ?? {}).map((vendor) => vendor.name); - return loadedVendorNames.length > 0 ? loadedVendorNames : connectionVendorNames; - }), + .flatMap(([, vendorNames]) => vendorNames), ); vendorItems.push( ...Array.from(uniqueVendorNames) @@ -79,18 +64,6 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele .toSorted((a, b) => sortOptionsWithEmptyValue(a.text, b.text, localeCompare)), ); - if (isLoadingInitialVendors) { - return ( - - - - ); - } - return ( { - if (areVendorsLoaded || isLoadingVendors !== undefined) { - hasObservedVendorRequestStateRef.current = true; - } - - const wasVendorRequestStateCleared = hasRequestedVendorDataRef.current && hasObservedVendorRequestStateRef.current && !areVendorsLoaded && isLoadingVendors === undefined; - if (wasVendorRequestStateCleared) { - hasRequestedVendorDataRef.current = false; - hasObservedVendorRequestStateRef.current = false; - } - - if (!shouldLoad) { - hasRequestedVendorDataRef.current = false; - hasObservedVendorRequestStateRef.current = false; - return; - } - - if (isLoadingVendors) { - hasRequestedVendorDataRef.current = true; - return; - } - - if (isOffline || hasRequestedVendorDataRef.current || (areVendorsLoaded && !shouldRefresh)) { - return; - } - hasRequestedVendorDataRef.current = true; - openSearchVendorFiltersPage(); - }, [areVendorsLoaded, isLoadingVendors, isOffline, shouldLoad, shouldRefresh]); - - const isLoadingInitialVendors = shouldLoad && !areVendorsLoaded && !isOffline && isLoadingVendors !== false; - - return {areVendorsLoaded, isLoadingInitialVendors, isOffline}; -} - -export default useLoadSearchVendorData; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 883a395ae6bd..47c602369c93 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1561,7 +1561,6 @@ const READ_COMMANDS = { OPEN_SEARCH_PAGE: 'OpenSearchPage', OPEN_SEARCH_CARD_FILTERS_PAGE: 'OpenSearchCardFiltersPage', OPEN_SEARCH_CATEGORY_FILTERS_PAGE: 'OpenSearchCategoryFiltersPage', - OPEN_SEARCH_VENDOR_FILTERS_PAGE: 'OpenSearchVendorFiltersPage', SEARCH: 'Search', GET_INSIGHTS: 'GetInsights', GET_TRANSACTIONS_CONVERTED_AMOUNT: 'GetTransactionsConvertedAmount', @@ -1743,7 +1742,6 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_DRAFT_DISTANCE_EXPENSE]: null; [READ_COMMANDS.OPEN_SEARCH_CARD_FILTERS_PAGE]: null; [READ_COMMANDS.OPEN_SEARCH_CATEGORY_FILTERS_PAGE]: null; - [READ_COMMANDS.OPEN_SEARCH_VENDOR_FILTERS_PAGE]: null; [READ_COMMANDS.START_ISSUE_NEW_CARD_FLOW]: Parameters.StartIssueNewCardFlowParams; [READ_COMMANDS.OPEN_CARD_DETAILS_PAGE]: Parameters.OpenCardDetailsPageParams; [READ_COMMANDS.GET_CORPAY_ONBOARDING_FIELDS]: Parameters.GetCorpayOnboardingFieldsParams; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 578429ff5a4b..5905a34ca194 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -255,7 +255,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_PLAID_DISABLED, ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED, ONYXKEYS.IS_SEARCH_FILTERS_CATEGORY_DATA_LOADED, - ONYXKEYS.IS_SEARCH_FILTERS_VENDOR_DATA_LOADED, ONYXKEYS.IS_SENTRY_DEBUG_ENABLED, ONYXKEYS.IS_SENTRY_SEND_ENABLED, ONYXKEYS.IS_SWITCHING_TO_DELEGATOR, @@ -504,7 +503,6 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, - ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA, ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 55331b75f16f..adfcb11aeb30 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1078,34 +1078,6 @@ function openSearchCategoryFiltersPage() { read(READ_COMMANDS.OPEN_SEARCH_CATEGORY_FILTERS_PAGE, null, {optimisticData, successData, finallyData}); } -function openSearchVendorFiltersPage() { - const optimisticData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA, - value: true, - }, - ]; - - const successData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_SEARCH_FILTERS_VENDOR_DATA_LOADED, - value: true, - }, - ]; - - const finallyData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA, - value: false, - }, - ]; - - read(READ_COMMANDS.OPEN_SEARCH_VENDOR_FILTERS_PAGE, null, {optimisticData, successData, finallyData}); -} - /** * Fetches a page of tag filter search results from the server. * Returns pagination metadata (hasMore, nextCursor) for infinite scroll. @@ -2657,7 +2629,6 @@ export { handlePreventSearchAPI, openSearchCardFiltersPage, openSearchCategoryFiltersPage, - openSearchVendorFiltersPage, openSearchTagFiltersPage, setSearchTagFiltersPagination, clearSearchTagFiltersState, diff --git a/src/setup/index.ts b/src/setup/index.ts index 7f43c08e211c..db2eb0cf3555 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -90,7 +90,6 @@ export default function () { ONYXKEYS.RAM_ONLY_HAS_RULES_DATA_BEEN_FETCHED, ONYXKEYS.RAM_ONLY_IS_LOADING_RULES, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, - ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA, ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_PAGINATION, ONYXKEYS.RAM_ONLY_SEARCH_TAG_FILTERS_RESULTS, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, diff --git a/tests/ui/VendorSelectorTest.tsx b/tests/ui/VendorSelectorTest.tsx index 5766370dcf21..6c4fa2ce9538 100644 --- a/tests/ui/VendorSelectorTest.tsx +++ b/tests/ui/VendorSelectorTest.tsx @@ -5,8 +5,8 @@ import VendorSelector from '@components/Search/FilterComponents/VendorSelector'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyVendors} from '@src/types/onyx'; -import type {Connections} from '@src/types/onyx/Policy'; +import type {Policy} from '@src/types/onyx'; +import type {Connections, QBONonReimbursableExportAccountType} from '@src/types/onyx/Policy'; import React from 'react'; @@ -16,11 +16,7 @@ import createMock from '../utils/createMock'; const mockOnyxData: Record = {}; jest.mock('@components/Search/FilterComponents/MultiSelect', () => jest.fn(() => null)); -jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); -jest.mock('@hooks/useLoadSearchVendorData', () => jest.fn(() => ({isLoadingInitialVendors: false}))); jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); -jest.mock('@hooks/useTheme', () => jest.fn(() => ({}))); -jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({flex1: {}, flexColumn: {}, justifyContentCenter: {}, alignItemsCenter: {}, pl3: {}}))); jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: (key: string) => key, @@ -34,22 +30,23 @@ jest.mock('@hooks/useOnyx', () => }), ); -/** QBO workspace exporting card expenses as credit card transactions, with its synced vendors in its connections. */ -const buildQBOPolicy = (policyID: string, vendorNames: string[]): Policy => +/** QBO workspace with its synced vendors in its connections, exporting card expenses to the given destination. */ +const buildQBOPolicy = ( + policyID: string, + vendorNames: string[], + exportDestination: QBONonReimbursableExportAccountType = CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD, +): Policy => createMock({ ...createRandomPolicy(0), id: policyID, connections: createMock({ [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: {nonReimbursableExpensesExportDestination: CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD}, + config: {nonReimbursableExpensesExportDestination: exportDestination}, data: {vendors: vendorNames.map((name) => ({id: `${policyID}-${name}`, name, currency: 'USD'}))}, }, }), }); -const buildPolicyVendors = (vendorNames: string[]): PolicyVendors => - Object.fromEntries(vendorNames.map((name) => [name, {externalID: name, name, enabled: true, origin: CONST.POLICY.CONNECTIONS.NAME.QBO}])); - describe('VendorSelector', () => { const mockedMultiSelect = jest.mocked(MultiSelect); const getItemTexts = () => mockedMultiSelect.mock.lastCall?.[0].items.map((item) => item.text); @@ -61,10 +58,9 @@ describe('VendorSelector', () => { } }); - it('lists the vendors in the connections of a workspace with no loaded vendor list', () => { - // Given a member's workspace whose connections carry synced vendors, while the vendor list load returned nothing for it + it('lists the vendors in the connections of a workspace with the vendor feature', () => { + // Given a member's workspace whose connections carry synced vendors mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Zeta Supplies', 'Acme Tools'])}; - mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {}; // When the vendor picker renders render( @@ -75,14 +71,16 @@ describe('VendorSelector', () => { />, ); - // Then the workspace's vendors are offered after "No vendor", because members only get vendor lists through the workspace connections + // Then the workspace's vendors are offered after "No vendor", read from the same connections that turn the vendor feature on expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supplies']); }); - it('uses the loaded vendor list over the vendors cached in the connections', () => { - // Given a workspace whose loaded vendor list has a vendor renamed since its connections were cached - mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Acme Tools', 'Zeta Supplies'])}; - mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}qbo`]: buildPolicyVendors(['Acme Tools', 'Zeta Supply Co'])}; + it('lists a vendor name once when several workspaces sync it', () => { + // Given two vendor workspaces that both sync a vendor with the same name + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = { + [`${ONYXKEYS.COLLECTION.POLICY}first`]: buildQBOPolicy('first', ['Acme Tools', 'Bravo Freight']), + [`${ONYXKEYS.COLLECTION.POLICY}second`]: buildQBOPolicy('second', ['Acme Tools']), + }; // When the vendor picker renders render( @@ -93,14 +91,15 @@ describe('VendorSelector', () => { />, ); - // Then only the loaded list is offered, because it is rebuilt after every sync while the connections can be stale - expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supply Co']); + // Then the shared name appears once, because the filter matches vendors by name + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Bravo Freight']); }); - it('falls back to the connections when the loaded vendor list is empty', () => { - // Given a workspace whose loaded vendor list was built empty before the vendor feature was turned on, while its connections carry the synced vendors - mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = {[`${ONYXKEYS.COLLECTION.POLICY}qbo`]: buildQBOPolicy('qbo', ['Acme Tools', 'Zeta Supplies'])}; - mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {[`${ONYXKEYS.COLLECTION.POLICY_VENDORS}qbo`]: {}}; + it('leaves out the vendors of workspaces without the vendor feature', () => { + // Given a QBO workspace that exports card expenses as vendor bills, which syncs vendors but has no vendor feature + mockOnyxData[ONYXKEYS.COLLECTION.POLICY] = { + [`${ONYXKEYS.COLLECTION.POLICY}bill`]: buildQBOPolicy('bill', ['Bill Vendor'], CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL), + }; // When the vendor picker renders render( @@ -111,8 +110,8 @@ describe('VendorSelector', () => { />, ); - // Then the vendors from the connections are offered, because the empty list is stale until the next sync rebuilds it - expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Zeta Supplies']); + // Then only "No vendor" is offered + expect(getItemTexts()).toEqual(['search.noVendor']); }); it('only lists the vendors of the selected workspaces', () => { @@ -121,7 +120,6 @@ describe('VendorSelector', () => { [`${ONYXKEYS.COLLECTION.POLICY}first`]: buildQBOPolicy('first', ['Acme Tools']), [`${ONYXKEYS.COLLECTION.POLICY}second`]: buildQBOPolicy('second', ['Zeta Supplies']), }; - mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {}; // When the vendor picker renders with that workspace filter render( @@ -142,7 +140,6 @@ describe('VendorSelector', () => { [`${ONYXKEYS.COLLECTION.POLICY}first`]: buildQBOPolicy('first', ['Acme Tools']), [`${ONYXKEYS.COLLECTION.POLICY}second`]: buildQBOPolicy('second', ['Zeta Supplies']), }; - mockOnyxData[ONYXKEYS.COLLECTION.POLICY_VENDORS] = {}; // When the vendor picker renders with that negated workspace filter render( From aed845ebb7f655fca097372c591768edb7f944bd Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 17:35:16 +0530 Subject: [PATCH 7/8] Shorten the VendorSelector selector comment --- src/components/Search/FilterComponents/VendorSelector.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index 8c2ccd0467a8..bb2b6273466d 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -27,9 +27,7 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele const {isBetaEnabled} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); - // Only workspaces with the vendor feature on are offered, so a disconnected integration's leftover list never shows. - // Those workspaces always have their connections loaded, which carry the synced vendors, the same list the expense - // Vendor field offers. Only the names are kept so the selector result stays small. + // Uses the same synced vendors the expense Vendor field offers, keeping only the names so the selector result stays small. const vendorNamesSelector = useCallback( (allPolicies: OnyxCollection) => Object.fromEntries( From 7133233806208ebe2fcee25ca854ee1758c3ca4d Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Date: Tue, 6 Oct 2026 17:51:21 +0530 Subject: [PATCH 8/8] Build the vendor names in VendorSelector without a policy collection selector --- .../FilterComponents/VendorSelector.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/components/Search/FilterComponents/VendorSelector.tsx b/src/components/Search/FilterComponents/VendorSelector.tsx index bb2b6273466d..fdd9721aabe6 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -9,12 +9,8 @@ import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxCollection} from 'react-native-onyx'; - -import React, {useCallback} from 'react'; +import React from 'react'; import MultiSelect from './MultiSelect'; @@ -27,18 +23,15 @@ function VendorSelector({value = [], policyID, selectionListTextInputStyle, sele const {isBetaEnabled} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); - // Uses the same synced vendors the expense Vendor field offers, keeping only the names so the selector result stays small. - const vendorNamesSelector = useCallback( - (allPolicies: OnyxCollection) => - Object.fromEntries( - getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled).map((id) => [ - id, - getMatchingVendors(allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${id}`]).map((vendor) => vendor.name), - ]), - ), - [isVendorMatchingBetaEnabled], + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + + // Uses the same synced vendors the expense Vendor field offers. + const vendorNamesByPolicyID = Object.fromEntries( + getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled).map((id) => [ + id, + getMatchingVendors(allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${id}`]).map((vendor) => vendor.name), + ]), ); - const [vendorNamesByPolicyID = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorNamesSelector}); const noVendorLabel = translate('search.noVendor'); const selectedVendorItems = value.map((vendor) => {