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 84fc959dd390..fdd9721aabe6 100644 --- a/src/components/Search/FilterComponents/VendorSelector.tsx +++ b/src/components/Search/FilterComponents/VendorSelector.tsx @@ -1,25 +1,16 @@ -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 {getVendorFeaturePolicyIDs} from '@libs/PolicyUtils'; -import {getAllPolicyValues, sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +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 {getEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxCollection} from 'react-native-onyx'; - -import React, {useCallback} from 'react'; -import {View} from 'react-native'; +import React from 'react'; import MultiSelect from './MultiSelect'; @@ -31,15 +22,16 @@ 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(); - const vendorFeaturePolicyIDsSelector = useCallback( - (allPolicies: OnyxCollection) => getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled), - [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 [vendorFeaturePolicyIDs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorFeaturePolicyIDsSelector}); - const [allPolicyVendors = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_VENDORS); const noVendorLabel = translate('search.noVendor'); const selectedVendorItems = value.map((vendor) => { @@ -49,18 +41,12 @@ 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(vendorFeaturePolicyIDs); - const eligiblePolicyVendors: OnyxCollection = Object.fromEntries( - Object.entries(allPolicyVendors).filter(([key]) => eligiblePolicyIDs.has(key.replace(ONYXKEYS.COLLECTION.POLICY_VENDORS, ''))), - ); - + 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(policyID?.value?.length ? policyID : undefined, ONYXKEYS.COLLECTION.POLICY_VENDORS, eligiblePolicyVendors).flatMap((policyVendors) => - Object.values(policyVendors ?? {}).map((vendor) => vendor.name), - ), + Object.entries(vendorNamesByPolicyID) + .filter(([id]) => isPolicySelected(id)) + .flatMap(([, vendorNames]) => vendorNames), ); vendorItems.push( ...Array.from(uniqueVendorNames) @@ -69,18 +55,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 new file mode 100644 index 000000000000..6c4fa2ce9538 --- /dev/null +++ b/tests/ui/VendorSelectorTest.tsx @@ -0,0 +1,156 @@ +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} from '@src/types/onyx'; +import type {Connections, QBONonReimbursableExportAccountType} 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('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +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 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: exportDestination}, + data: {vendors: vendorNames.map((name) => ({id: `${policyID}-${name}`, name, currency: 'USD'}))}, + }, + }), + }); + +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 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'])}; + + // When the vendor picker renders + render( + , + ); + + // 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('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( + , + ); + + // Then the shared name appears once, because the filter matches vendors by name + expect(getItemTexts()).toEqual(['search.noVendor', 'Acme Tools', 'Bravo Freight']); + }); + + 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( + , + ); + + // Then only "No vendor" is offered + expect(getItemTexts()).toEqual(['search.noVendor']); + }); + + 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']), + }; + + // 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']); + }); + + 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']), + }; + + // 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']); + }); +});