Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ modules/*/lib/

# Claude local settings
.claude/settings.local.json
.claude/scheduled_tasks.lock

# Playwright
.playwright-output/
Expand Down
2 changes: 1 addition & 1 deletion Mobile-Expensify
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,9 @@ const ONYXKEYS = {
/** Stores the current search page context (e.g., whether to show the search query) */
SEARCH_CONTEXT: 'searchContext',

/** Persists the last-used query (filters + columns) per suggested search, keyed by search key */
SEARCH_SUGGESTED_OVERRIDES: 'searchSuggestedOverrides',

/** Stores recently used currencies */
RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies',

Expand Down Expand Up @@ -1444,6 +1447,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.RECENT_SEARCHES]: Record<string, OnyxTypes.RecentSearchItem>;
[ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch;
[ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext;
[ONYXKEYS.SEARCH_SUGGESTED_OVERRIDES]: Record<string, string>;
[ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[];
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
[ONYXKEYS.DEVICE_ID]: string;
Expand Down
18 changes: 16 additions & 2 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,9 +1016,23 @@ const ROUTES = {
SEARCH_ROUTER: 'search-router',
SEARCH_ROOT: {
route: 'search',
getRoute: ({query, rawQuery, name}: {query: SearchQueryString; rawQuery?: SearchQueryString; name?: string}) => {
getRoute: ({
query,
rawQuery,
name,
searchKey,
savedSearchKey,
}: {
query: SearchQueryString;
rawQuery?: SearchQueryString;
name?: string;
searchKey?: string;
savedSearchKey?: string;
}) => {
const rawQuerySegment = rawQuery ? `&rawQuery=${encodeURIComponent(rawQuery)}` : '';
return `search?q=${encodeURIComponent(query)}${name ? `&name=${name}` : ''}${rawQuerySegment}` as const;
const searchKeySegment = searchKey ? `&searchKey=${searchKey}` : '';
const savedSearchKeySegment = savedSearchKey ? `&savedSearchKey=${savedSearchKey}` : '';
return `search?q=${encodeURIComponent(query)}${name ? `&name=${name}` : ''}${rawQuerySegment}${searchKeySegment}${savedSearchKeySegment}` as const;
},
},
SEARCH_SAVE: 'search/save',
Expand Down
9 changes: 7 additions & 2 deletions src/components/Search/FilterDropdowns/DropdownButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,19 @@ type DropdownButtonProps = WithSentryLabel &
/** Caret wrapper style */
caretWrapperStyle?: StyleProp<ViewStyle>;
onClosePress?: () => void;

/** When true, the close button is shown but disabled (used for a suggested search's mandatory filters) */
isCloseButtonDisabled?: boolean;
};

function DropdownButton({label, value, medium = false, labelStyle, innerStyles, caretWrapperStyle, sentryLabel, onClosePress, ...props}: DropdownButtonProps) {
function DropdownButton({label, value, medium = false, labelStyle, innerStyles, caretWrapperStyle, sentryLabel, onClosePress, isCloseButtonDisabled = false, ...props}: DropdownButtonProps) {
const styles = useThemeStyles();
const theme = useTheme();
const icons = useMemoizedLazyExpensifyIcons(['Close']);

const shouldShowCloseButton = !!onClosePress;
// A suggested search's mandatory filters can't be removed, so we omit the close section entirely and
// render a fully-rounded pill rather than a disabled "x".
const shouldShowCloseButton = !!onClosePress && !isCloseButtonDisabled;

/**
* When no items are selected, render the label, otherwise, render the
Expand Down
2 changes: 2 additions & 0 deletions src/components/Search/SearchContextDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const defaultSearchQueryContext: SearchQueryContextValue = {
currentSearchHash: -1,
currentSimilarSearchHash: -1,
currentSearchKey: undefined,
currentSavedSearchKey: undefined,
currentSavedSearchQuery: undefined,
currentSearchQueryJSON: undefined,
suggestedSearches: {} as Record<SearchKey, SearchTypeMenuItem>,
shouldResetSearchQuery: false,
Expand Down
24 changes: 16 additions & 8 deletions src/components/Search/SearchPageHeader/SearchFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import React from 'react';

import type {FilterItem} from './useSearchFiltersBar';

type DropdownProps = Pick<DropdownButtonProps, 'label' | 'PopoverComponent' | 'sentryLabel' | 'onClosePress'> & {
type DropdownProps = Pick<DropdownButtonProps, 'label' | 'PopoverComponent' | 'sentryLabel' | 'onClosePress' | 'isCloseButtonDisabled'> & {
value: SearchFilter['value'];
};

function UserDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function UserDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const users = useFilterUserValue(value);
return (
<DropdownButton
Expand All @@ -29,11 +29,12 @@ function UserDropdown({label, value, PopoverComponent, sentryLabel, onClosePress
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}

function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const workspaceValue = useFilterWorkspaceValue(value);
return (
<DropdownButton
Expand All @@ -42,11 +43,12 @@ function WorkspaceDropdown({label, value, PopoverComponent, sentryLabel, onClose
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}

function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const feedValue = useFilterFeedValue(value as string[]);
return (
<DropdownButton
Expand All @@ -55,11 +57,12 @@ function FeedDropdown({label, value, PopoverComponent, sentryLabel, onClosePress
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}

function CardDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function CardDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const cardValue = useFilterCardValue(value as string[]);
return (
<DropdownButton
Expand All @@ -68,11 +71,12 @@ function CardDropdown({label, value, PopoverComponent, sentryLabel, onClosePress
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}

function BankAccountDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function BankAccountDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const bankAccountValue = useFilterBankAccountValue(value);
return (
<DropdownButton
Expand All @@ -81,11 +85,12 @@ function BankAccountDropdown({label, value, PopoverComponent, sentryLabel, onClo
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}

function TaxRateDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function TaxRateDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const taxRateValue = useFilterTaxRateValue(value as string[]);
return (
<DropdownButton
Expand All @@ -94,11 +99,12 @@ function TaxRateDropdown({label, value, PopoverComponent, sentryLabel, onClosePr
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}

function ReportDropdown({label, value, PopoverComponent, sentryLabel, onClosePress}: DropdownProps) {
function ReportDropdown({label, value, PopoverComponent, sentryLabel, onClosePress, isCloseButtonDisabled}: DropdownProps) {
const reportValue = useFilterReportValue(value);
return (
<DropdownButton
Expand All @@ -107,6 +113,7 @@ function ReportDropdown({label, value, PopoverComponent, sentryLabel, onClosePre
PopoverComponent={PopoverComponent}
sentryLabel={sentryLabel}
onClosePress={onClosePress}
isCloseButtonDisabled={isCloseButtonDisabled}
/>
);
}
Expand Down Expand Up @@ -138,6 +145,7 @@ function SearchFilterBar({item}: {item: SearchFilter & FilterItem}) {
PopoverComponent={item.PopoverComponent}
sentryLabel={item.sentryLabel}
onClosePress={item.onClosePress}
isCloseButtonDisabled={item.isCloseButtonDisabled}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type SearchFiltersBarNarrowProps = {
function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) {
const styles = useThemeStyles();
const scrollRef = useRef<FlatList<SearchFilter & FilterItem>>(null);
const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON);
const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters, shouldShowClearButton} = useSearchFiltersBar(queryJSON);

const adjustScroll = (info: {distanceFromEnd: number}) => {
// Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504):
Expand Down Expand Up @@ -69,7 +69,7 @@ function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) {
renderItem={renderFilterItem}
onEndReached={adjustScroll}
onEndReachedThreshold={0.75}
ListFooterComponent={filters.length > 0 ? <SearchFiltersClearButton onPress={clearFilters} /> : undefined}
ListFooterComponent={shouldShowClearButton ? <SearchFiltersClearButton onPress={clearFilters} /> : undefined}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type SearchFiltersBarWideProps = {
};

function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) {
const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON);
const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters, shouldShowClearButton} = useSearchFiltersBar(queryJSON);

if (hasErrors) {
return null;
Expand All @@ -41,7 +41,7 @@ function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) {
item={item}
/>
))}
{filters.length > 0 && <SearchFiltersClearButton onPress={clearFilters} />}
{shouldShowClearButton && <SearchFiltersClearButton onPress={clearFilters} />}
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,23 @@ function SearchFiltersClearButton({onPress}: SearchFiltersClearButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']);
const expensifyIcons = useMemoizedLazyExpensifyIcons(['RotateLeft']);
const label = translate('common.reset');

return (
<PressableWithFeedback
accessibilityLabel={translate('common.clear')}
accessibilityLabel={label}
onPress={onPress}
style={[styles.searchFiltersClearButton]}
hoverStyle={styles.hoveredComponentBG}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.CLEAR_FILTERS_BUTTON}
>
<Icon
src={expensifyIcons.Close}
src={expensifyIcons.RotateLeft}
fill={theme.icon}
extraSmall
/>
<Text style={[styles.textMicroBoldSupporting]}>{translate('common.clear')}</Text>
<Text style={[styles.textMicroBoldSupporting]}>{label}</Text>
</PressableWithFeedback>
);
}
Expand Down
52 changes: 48 additions & 4 deletions src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/Fil
import ReportFieldPopup from '@components/Search/FilterDropdowns/ReportFieldPopup';
import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup';
import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery';
import {useSearchResultsContext} from '@components/Search/SearchContext';
import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext';
import type {ReportFieldKey, SearchFilterKey, SearchQueryJSON} from '@components/Search/types';

import {useCurrencyListActions} from '@hooks/useCurrencyList';
Expand All @@ -15,8 +15,17 @@ import useOnyx from '@hooks/useOnyx';

import {close} from '@libs/actions/Modal';
import {setSearchContext} from '@libs/actions/Search';
import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils';
import {FILTER_VIEW_MAP, isAmountFilterKey, isDateFilterKey, isTextFilterKey, mapFiltersFormToLabelValueList, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils';
import Navigation from '@libs/Navigation/Navigation';
import {buildSearchQueryJSON, getAdvancedFiltersToReset} from '@libs/SearchQueryUtils';
import {
FILTER_VIEW_MAP,
getSuggestedSearchMandatoryFilterKeys,
isTextFilterKey,
isAmountFilterKey,
isDateFilterKey,
mapFiltersFormToLabelValueList,
SKIPPED_SEARCH_FILTERS,
} from '@libs/SearchUIUtils';
import type {SearchFilter} from '@libs/SearchUIUtils';

import CONST from '@src/CONST';
Expand All @@ -35,13 +44,19 @@ import DatePickerFilterPopup from './DatePickerFilterPopup';
type FilterItem = WithSentryLabel & {
PopoverComponent: (props: PopoverComponentProps) => ReactNode;
onClosePress: () => void;

/** Mandatory filters of a suggested search can't be removed, otherwise the search loses its identity */
isCloseButtonDisabled?: boolean;
};

type UseSearchFiltersBarResult = {
filters: Array<SearchFilter & FilterItem>;
hasErrors: boolean;
shouldShowFiltersBarLoading: boolean;
clearFilters: () => void;

/** Whether to render the reset button. On a suggested search it only shows once the user deviates from its defaults */
shouldShowClearButton: boolean;
};

type FilterPopupProps = {
Expand Down Expand Up @@ -145,7 +160,14 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes
const {isOffline} = useNetwork();
const {convertToDisplayStringWithoutCurrency} = useCurrencyListActions();
const {shouldShowFiltersBarLoading, currentSearchResults} = useSearchResultsContext();
const {currentSearchKey, suggestedSearches, currentSavedSearchQuery} = useSearchQueryContext();
const {setFilterQueryParams, updateFilterQueryParams} = useUpdateFilterQuery(queryJSON);
// The "defaults" for the active named search — a suggested search's canned query, or a saved search's stored
// query — drive which pills are locked, when the reset button shows, and what reset reverts to.
const suggestedSearch = currentSearchKey ? suggestedSearches[currentSearchKey] : undefined;
const defaultsQueryString = suggestedSearch?.searchQuery ?? currentSavedSearchQuery;
const defaultsQueryJSON = suggestedSearch?.searchQueryJSON ?? (currentSavedSearchQuery ? buildSearchQueryJSON(currentSavedSearchQuery) : undefined);
const mandatoryFilterKeys = getSuggestedSearchMandatoryFilterKeys(defaultsQueryJSON);
const filters = mapFiltersFormToLabelValueList<FilterItem>(
searchAdvancedFiltersForm,
queryJSON.policyID,
Expand All @@ -167,7 +189,12 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes
</ListFilterHeightContextProvider>
),
sentryLabel: getFilterSentryLabel(filterKey),
isCloseButtonDisabled: mandatoryFilterKeys.has(filterKey),
onClosePress: () => {
if (mandatoryFilterKeys.has(filterKey)) {
return;
}

if (isAmountFilterKey(filterKey)) {
const equalToKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`;
const greaterThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`;
Expand Down Expand Up @@ -201,16 +228,33 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes
}),
);

// Default (mandatory) filter pills render first, followed by any custom filters the user added, so pill
// placement stays predictable as filters are added and removed.
const orderedFilters = [...filters.filter((filter) => mandatoryFilterKeys.has(filter.key)), ...filters.filter((filter) => !mandatoryFilterKeys.has(filter.key))];

const clearFilters = () => {
// On a suggested or saved search, resetting restores its defining filters (keeping its mandatory ones)
// rather than clearing everything. setParams preserves the searchKey/savedSearchKey, so identity is kept.
if (defaultsQueryString) {
Navigation.setParams({q: defaultsQueryString, rawQuery: undefined});
setSearchContext(false);
return;
}
setFilterQueryParams(getAdvancedFiltersToReset(searchAdvancedFiltersForm ?? {}));
setSearchContext(false);
};

// On a suggested or saved search, only surface the reset action once the query deviates from its defaults
// (added/changed filters, columns, or sort). Other searches keep the standard "reset when filters exist".
const hasChangesFromDefaults = !!defaultsQueryJSON && queryJSON.hash !== defaultsQueryJSON.hash;
const shouldShowClearButton = defaultsQueryJSON ? hasChangesFromDefaults : orderedFilters.length > 0;

return {
filters,
filters: orderedFilters,
hasErrors: Object.keys(currentSearchResults?.errors ?? {}).length > 0 && !isOffline,
shouldShowFiltersBarLoading,
clearFilters,
shouldShowClearButton,
};
}

Expand Down
Loading
Loading