diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dbfd8a7db054..079783ab5bba 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8787,8 +8787,10 @@ const CONST = { FILTER_FROM: 'Search-FilterFrom', FILTER_WORKSPACE: 'Search-FilterWorkspace', FILTER_GROUP_BY: 'Search-FilterGroupBy', + FILTER_SORT_BY: 'Search-FilterSortBy', FILTER_GROUP_CURRENCY: 'Search-FilterGroupCurrency', FILTER_VIEW: 'Search-FilterView', + FILTER_LIMIT: 'Search-FilterLimit', FILTER_FEED: 'Search-FilterFeed', FILTER_POSTED: 'Search-FilterPosted', FILTER_WITHDRAWN: 'Search-FilterWithdrawn', @@ -8811,10 +8813,19 @@ const CONST = { FILTER_POPUP_APPLY_MULTI_SELECT: 'Search-FilterPopupApplyMultiSelect', FILTER_POPUP_RESET_TEXT_INPUT: 'Search-FilterPopupResetTextInput', FILTER_POPUP_APPLY_TEXT_INPUT: 'Search-FilterPopupApplyTextInput', + FILTER_POPUP_RESET_AMOUNT: 'Search-FilterPopupResetAmount', + FILTER_POPUP_APPLY_AMOUNT: 'Search-FilterPopupApplyAmount', + FILTER_POPUP_SAVE_AMOUNT: 'Search-FilterPopupSaveAmount', FILTER_POPUP_RESET_DATE: 'Search-FilterPopupResetDate', FILTER_POPUP_APPLY_DATE: 'Search-FilterPopupApplyDate', FILTER_POPUP_RESET_USER: 'Search-FilterPopupResetUser', FILTER_POPUP_APPLY_USER: 'Search-FilterPopupApplyUser', + FILTER_POPUP_RESET_REPORT: 'Search-FilterPopupResetReport', + FILTER_POPUP_APPLY_REPORT: 'Search-FilterPopupApplyReport', + FILTER_POPUP_RESET_REPORT_FIELD: 'Search-FilterPopupResetReportField', + FILTER_POPUP_APPLY_REPORT_FIELD: 'Search-FilterPopupApplyReportField', + FILTER_POPUP_RESET_CARD: 'Search-FilterPopupResetCard', + FILTER_POPUP_APPLY_CARD: 'Search-FilterPopupApplyCard', TRANSACTION_LIST_ITEM_CHECKBOX: 'Search-TransactionListItemCheckbox', EXPANDED_TRANSACTION_ROW: 'Search-ExpandedTransactionRow', EXPANDED_TRANSACTION_ROW_CHECKBOX: 'Search-ExpandedTransactionRowCheckbox', diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/ActionButtons.tsx b/src/components/Search/FilterDropdowns/ActionButtons.tsx similarity index 100% rename from src/components/Search/FilterDropdowns/DateSelectPopup/ActionButtons.tsx rename to src/components/Search/FilterDropdowns/ActionButtons.tsx diff --git a/src/components/Search/FilterDropdowns/AmountPopup.tsx b/src/components/Search/FilterDropdowns/AmountPopup.tsx new file mode 100644 index 000000000000..88eac1b5fbdc --- /dev/null +++ b/src/components/Search/FilterDropdowns/AmountPopup.tsx @@ -0,0 +1,153 @@ +import React, {useState} from 'react'; +import type {ValueOf} from 'type-fest'; +import AmountWithoutCurrencyInput from '@components/AmountWithoutCurrencyInput'; +import MenuItem from '@components/MenuItem'; +import type {SearchAmountFilterKeys} from '@components/Search/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import CONST from '@src/CONST'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import BasePopup from './BasePopup'; + +type AmountPopupProps = { + filterKey: SearchAmountFilterKeys; + label: string; + value: Record, string | undefined>; + updateFilterForm: (value: Partial) => void; + closeOverlay: () => void; +}; + +type AmountInputProps = { + title: string; + value: string; + name: string; + onSave: (value: string) => void; + onBackButtonPress: () => void; +}; + +function AmountInput({title, value, name, onSave, onBackButtonPress}: AmountInputProps) { + const styles = useThemeStyles(); + const [amount, setAmount] = useState(value); + + return ( + onSave('')} + onApply={() => onSave(amount)} + resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_AMOUNT} + applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_AMOUNT} + onBackButtonPress={onBackButtonPress} + > + + + ); +} + +function AmountPopup({filterKey, label, value, closeOverlay, updateFilterForm}: AmountPopupProps) { + const {translate} = useLocalize(); + const [selectedModifier, setSelectedModifier] = useState | null>(null); + const [amountValues, setAmountValues] = useState(value); + + const title = { + [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]: translate('search.filters.amount.equalTo'), + [CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]: translate('search.filters.amount.greaterThan'), + [CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]: translate('search.filters.amount.lessThan'), + }; + + const modifierConfig = [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO, CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN, CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; + + const formatAmount = (amount: string | undefined) => { + return amount ? convertToFrontendAmountAsString(Number(amount), CONST.DEFAULT_CURRENCY_DECIMALS) : ''; + }; + + if (selectedModifier) { + const goBack = () => { + setSelectedModifier(null); + }; + + const save = (rawAmount: string) => { + if (rawAmount.trim() === '') { + setAmountValues((prevAmountValues) => ({...prevAmountValues, [selectedModifier]: undefined})); + goBack(); + return; + } + + const newAmount = convertToBackendAmount(Number(rawAmount)).toString(); + + // When setting an Equal To value, clear Greater Than and Less Than to avoid conflicting filters. + if (selectedModifier === CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO) { + setAmountValues({[CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]: '', [CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]: '', [selectedModifier]: newAmount}); + } + + // When setting Greater Than or Less Than, clear Equal To to avoid conflicting filters. + if (selectedModifier === CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN || selectedModifier === CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN) { + setAmountValues((prevAmountValues) => ({...prevAmountValues, [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]: '', [selectedModifier]: newAmount})); + } + goBack(); + }; + + return ( + + ); + } + + const onChange = (values: Record, string | undefined>) => { + const formValues: Record = {}; + formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]; + formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]; + formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; + updateFilterForm(formValues); + closeOverlay(); + }; + + const applyChanges = () => onChange(amountValues); + + const resetChanges = () => { + onChange({ + [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]: undefined, + [CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]: undefined, + [CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]: undefined, + }); + }; + + return ( + + {modifierConfig.map((modifier) => ( + setSelectedModifier(modifier)} + shouldShowRightIcon + viewMode={CONST.OPTION_MODE.COMPACT} + /> + ))} + + ); +} + +export default AmountPopup; diff --git a/src/components/Search/FilterDropdowns/BasePopup.tsx b/src/components/Search/FilterDropdowns/BasePopup.tsx new file mode 100644 index 000000000000..845efe384cc7 --- /dev/null +++ b/src/components/Search/FilterDropdowns/BasePopup.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ActionButtons from './ActionButtons'; + +type BasePopupProps = React.PropsWithChildren & { + label?: string; + applySentryLabel: string; + resetSentryLabel: string; + style?: StyleProp; + onApply: () => void; + onReset: () => void; + onBackButtonPress?: () => void; +}; + +function BasePopup({children, label, applySentryLabel, resetSentryLabel, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const styles = useThemeStyles(); + + return ( + + {onBackButtonPress ? ( + + ) : ( + isSmallScreenWidth && !!label && {label} + )} + {children} + + + ); +} + +export default BasePopup; diff --git a/src/components/Search/FilterDropdowns/CardSelectPopup.tsx b/src/components/Search/FilterDropdowns/CardSelectPopup.tsx new file mode 100644 index 000000000000..52a6a0defeed --- /dev/null +++ b/src/components/Search/FilterDropdowns/CardSelectPopup.tsx @@ -0,0 +1,203 @@ +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import CardListItem from '@components/SelectionList/ListItem/CardListItem'; +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; +import useThemeIllustrations from '@hooks/useThemeIllustrations'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {openSearchCardFiltersPage} from '@libs/actions/Search'; +import {buildCardFeedsData, buildCardsData, generateSelectedCards, getDomainFeedData, getSelectedCardsFromFeeds} from '@libs/CardFeedUtils'; +import type {CardFilterItem} from '@libs/CardFeedUtils'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import BasePopup from './BasePopup'; + +type CardSelectPopupProps = { + isExpanded: boolean; + closeOverlay: () => void; + updateFilterForm: (values: Partial) => void; +}; + +function CardSelectPopup({isExpanded, updateFilterForm, closeOverlay}: CardSelectPopupProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const illustrations = useThemeIllustrations(); + const companyCardFeedIcons = useCompanyCardFeedIcons(); + const {windowHeight} = useWindowDimensions(); + + const [areCardsLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED); + const [userCardList, userCardListMetadata] = useOnyx(ONYXKEYS.CARD_LIST); + const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); + const [workspaceCardFeeds, workspaceCardFeedsMetadata] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); + const [policies, policiesMetadata] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [searchAdvancedFiltersForm, searchAdvancedFiltersFormMetadata] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const personalDetails = usePersonalDetails(); + + const [selectedCards, setSelectedCards] = useState([]); + + useEffect(() => { + if (isOffline || !isExpanded) { + return; + } + openSearchCardFiltersPage(); + }, [isOffline, isExpanded]); + + useEffect(() => { + const generatedCards = generateSelectedCards(userCardList, workspaceCardFeeds, searchAdvancedFiltersForm?.feed, searchAdvancedFiltersForm?.cardID); + setSelectedCards(generatedCards); + }, [searchAdvancedFiltersForm?.feed, searchAdvancedFiltersForm?.cardID, workspaceCardFeeds, userCardList]); + + const individualCardsSectionData = buildCardsData( + workspaceCardFeeds ?? {}, + userCardList ?? {}, + personalDetails ?? {}, + selectedCards, + illustrations, + companyCardFeedIcons, + false, + customCardNames, + ); + + const closedCardsSectionData = buildCardsData( + workspaceCardFeeds ?? {}, + userCardList ?? {}, + personalDetails ?? {}, + selectedCards, + illustrations, + companyCardFeedIcons, + true, + customCardNames, + ); + + const domainFeedsData = getDomainFeedData(workspaceCardFeeds); + + const cardFeedsSectionData = buildCardFeedsData(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, domainFeedsData, policies, selectedCards, translate, illustrations, companyCardFeedIcons); + + const shouldShowSearchInput = + cardFeedsSectionData.selected.length + cardFeedsSectionData.unselected.length + individualCardsSectionData.selected.length + individualCardsSectionData.unselected.length >= + CONST.STANDARD_LIST_ITEM_LIMIT; + + const searchFunction = (item: CardFilterItem) => + !!item.text?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + !!item.lastFourPAN?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + !!item.cardName?.toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase()) || + (item.isVirtual && translate('workspace.expensifyCard.virtual').toLocaleLowerCase().includes(debouncedSearchTerm.toLocaleLowerCase())); + + const sections = + searchAdvancedFiltersForm === undefined + ? [] + : [ + { + title: undefined, + data: [...cardFeedsSectionData.selected, ...individualCardsSectionData.selected, ...closedCardsSectionData.selected].filter(searchFunction), + sectionIndex: 0, + }, + { + title: translate('search.filters.card.cardFeeds'), + data: cardFeedsSectionData.unselected.filter(searchFunction), + sectionIndex: 1, + }, + { + title: translate('search.filters.card.individualCards'), + data: individualCardsSectionData.unselected.filter(searchFunction), + sectionIndex: 2, + }, + { + title: translate('search.filters.card.closedCards'), + data: closedCardsSectionData.unselected.filter(searchFunction), + sectionIndex: 3, + }, + ]; + + const applyChanges = () => { + const feeds = cardFeedsSectionData.selected.map((feed) => feed.cardFeedKey); + const cardsFromSelectedFeed = getSelectedCardsFromFeeds(userCardList, workspaceCardFeeds, feeds); + const cardIDs = selectedCards.filter((card) => !cardsFromSelectedFeed.includes(card)); + + updateFilterForm({cardID: cardIDs, feed: feeds}); + closeOverlay(); + }; + + const resetChanges = () => { + updateFilterForm({feed: undefined, cardID: undefined}); + closeOverlay(); + }; + + const updateNewCards = (item: CardFilterItem) => { + if (!item.keyForList) { + return; + } + + const isCardFeed = item?.isCardFeed && item?.correspondingCards; + + if (item.isSelected) { + const newCardsObject = selectedCards.filter((card) => (isCardFeed ? !item.correspondingCards?.includes(card) : card !== item.keyForList)); + setSelectedCards(newCardsObject); + } else { + const newCardsObject = isCardFeed ? [...selectedCards, ...(item?.correspondingCards ?? [])] : [...selectedCards, item.keyForList]; + setSelectedCards(newCardsObject); + } + }; + + const textInputOptions = { + value: searchTerm, + label: translate('common.search'), + onChangeText: setSearchTerm, + headerMessage: debouncedSearchTerm.trim() && sections.every((section) => !section.data.length) ? translate('common.noResultsFound') : '', + }; + + const isLoadingOnyxData = isLoadingOnyxValue(userCardListMetadata, workspaceCardFeedsMetadata, searchAdvancedFiltersFormMetadata, policiesMetadata); + const shouldShowLoadingState = isLoadingOnyxData || (!areCardsLoaded && !isOffline); + const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'SearchFiltersCardPage', isLoadingFromOnyx: isLoadingOnyxData}; + + return ( + + {!!shouldShowLoadingState && ( + + + + )} + {!shouldShowLoadingState && ( + section.data).length || 1, windowHeight, shouldShowSearchInput)]}> + + sections={sections} + ListItem={CardListItem} + onSelectRow={updateNewCards} + shouldPreventDefaultFocusOnSelectRow={false} + shouldShowTextInput={shouldShowSearchInput} + textInputOptions={textInputOptions} + shouldStopPropagation + canSelectMultiple + /> + + )} + + ); +} + +export default CardSelectPopup; diff --git a/src/components/Search/FilterDropdowns/CategorySelectPopup.tsx b/src/components/Search/FilterDropdowns/CategorySelectPopup.tsx new file mode 100644 index 000000000000..d23c7642e0f5 --- /dev/null +++ b/src/components/Search/FilterDropdowns/CategorySelectPopup.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import MultiSelectFilterPopup from '@components/Search/SearchPageHeader/MultiSelectFilterPopup'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import type {MultiSelectItem} from './MultiSelectPopup'; + +type CategorySelectPopupProps = { + closeOverlay: () => void; + updateFilterForm: (values: Partial) => void; +}; + +function CategorySelectPopup({closeOverlay, updateFilterForm}: CategorySelectPopupProps) { + const {translate} = useLocalize(); + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); + const policyIDs = searchAdvancedFiltersForm?.policyID ?? []; + + const selectedCategoriesItems = searchAdvancedFiltersForm?.category?.map((category) => { + if (category === CONST.SEARCH.CATEGORY_EMPTY_VALUE) { + return {text: translate('search.noCategory'), value: category}; + } + return {text: category, value: category}; + }); + + const availableNonPersonalPolicyCategoriesSelector = (policyCategories: OnyxCollection) => + Object.fromEntries( + Object.entries(policyCategories ?? {}).filter(([key, categories]) => { + if (key === `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${personalPolicyID}`) { + return false; + } + const availableCategories = Object.values(categories ?? {}).filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return availableCategories.length > 0; + }), + ); + + const [allPolicyCategories = getEmptyObject>>()] = useOnyx( + ONYXKEYS.COLLECTION.POLICY_CATEGORIES, + { + selector: availableNonPersonalPolicyCategoriesSelector, + }, + [availableNonPersonalPolicyCategoriesSelector], + ); + const selectedPoliciesCategories: PolicyCategory[] = Object.keys(allPolicyCategories ?? {}) + .filter((key) => policyIDs?.map((policyID) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`)?.includes(key)) + .map((key) => Object.values(allPolicyCategories?.[key] ?? {})) + .flat(); + + const categoryItems = [{text: translate('search.noCategory'), value: CONST.SEARCH.CATEGORY_EMPTY_VALUE as string}]; + const uniqueCategoryNames = new Set(); + if (policyIDs?.length === 0) { + const categories = Object.values(allPolicyCategories ?? {}).flatMap((policyCategories) => Object.values(policyCategories ?? {})); + for (const category of categories) { + uniqueCategoryNames.add(category.name); + } + } else if (selectedPoliciesCategories.length > 0) { + for (const category of selectedPoliciesCategories) { + uniqueCategoryNames.add(category.name); + } + } + categoryItems.push( + ...Array.from(uniqueCategoryNames) + .filter(Boolean) + .map((categoryName) => { + const decodedCategoryName = getDecodedCategoryName(categoryName); + return {text: decodedCategoryName, value: categoryName}; + }), + ); + + const updateCategoryFilterForm = (items: Array>) => { + updateFilterForm({category: items.map((item) => item.value)}); + }; + + return ( + = CONST.STANDARD_LIST_ITEM_LIMIT} + onChangeCallback={updateCategoryFilterForm} + /> + ); +} + +export default CategorySelectPopup; diff --git a/src/components/Search/FilterDropdowns/CurrencySelectPopup.tsx b/src/components/Search/FilterDropdowns/CurrencySelectPopup.tsx new file mode 100644 index 000000000000..e5d74588873c --- /dev/null +++ b/src/components/Search/FilterDropdowns/CurrencySelectPopup.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {useCurrencyListActions, useCurrencyListState} from '@components/CurrencyListContextProvider'; +import MultiSelectFilterPopup from '@components/Search/SearchPageHeader/MultiSelectFilterPopup'; +import {getCurrencyOptions} from '@libs/SearchUIUtils'; +import type {TranslationPaths} from '@src/languages/types'; +import type {MultiSelectItem} from './MultiSelectPopup'; + +type CurrencySelectPopupProps = { + translationKey: TranslationPaths; + value: string[]; + closeOverlay: () => void; + onChange: (item: Array>) => void; +}; + +function CurrencySelectPopup({translationKey, value, onChange, closeOverlay}: CurrencySelectPopupProps) { + const {currencyList} = useCurrencyListState(); + const {getCurrencySymbol} = useCurrencyListActions(); + const currencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol); + const currencyValues = currencyOptions.filter((option) => value.includes(option.value)); + + return ( + + ); +} + +export default CurrencySelectPopup; diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index 3ad0ba9194d5..45e7d153bb7c 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -1,9 +1,11 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; import ScrollView from '@components/ScrollView'; import DatePresetFilterBase from '@components/Search/FilterComponents/DatePresetFilterBase'; import type {SearchDatePresetFilterBaseHandle} from '@components/Search/FilterComponents/DatePresetFilterBase'; +import ActionButtons from '@components/Search/FilterDropdowns/ActionButtons'; import type {SearchDatePreset} from '@components/Search/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -14,12 +16,11 @@ import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDateModifierTitle, getDateRangeDisplayValueFromFormValue} from '@libs/SearchQueryUtils'; import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import ActionButtons from './ActionButtons'; import SelectedDateModifierHeader from './SelectedDateModifierHeader'; type DateSelectPopupProps = { /** The label to show when in an overlay on mobile */ - label: string; + label?: string; /** The current date values */ value: SearchDateValues; @@ -27,6 +28,9 @@ type DateSelectPopupProps = { /** The date presets */ presets?: SearchDatePreset[]; + /** Additional style props */ + style?: StyleProp; + /** Function to call when changes are applied */ onChange: (value: SearchDateValues) => void; @@ -37,7 +41,7 @@ type DateSelectPopupProps = { setPopoverWidth?: (width: number | undefined) => void; }; -function DateSelectPopup({label, value, presets, closeOverlay, onChange, setPopoverWidth}: DateSelectPopupProps) { +function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, setPopoverWidth}: DateSelectPopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -133,7 +137,7 @@ function DateSelectPopup({label, value, presets, closeOverlay, onChange, setPopo if (!isSmallScreenWidth) { return ( - + {!!selectedDateModifier && ( - {!selectedDateModifier && {label}} + + {!selectedDateModifier && !!label && {label}} setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_SORT_BY} /> {(isExpenseType || isTripType) && ( setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_BY} /> )} {isExpenseType && !!groupByValue && ( @@ -86,6 +88,7 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP description={translate('search.view.label')} title={viewValue ? translate(`search.view.${viewValue}`) : undefined} onPress={() => setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_VIEW} /> )} {isExpenseType && ( @@ -94,6 +97,7 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP description={translate('search.display.limitResults')} title={limitValue} onPress={() => setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_LIMIT} /> )} {shouldShowColumnsButton && ( @@ -193,7 +197,7 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP setSelectedDisplayFilter(null)} /> diff --git a/src/components/Search/FilterDropdowns/ExportedToSelectPopup.tsx b/src/components/Search/FilterDropdowns/ExportedToSelectPopup.tsx new file mode 100644 index 000000000000..b95ca22e1bfb --- /dev/null +++ b/src/components/Search/FilterDropdowns/ExportedToSelectPopup.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {TupleToUnion} from 'type-fest'; +import Icon from '@components/Icon'; +import MultiSelectFilterPopup from '@components/Search/SearchPageHeader/MultiSelectFilterPopup'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getSearchValueForConnection} from '@libs/AccountingUtils'; +import {getExportTemplates} from '@libs/actions/Search'; +import {getConnectedIntegrationNamesForPolicies} from '@libs/PolicyUtils'; +import {getIntegrationIcon} from '@libs/ReportUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {MultiSelectItem} from './MultiSelectPopup'; + +type ExportedToSelectPopupProps = { + closeOverlay: () => void; + updateFilterForm: (values: Partial) => void; +}; + +const STANDARD_EXPORT_TEMPLATE_ID_TO_DISPLAY_LABEL: Record = { + [CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT]: CONST.REPORT.EXPORT_OPTION_LABELS.REPORT_LEVEL_EXPORT, + [CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT]: CONST.REPORT.EXPORT_OPTION_LABELS.EXPENSE_LEVEL_EXPORT, +}; + +function filterExportedToSelector(searchAdvancedFiltersForm: SearchAdvancedFiltersForm | undefined) { + return searchAdvancedFiltersForm?.exportedTo; +} + +function ExportedToSelectPopup({closeOverlay, updateFilterForm}: ExportedToSelectPopupProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['XeroSquare', 'QBOSquare', 'NetSuiteSquare', 'IntacctSquare', 'QBDSquare', 'CertiniaSquare', 'Table']); + const [exportedTo] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterExportedToSelector}); + const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); + const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const policyIDs = searchAdvancedFiltersForm?.policyID ?? []; + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + + const connectedIntegrationNames = getConnectedIntegrationNamesForPolicies(policies, policyIDs.length > 0 ? policyIDs : undefined); + + const integrationConnectionNames = Object.values(CONST.POLICY.CONNECTIONS.NAME); + + const tableIconForExportOption = ( + + + + ); + const exportedToPickerOptions = (() => { + const integrationConnectionNamesSet = new Set(integrationConnectionNames); + + const connectedIntegrationPickerItems = integrationConnectionNames + .filter((connectionName) => connectedIntegrationNames.has(connectionName)) + .map((connectionName) => { + const icon = getIntegrationIcon(connectionName, expensifyIcons); + const leftElement = icon ? ( + + + + ) : ( + tableIconForExportOption + ); + return { + text: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName], + value: getSearchValueForConnection(connectionName), + leftElement, + }; + }); + + const usedPickerValueKeys = new Set(connectedIntegrationPickerItems.map((item) => item.value)); + const policiesToLoadTemplatesFrom = policyIDs.length > 0 ? policyIDs.map((id) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${id}`]).filter(Boolean) : Object.values(policies ?? {}); + const exportTemplatesFromPolicies = policiesToLoadTemplatesFrom.flatMap((policy) => getExportTemplates([], {}, translate, policy, false)); + const exportTemplatesFromAccount = getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, undefined, true); + const allExportTemplates = [...exportTemplatesFromAccount, ...exportTemplatesFromPolicies]; + + const exportTemplatesByTemplateId = new Map>(); + for (const template of allExportTemplates) { + if (template.templateName && !exportTemplatesByTemplateId.has(template.templateName)) { + exportTemplatesByTemplateId.set(template.templateName, template); + } + } + const deduplicatedExportTemplates = Array.from(exportTemplatesByTemplateId.values()); + + const standardAndIntegrationCustomTemplatePickerItems = []; + + for (const template of deduplicatedExportTemplates) { + if (!template.templateName || integrationConnectionNamesSet.has(template.templateName) || template.type === CONST.EXPORT_TEMPLATE_TYPES.IN_APP) { + continue; + } + + const displayName = template.name ?? template.templateName ?? ''; + const filterValue = STANDARD_EXPORT_TEMPLATE_ID_TO_DISPLAY_LABEL[template.templateName] ?? displayName; + if (usedPickerValueKeys.has(filterValue)) { + continue; + } + + usedPickerValueKeys.add(filterValue); + standardAndIntegrationCustomTemplatePickerItems.push({ + text: displayName, + value: filterValue, + leftElement: tableIconForExportOption, + }); + } + + return [...connectedIntegrationPickerItems, ...standardAndIntegrationCustomTemplatePickerItems]; + })(); + const selectedExportedTo = exportedToPickerOptions.filter((option) => exportedTo?.includes(option.value)); + + const updateExportedToFilterForm = (items: Array>) => { + updateFilterForm({exportedTo: items.map((item) => item.value)}); + }; + + return ( + = CONST.STANDARD_LIST_ITEM_LIMIT} + onChangeCallback={updateExportedToFilterForm} + /> + ); +} + +export default ExportedToSelectPopup; diff --git a/src/components/Search/FilterDropdowns/GroupByPopup.tsx b/src/components/Search/FilterDropdowns/GroupByPopup.tsx index a12e714ad25b..6b26071d2b55 100644 --- a/src/components/Search/FilterDropdowns/GroupByPopup.tsx +++ b/src/components/Search/FilterDropdowns/GroupByPopup.tsx @@ -1,18 +1,15 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; -import Button from '@components/Button'; import type {SearchGroupBy} from '@components/Search/types'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import type {ListItem} from '@components/SelectionList/types'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import type {GroupBySection} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import BasePopup from './BasePopup'; type GroupByPopupItem = { text: string; @@ -39,10 +36,7 @@ type GroupByPopupProps = { }; function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: GroupByPopupProps) { - const {translate} = useLocalize(); const styles = useThemeStyles(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const [selectedItem, setSelectedItem] = useState(value); @@ -86,9 +80,14 @@ function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: G }, [closeOverlay, onChange]); return ( - - {isSmallScreenWidth && !!label && {label}} - + - - -