Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
08f70e2
Add "Edit filters" to saved views
mukhrr Jun 25, 2026
07e6b43
update tests
mukhrr Jun 25, 2026
89882f9
added edit filters mobile
mukhrr Jun 25, 2026
1cd60b7
added missing tests
mukhrr Jun 25, 2026
02a3327
added missing component tests
mukhrr Jun 25, 2026
c1a111c
fixed mobile navigation on back from save as new edit page
mukhrr Jun 26, 2026
9f385d5
Merge remote-tracking branch 'upstream/main' into fix/92770
mukhrr Jun 26, 2026
34417d8
Fix auto-named view rename and stale edit mode on dismiss
mukhrr Jun 26, 2026
359ae83
fixed eslint
mukhrr Jun 26, 2026
0636e13
Merge remote-tracking branch 'upstream' into fix/92770
mukhrr Jun 30, 2026
d508f19
fixed desktop view
mukhrr Jun 30, 2026
ee71c05
cleared comments
mukhrr Jun 30, 2026
756c39e
fixed wrong LHN active state
mukhrr Jun 30, 2026
4b568ea
hide footer buttons when there is no change on search filters
mukhrr Jun 30, 2026
f91ed4f
Merge remote-tracking branch 'upstream/main' into fix/92770
mukhrr Jun 30, 2026
d4408a6
fixeed prettier
mukhrr Jul 1, 2026
18c28b8
Merge remote-tracking branch 'upstream/main' into fix/92770
mukhrr Jul 1, 2026
4c27a70
fixed knip bug
mukhrr Jul 1, 2026
b226049
revert live changes on open filters popup
mukhrr Jul 1, 2026
01d2535
fix review comments: make edit-state keys RAM-only, revert edits on c…
mukhrr Jul 2, 2026
9cac45f
clear edit mode on save as new view so LHN highlight isn't stuck on t…
mukhrr Jul 2, 2026
71a6fcb
open edit filters imperatively like the filter button, drop requestID
mukhrr Jul 3, 2026
dba25ee
Merge remote-tracking branch 'upstream/main' into fix/92770
mukhrr Jul 3, 2026
4dcdc3d
Merge remote-tracking branch 'upstream/main' into fix/92770
mukhrr Jul 6, 2026
ca16a7d
Merge upstream/main into fix/92770
mukhrr Jul 6, 2026
c16358e
fix eslint errors
mukhrr Jul 7, 2026
fa8aacf
replace onOverlayClose reason param with isClosedByBrowserNavigation …
mukhrr Jul 7, 2026
4ce1126
remove ttl from edit filters open request, plain consumable boolean
mukhrr Jul 8, 2026
edeb5c4
Merge remote-tracking branch 'upstream/main' into fix/92770
mukhrr Jul 8, 2026
931c233
replace deprecated icon small/extraSmall props with size
mukhrr Jul 8, 2026
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
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8057,6 +8057,9 @@ const CONST = {
TYPE_MENU_ITEM: 'Search-TypeMenuItem',
SAVED_SEARCH_MENU_ITEM: 'Search-SavedSearchMenuItem',
SAVE_VIEW_BUTTON: 'Search-SaveViewButton',
SAVE_EDITS_BUTTON: 'Search-SaveEditsButton',
SAVE_AS_NEW_VIEW_BUTTON: 'Search-SaveAsNewViewButton',
EDIT_FILTERS_CANCEL_BUTTON: 'Search-EditFiltersCancelButton',
CLEAR_FILTERS_BUTTON: 'Search-ClearFiltersButton',
ACTION_CELL_VIEW: 'Search-ActionCellView',
ACTION_CELL_PAY: 'Search-ActionCellPay',
Expand Down
8 changes: 8 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,12 @@ const ONYXKEYS = {
/** Stores the information about the saved searches */
SAVED_SEARCHES: 'nvp_savedSearches',

/** Holds the saved view currently being edited via the "Edit filters" flow (null when not editing). RAM-only: cleared on reload. */
RAM_ONLY_SEARCH_EDITING_SAVED_VIEW: 'searchEditingSavedView',

/** The edited query the small-screen (mobile) "Save as new view" flow carries to the save page (saves edited filters without changing the active search). RAM-only: cleared on reload. */
RAM_ONLY_SEARCH_SAVE_AS_NEW_VIEW_QUERY: 'searchSaveAsNewViewQuery',

/** Stores the information about the recent searches */
RECENT_SEARCHES: 'nvp_recentSearches',

Expand Down Expand Up @@ -1443,6 +1449,8 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_TRY_NEW_DOT]: OnyxTypes.TryNewDot;
[ONYXKEYS.RECENT_SEARCHES]: Record<string, OnyxTypes.RecentSearchItem>;
[ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch;
[ONYXKEYS.RAM_ONLY_SEARCH_EDITING_SAVED_VIEW]: OnyxTypes.EditingSavedSearch;
[ONYXKEYS.RAM_ONLY_SEARCH_SAVE_AS_NEW_VIEW_QUERY]: string;
[ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext;
[ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[];
[ONYXKEYS.ACTIVE_CLIENTS]: string[];
Expand Down
11 changes: 10 additions & 1 deletion src/components/PopoverMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,16 @@ function BasePopoverMenu({

const menuContainerStyle = useMemo(() => {
if (isSmallScreenWidth) {
return shouldEnableMaxHeight && !isInLandscapeMode ? [{maxHeight: CONST.POPOVER_MENU_MAX_HEIGHT_MOBILE}] : [];
if (!shouldEnableMaxHeight) {
return [];
}
// In landscape the fixed mobile max-height is dropped (it would be taller than the short screen), but that lets a
// tall menu grow past the screen and clip its last item. For menus that scroll, cap to the available height
// instead so they scroll rather than clip.
if (isInLandscapeMode) {
return shouldUseScrollView ? [{maxHeight: windowHeight - variables.compactPopoverMenuVerticalMargin}] : [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding "Edit filters" made the saved-view overflow menu 4 items. On small screens in landscape, PopoverMenu drops the fixed mobile max-height (POPOVER_MENU_MAX_HEIGHT_MOBILE would be taller than the short screen), so the menu grew past the screen and the last item (Delete) was clipped and unreachable (reproduced on device). This caps scrollable menus to the available window height so they scroll instead of clipping. It's gated on shouldUseScrollView, which only the saved-view menu passes, so every other popover menu keeps its current behavior.

}
return [{maxHeight: CONST.POPOVER_MENU_MAX_HEIGHT_MOBILE}];
}

const stylesArray: ViewStyle[] = [StyleSheet.flatten(styles.createMenuContainer), {width: variables.compactPopoverMenuWidth}, styles.pv2];
Expand Down
123 changes: 105 additions & 18 deletions src/components/Search/FilterDropdowns/FilterPopupButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import withViewportOffsetTop from '@components/withViewportOffsetTop';

import useOnyx from '@hooks/useOnyx';
import usePopoverPosition from '@hooks/usePopoverPosition';
import usePopstateListener from '@hooks/usePopstateListener';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';

import subscribeToRootNavigation from '@libs/Navigation/helpers/subscribeToRootNavigation';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
Expand All @@ -17,7 +20,7 @@ import type {StyleProp, ViewStyle} from 'react-native';

import {useIsFocused} from '@react-navigation/core';
import {willAlertModalBecomeVisibleSelector} from '@selectors/Modal';
import React, {useRef, useState} from 'react';
import React, {useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';

type PopoverComponentProps = {
Expand Down Expand Up @@ -47,14 +50,41 @@ type FilterPopupButtonProps = {

/** The component to render as the button */
renderButton: (props: ButtonComponentProps) => ReactNode;

/** Exposes an imperative `open` (same code path as pressing the button), e.g. for the saved-view "Edit filters" flow */
handleRef?: RefObject<FilterPopupButtonHandle | null>;

/** Called whenever the popover closes (click-outside, Cancel, Save or browser back) so the caller can leave any associated edit mode */
onOverlayClose?: (isClosedByBrowserNavigation?: boolean) => void;

/** Called after a browser back/forward while the popover is open; return true to close it (in-app pushes/pops never trigger this) */
shouldCloseOnBrowserNavigation?: () => boolean;
};

type FilterPopupButtonHandle = {
/** Opens the popover, bypassing the transient modal-transition guard (e.g. the LHN 3-dot menu is still closing) */
open: () => void;
};

const ANCHOR_ORIGIN = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
};

function FilterPopupButton({viewportOffsetTop, popoverWidth, wrapperStyle, popoverAnchorAlignment: popoverAnchorAlignmentProp, PopoverComponent, renderButton}: FilterPopupButtonProps) {
// Fallback wait after a popstate in case the navigation state event fired before our listener ran.
const POPSTATE_SETTLE_TIME_MS = 1000;

function FilterPopupButton({
viewportOffsetTop,
popoverWidth,
wrapperStyle,
popoverAnchorAlignment: popoverAnchorAlignmentProp,
PopoverComponent,
renderButton,
handleRef,
onOverlayClose,
shouldCloseOnBrowserNavigation,
}: FilterPopupButtonProps) {
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHP and narrow layout
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
Expand All @@ -77,43 +107,100 @@ function FilterPopupButton({viewportOffsetTop, popoverWidth, wrapperStyle, popov

const popoverAnchorAlignment = popoverAnchorAlignmentProp ?? ANCHOR_ORIGIN;

const toggleOverlay = () => {
setIsOverlayVisible((previousValue) => {
if (!previousValue && willAlertModalBecomeVisible) {
return false;
}

return !previousValue;
});
};

const calculatePopoverPositionAndToggleOverlay = () => {
const openOverlay = (shouldBypassAlertModalCheck = false) => {
if (willAlertModalBecomeVisible && !shouldBypassAlertModalCheck) {
return;
}
calculatePopoverPosition(anchorRef, popoverAnchorAlignment).then((position) => {
setPopoverTriggerPosition({...position, vertical: position.vertical});
toggleOverlay();
setIsOverlayVisible(true);
});
};

const closeOverlay = () => {
setIsOverlayVisible(false);
onOverlayClose?.();
};

const toggleOverlay = () => {
if (isOverlayVisible) {
closeOverlay();
return;
}
openOverlay();
};

// Imperative open — the same code path as pressing the button, bypassing the transient modal-transition guard.
useImperativeHandle(handleRef, () => ({open: () => openOverlay(true)}));

// Close on browser back/forward when the caller confirms the navigation left the popover's context (in-app
// pushes/pops never fire popstate here, so e.g. RHP year pickers keep the popover open).
const shouldCloseOnBrowserNavigationRef = useRef(shouldCloseOnBrowserNavigation);
const onOverlayCloseRef = useRef(onOverlayClose);
const isOverlayVisibleRef = useRef(isOverlayVisible);
useEffect(() => {
shouldCloseOnBrowserNavigationRef.current = shouldCloseOnBrowserNavigation;
onOverlayCloseRef.current = onOverlayClose;
isOverlayVisibleRef.current = isOverlayVisible;
});
// If the component unmounts while the popover is open (e.g. the screen remounts on browser back), end the caller's
// edit session too — otherwise its state would outlive the popover.
useEffect(
() => () => {
if (!isOverlayVisibleRef.current) {
return;
}
onOverlayCloseRef.current?.(true);
},
[],
);
usePopstateListener(isOverlayVisible && !!shouldCloseOnBrowserNavigation, () => {
// Capture the pre-navigation check now (its closure holds the pre-back query), then evaluate it on the next
// navigation state event — or on a fallback timer in case that event already fired before this listener ran.
const didNavigateAway = shouldCloseOnBrowserNavigationRef.current;
if (!didNavigateAway) {
return;
}
let isDone = false;
let unsubscribe: (() => void) | undefined;
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
const evaluate = () => {
if (isDone) {
return;
}
isDone = true;
unsubscribe?.();
clearTimeout(fallbackTimer);
if (!isOverlayVisibleRef.current || !didNavigateAway()) {
return;
}
setIsOverlayVisible(false);
onOverlayCloseRef.current?.(true);
};
unsubscribe = subscribeToRootNavigation(evaluate);
fallbackTimer = setTimeout(evaluate, POPSTATE_SETTLE_TIME_MS);
});

const actualPopoverWidth = customPopoverWidth ?? popoverWidth ?? CONST.POPOVER_DROPDOWN_WIDTH;
const containerStyles = isSmallScreenWidth ? styles.w100 : {width: actualPopoverWidth};

const popoverContent = PopoverComponent({closeOverlay: toggleOverlay, isExpanded: isOverlayVisible, setPopoverWidth: setCustomPopoverWidth});
const popoverContent = PopoverComponent({closeOverlay, isExpanded: isOverlayVisible, setPopoverWidth: setCustomPopoverWidth});

return (
<View
ref={anchorRef}
style={wrapperStyle}
>
{/* Dropdown Trigger */}
{renderButton({ref: triggerRef, onPress: calculatePopoverPositionAndToggleOverlay, isExpanded: isOverlayVisible})}
{renderButton({ref: triggerRef, onPress: toggleOverlay, isExpanded: isOverlayVisible})}

{/* Dropdown overlay */}
{isFocused && (
<PopoverWithMeasuredContent
anchorRef={triggerRef}
avoidKeyboard
isVisible={isOverlayVisible}
onClose={toggleOverlay}
onClose={closeOverlay}
anchorPosition={popoverTriggerPosition}
anchorAlignment={popoverAnchorAlignment}
restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
Expand All @@ -139,5 +226,5 @@ function FilterPopupButton({viewportOffsetTop, popoverWidth, wrapperStyle, popov
);
}

export type {PopoverComponentProps, ButtonComponentProps, FilterPopupButtonProps};
export type {PopoverComponentProps, ButtonComponentProps, FilterPopupButtonProps, FilterPopupButtonHandle};
export default withViewportOffsetTop(FilterPopupButton);
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import Button from '@components/ButtonComposed';
import SafeTriangle from '@components/SafeTriangle';
import FilterList from '@components/Search/FilterComponents/AdvancedFilters/FilterList';
import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent';
import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery';
import type {SearchQueryJSON} from '@components/Search/types';

import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';

import {clearSavedViewEditMode, saveSavedViewEdits} from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
import {canSaveEditedView} from '@libs/SearchUIUtils';
import type {SearchFilter} from '@libs/SearchUIUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {EditingSavedSearch} from '@src/types/onyx';

import React, {useRef, useState} from 'react';
import {View} from 'react-native';
Expand All @@ -25,21 +32,63 @@ import TextInputFilterContentPopupWrapper from './TextInputFilterContentPopupWra

type SearchAdvancedFiltersPopupProps = {
queryJSON: SearchQueryJSON;

/** Set when the popover is opened through the saved-view "Edit filters" flow; renders the edit footer */
editingSavedView?: EditingSavedSearch;

/** Closes the popover (provided by FilterPopupButton) */
closeOverlay?: () => void;

/** Called before a Save-triggered close so the popover isn't reverted like a click-outside */
preventRevertOnClose?: () => void;
};

function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps) {
function SearchAdvancedFiltersPopup({queryJSON, editingSavedView, closeOverlay, preventRevertOnClose}: SearchAdvancedFiltersPopupProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const {windowHeight} = useWindowDimensions();
const [selectedFilter, setSelectedFilter] = useState<SearchFilter['key']>(CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE);
const filterContentRef = useRef<View>(null);
const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);

const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON);

return (
const isEditingSavedView = !!editingSavedView;

// Editing a view applies filter changes to the active search live (the table updates behind the popover); the footer
// only appears once the live query differs from the view (and isn't already another saved view).
const shouldShowEditFooter = isEditingSavedView && canSaveEditedView(savedSearches, queryJSON.hash);

// Cancel just closes; onOverlayClose re-executes the view's original query to restore it (same as clicking outside).
const onCancel = () => {
closeOverlay?.();
};

const onSaveAsNewView = () => {
preventRevertOnClose?.();
clearSavedViewEditMode();
closeOverlay?.();
Navigation.navigate(ROUTES.SEARCH_SAVE);
};

const onSaveEdits = () => {
preventRevertOnClose?.();
if (editingSavedView) {
saveSavedViewEdits({queryJSON, editingSavedView});
}
closeOverlay?.();
};

const popoverHeight = Math.min(windowHeight, CONST.ADVANCED_FILTERS_POPOVER_HEIGHT);

// In edit mode the popover reserves space for the footer, so the master-detail shrinks to share the popover height.
const masterDetailRowStyle = isEditingSavedView ? [styles.flex1, styles.mnh0] : StyleUtils.getHeight(popoverHeight);

const masterDetail = (
<SafeTriangle submenuRef={filterContentRef}>
<View style={[styles.flexRow, StyleUtils.getHeight(Math.min(windowHeight, CONST.ADVANCED_FILTERS_POPOVER_HEIGHT))]}>
<View style={[styles.flexRow, masterDetailRowStyle]}>
<FilterList
style={[styles.typeFiltersPopupContainer]}
type={searchAdvancedFiltersForm?.type}
Expand Down Expand Up @@ -69,6 +118,43 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps
</View>
</SafeTriangle>
);

if (!isEditingSavedView) {
return masterDetail;
}

return (
<View style={[styles.flexColumn, StyleUtils.getHeight(popoverHeight)]}>
<View style={[styles.flex1, styles.mnh0]}>{masterDetail}</View>
{shouldShowEditFooter && (
<View style={[styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, styles.gap2, styles.ph3, styles.pv3, styles.borderTop]}>
<Button
onPress={onCancel}
innerStyles={styles.bgTransparent}
hoverStyles={styles.hoveredComponentBG}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.EDIT_FILTERS_CANCEL_BUTTON}
>
<Button.Text style={styles.textSupporting}>{translate('common.cancel')}</Button.Text>
</Button>
<View style={[styles.flexRow, styles.gap2]}>
<Button
onPress={onSaveAsNewView}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.SAVE_AS_NEW_VIEW_BUTTON}
>
<Button.Text>{translate('search.saveAsNewView')}</Button.Text>
</Button>
<Button
variant="success"
onPress={onSaveEdits}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.SAVE_EDITS_BUTTON}
>
<Button.Text>{translate('search.saveEdits')}</Button.Text>
</Button>
</View>
</View>
)}
</View>
);
}

export default SearchAdvancedFiltersPopup;
Loading
Loading