Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7427,6 +7427,11 @@ const CONST = {
search: true,
reportView: true,
},
VENDOR: {
column: this.TABLE_COLUMNS.VENDOR,
search: true,
reportView: true,
},
DESCRIPTION: {
column: this.TABLE_COLUMNS.DESCRIPTION,
search: true,
Expand Down Expand Up @@ -7794,6 +7799,7 @@ const CONST = {
POSTED: 'posted',
EXPORTED: 'exported',
MERCHANT: 'merchant',
VENDOR: 'vendor',
DESCRIPTION: 'description',
FROM: 'from',
TO: 'to',
Expand Down Expand Up @@ -7905,6 +7911,7 @@ const CONST = {
EXPORTER: 'exporter',
CATEGORY: 'category',
TAG: 'tag',
VENDOR: 'vendor',
TAX_RATE: 'taxRate',
CARD_ID: 'cardID',
FEED: 'feed',
Expand Down Expand Up @@ -7956,6 +7963,7 @@ const CONST = {
TAG_EMPTY_VALUE: 'none',
CATEGORY_EMPTY_VALUE: 'none',
CATEGORY_DEFAULT_VALUE: 'Uncategorized',
VENDOR_EMPTY_VALUE: 'none',
MERCHANT_EMPTY_VALUE: 'none',
SEARCH_ROUTER_ITEM_TYPE: {
CONTEXTUAL_SUGGESTION: 'contextualSuggestion',
Expand Down Expand Up @@ -7991,6 +7999,7 @@ const CONST = {
EXPORTER: 'exporter',
CATEGORY: 'category',
TAG: 'tag',
VENDOR: 'vendor',
TAX_RATE: 'tax-rate',
CARD_ID: 'card',
FEED: 'feed',
Expand Down Expand Up @@ -8037,6 +8046,7 @@ const CONST = {
[this.TABLE_COLUMNS.POSTED]: 'posted',
[this.TABLE_COLUMNS.EXPORTED]: 'exported',
[this.TABLE_COLUMNS.MERCHANT]: 'merchant',
[this.TABLE_COLUMNS.VENDOR]: 'vendor',
[this.TABLE_COLUMNS.DESCRIPTION]: 'description',
[this.TABLE_COLUMNS.FROM]: 'from',
[this.TABLE_COLUMNS.TO]: 'to',
Expand Down
8 changes: 8 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,12 @@ const ONYXKEYS = {
/** Set while search filter category data is loading */
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',

IS_TEST_TOOLS_MODAL_OPEN: 'isTestToolsModalOpen',
Expand Down Expand Up @@ -1746,6 +1752,8 @@ type OnyxValuesMapping = {
[ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean;
[ONYXKEYS.IS_SEARCH_FILTERS_CATEGORY_DATA_LOADED]: 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
import useTheme from '@hooks/useTheme';
Expand All @@ -23,7 +24,7 @@ import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransaction
import {resolveTransactionCardFields} from '@libs/CardUtils';
import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils';
import {navigationRef} from '@libs/Navigation/Navigation';
import {isPolicyTaxEnabled} from '@libs/PolicyUtils';
import {hasVendorFeature, isPolicyTaxEnabled} from '@libs/PolicyUtils';
import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils';
import {groupTransactionsByCategory, groupTransactionsByTag} from '@libs/ReportLayoutUtils';
import {
Expand Down Expand Up @@ -269,6 +270,7 @@ function MoneyRequestReportTransactionList({
const longPressModalRef = useRef<MoneyRequestReportTransactionLongPressModalHandle>(null);
const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report);
const {isOffline} = useNetwork();
const {isBetaEnabled} = usePermissions();

const isTaxEnabled = isPolicyTaxEnabled(policy);
const {totalDisplaySpend} = getMoneyRequestSpendBreakdown(report);
Expand Down Expand Up @@ -446,12 +448,15 @@ function MoneyRequestReportTransactionList({
const isExpenseReportViewFromIOUReport = isIOUReport(report);
const shouldShowBillableColumn = isBillableEnabledOnPolicy(policy);
const shouldShowCommentsColumn = useMemo(() => Object.values(reportActions ?? {}).some((action) => (action?.childVisibleActionCount ?? 0) > 0), [reportActions]);
// The saved column list is account-wide, so drop the vendor column on reports whose workspace lacks the vendor feature.
const isVendorColumnAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING));
const columnsToShow = useMemo(() => {
const savedColumns = (reportDetailsColumns ?? []).filter((column) => isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR);
return getColumnsToShow({
currentAccountID: currentUserDetails?.accountID,
data: transactions,
report,
visibleColumns: (isExpenseReportViewFromIOUReport ? [] : (reportDetailsColumns ?? [])) as SearchCustomColumnIds[],
visibleColumns: (isExpenseReportViewFromIOUReport ? [] : savedColumns) as SearchCustomColumnIds[],
isExpenseReportView: true,
isExpenseReportViewFromIOUReport,
shouldShowBillableColumn,
Expand All @@ -467,6 +472,7 @@ function MoneyRequestReportTransactionList({
shouldShowBillableColumn,
shouldShowCommentsColumn,
reportDetailsColumns,
isVendorColumnAvailable,
report,
isTaxEnabled,
shouldShowExpenseReportBreakDown,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import TagSelector from './TagSelector';
import TaxRateSelector from './TaxRateSelector';
import TypeSelector from './TypeSelector';
import UserSelector from './UserSelector';
import VendorSelector from './VendorSelector';
import WorkspaceSelector from './WorkspaceSelector';

type FilterKeys = Exclude<SearchFilter['key'], SearchDateFilterKeys | SearchAmountFilterKeys | SearchTextFilterKeys | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD>;
Expand Down Expand Up @@ -174,6 +175,7 @@ function ListFilterContent({
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.VENDOR:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: {
const Component = {
[CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED]: FeedSelector,
Expand All @@ -183,6 +185,7 @@ function ListFilterContent({
[CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE]: TaxRateSelector,
[CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO]: ExportedToSelector,
[CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG]: TagSelector,
[CONST.SEARCH.SYNTAX_FILTER_KEYS.VENDOR]: VendorSelector,
[CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY]: CategorySelector,
}[baseFilterKey];
content = (
Expand Down
98 changes: 98 additions & 0 deletions src/components/Search/FilterComponents/VendorSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 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 MultiSelect from './MultiSelect';

type VendorSelectorProps = SearchFilterCommonProps<string[] | undefined> & {
policyID: Filter | undefined;
};

function VendorSelector({value = [], policyID, selectionListTextInputStyle, selectionListStyle, autoFocus, footer, onChange}: VendorSelectorProps) {
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<Policy>) => getVendorFeaturePolicyIDs(allPolicies, isVendorMatchingBetaEnabled),
[isVendorMatchingBetaEnabled],
);
const [vendorFeaturePolicyIDs] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: vendorFeaturePolicyIDsSelector});
const [allPolicyVendors = getEmptyObject<NonNullable<OnyxCollection<PolicyVendors>>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_VENDORS);

const noVendorLabel = translate('search.noVendor');
const selectedVendorItems = value.map((vendor) => {
if (vendor === CONST.SEARCH.VENDOR_EMPTY_VALUE) {
return {text: noVendorLabel, value: vendor};
}
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<PolicyVendors> = Object.fromEntries(
Object.entries(allPolicyVendors).filter(([key]) => eligiblePolicyIDs.has(key.replace(ONYXKEYS.COLLECTION.POLICY_VENDORS, ''))),
);

const vendorItems = [{text: noVendorLabel, value: CONST.SEARCH.VENDOR_EMPTY_VALUE as string}];
const uniqueVendorNames = new Set<string>(
getAllPolicyValues(policyID?.value?.length ? policyID : undefined, ONYXKEYS.COLLECTION.POLICY_VENDORS, eligiblePolicyVendors).flatMap((policyVendors) =>
Object.values(policyVendors ?? {}).map((vendor) => vendor.name),
),
);
vendorItems.push(
...Array.from(uniqueVendorNames)
.filter(Boolean)
.map((vendorName) => ({text: vendorName, value: vendorName}))
.toSorted((a, b) => sortOptionsWithEmptyValue(a.text, b.text, localeCompare)),
);

if (isLoadingInitialVendors) {
return (
<View style={[styles.flex1, styles.flexColumn, styles.justifyContentCenter, styles.alignItemsCenter]}>
<ActivityIndicator
color={theme.spinner}
size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE}
style={[styles.pl3]}
/>
</View>
);
}

return (
<MultiSelect
value={selectedVendorItems}
items={vendorItems}
isSearchable={vendorItems.length >= CONST.STANDARD_LIST_ITEM_LIMIT}
autoFocus={autoFocus}
selectionListTextInputStyle={selectionListTextInputStyle}
selectionListStyle={selectionListStyle}
footer={footer}
onChange={(vendors) => onChange(vendors.map((vendor) => vendor.value))}
/>
);
}

export default VendorSelector;
5 changes: 5 additions & 0 deletions src/components/Search/SearchTableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
translationKey: 'common.merchant',
canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.VENDOR,
translationKey: 'common.vendor',
isColumnSortable: false,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION,
translationKey: 'common.description',
Expand Down
11 changes: 10 additions & 1 deletion src/components/TransactionItemRow/TransactionItemRowWide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles';

import {getCategoryGLCode} from '@libs/CategoryUtils';
import getBase62ReportID from '@libs/getBase62ReportID';
import {isTaxCodeCustomized, getTagGLCode} from '@libs/PolicyUtils';
import {getTagGLCode, getVendorDisplayName, isTaxCodeCustomized} from '@libs/PolicyUtils';
import {getReportName} from '@libs/ReportNameUtils';
import {getReimbursableTotal, isExpenseReport} from '@libs/ReportUtils';
import {getViolationsForTransaction} from '@libs/SearchUIUtils';
Expand Down Expand Up @@ -391,6 +391,15 @@ function TransactionItemRowWide({
/>
</View>
);
case CONST.SEARCH.TABLE_COLUMNS.VENDOR:
return (
<View
key={column}
style={[getSearchColumnStyles(CONST.SEARCH.TABLE_COLUMNS.VENDOR)]}
>
<TextCell text={getVendorDisplayName(policy, transactionItem.comment?.vendor)} />
</View>
);
case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION:
return (
<View
Expand Down
15 changes: 14 additions & 1 deletion src/hooks/useAdvancedSearchFilters.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {Filter} from '@components/Search/types';

import {isFilterableBankAccount} from '@libs/BankAccountUtils';
import {isPolicyFeatureEnabled} from '@libs/PolicyUtils';
import {hasVendorFeatureOnAnyPolicy, isPolicyFeatureEnabled} from '@libs/PolicyUtils';
import {getAllPolicyValues} from '@libs/SearchQueryUtils';

import CONST from '@src/CONST';
Expand All @@ -14,9 +14,11 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';

import {filterCardsHiddenFromSearch} from '@selectors/Card';
import {emailSelector} from '@selectors/Session';
import {useCallback} from 'react';

import useLocalize from './useLocalize';
import useOnyx from './useOnyx';
import usePermissions from './usePermissions';
import useWorkspaceList from './useWorkspaceList';

/**
Expand All @@ -41,6 +43,7 @@ const typeFiltersKeys = {
CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION,
CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY,
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG,
CONST.SEARCH.SYNTAX_FILTER_KEYS.VENDOR,
CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE,
CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE,
CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE,
Expand Down Expand Up @@ -325,6 +328,13 @@ function useAdvancedSearchFilters(type: SearchDataTypes | undefined, policyID: F
const [shouldDisplayCardFilter] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: shouldDisplayCardFilterSelector});
const [policies = getEmptyObject<NonNullable<OnyxCollection<Policy>>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: advancedSearchPoliciesSelector});
const [policyDerived] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policyDerivedSelector});
const {isBetaEnabled} = usePermissions();
const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING);
const isVendorFilterAvailableSelector = useCallback(
(allPolicies: OnyxCollection<Policy>) => hasVendorFeatureOnAnyPolicy(allPolicies, isVendorMatchingBetaEnabled),
[isVendorMatchingBetaEnabled],
);
const [isVendorFilterAvailable = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: isVendorFilterAvailableSelector});
const [allPolicyTagLists = getEmptyObject<NonNullable<OnyxCollection<PolicyTagLists>>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const selectedPolicyTagLists = policyID?.value?.length ? getAllPolicyValues(policyID, ONYXKEYS.COLLECTION.POLICY_TAGS, allPolicyTagLists) : [];
const [hasTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: hasTagsSelector});
Expand Down Expand Up @@ -353,6 +363,9 @@ function useAdvancedSearchFilters(type: SearchDataTypes | undefined, policyID: F
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && !shouldDisplayTagFilter) {
return;
}
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.VENDOR && !isVendorFilterAvailable) {
return;
}
if (
(key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED) &&
!shouldDisplayCardFilter
Expand Down
59 changes: 59 additions & 0 deletions src/hooks/useLoadSearchVendorData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {openSearchVendorFiltersPage} from '@libs/actions/Search';

import ONYXKEYS from '@src/ONYXKEYS';

import {useEffect, useRef} from 'react';

import useNetwork from './useNetwork';
import useOnyx from './useOnyx';

type UseLoadSearchVendorDataParams = {
/** Whether vendor data should be loaded. */
shouldLoad?: boolean;

/** Whether already loaded vendor data should be refreshed. */
shouldRefresh?: boolean;
};

function useLoadSearchVendorData({shouldLoad = true, shouldRefresh = false}: UseLoadSearchVendorDataParams = {}) {
const {isOffline} = useNetwork();
const [areVendorsLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_VENDOR_DATA_LOADED);
const [isLoadingVendors] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_VENDOR_DATA);
const hasRequestedVendorDataRef = useRef(false);
const hasObservedVendorRequestStateRef = useRef(!!areVendorsLoaded || isLoadingVendors !== undefined);

useEffect(() => {
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;
Loading
Loading