diff --git a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx index 8749340f6923..b862b7d3e447 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx @@ -54,6 +54,9 @@ type FilterListProps = FilterItemCallbacks & { /** Content at the top of the list, scrolled with its rows. Pass a falsy value when the search hides it, so the list knows nothing is listed. */ headerContent?: ReactNode; + /** Content below the rows, scrolled with them. */ + children?: ReactNode; + /** Called as the cursor travels over the list. Web only - it reports the movement inside a row, which `onHoverIn` only reports entering. */ onPointerMove?: (event: {clientX: number; clientY: number}) => void; @@ -135,6 +138,7 @@ function FilterList({ onSearchValueChange, searchInputRef, headerContent, + children, onHoverIn, onFocus, onPress, @@ -196,6 +200,7 @@ function FilterList({ ))} ))} + {children} ); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 45f62e682641..8db80163bd88 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -13,6 +13,7 @@ import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -38,6 +39,7 @@ function SearchAdvancedFiltersBase() { const {currentDraftFilters, shouldShowResetFilters} = useContext(SearchAdvancedFiltersContext); const {applyFilters, resetFilters} = useContext(SearchAdvancedFiltersActionContext); const isInLandscapeMode = useIsInLandscapeMode(); + const shouldFooterBeInsideList = useShouldFooterBeInsideList(); const StyleUtils = useStyleUtils(); const {isBetaEnabled} = usePermissions(); const canUseNLFilters = isBetaEnabled(CONST.BETAS.NL_FILTERS); @@ -49,6 +51,28 @@ function SearchAdvancedFiltersBase() { const filters = getMatchingFilters(allFilters, currentDraftFilters.type, listedSearchValue, translate); const isDescribeSearchListed = canUseNLFilters && isLabelMatchingSearchValue(translate('search.filters.describeSearch.title'), listedSearchValue); + const footer = ( + + {shouldShowResetFilters && ( + + )} + + + ); + return ( Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(filterKey))} - /> - - {shouldShowResetFilters && ( - - )} - - + > + {shouldFooterBeInsideList && footer} + + {!shouldFooterBeInsideList && footer} ); } diff --git a/tests/ui/SearchAdvancedFiltersBaseTest.tsx b/tests/ui/SearchAdvancedFiltersBaseTest.tsx new file mode 100644 index 000000000000..85a4e74a0550 --- /dev/null +++ b/tests/ui/SearchAdvancedFiltersBaseTest.tsx @@ -0,0 +1,76 @@ +import {render, screen, within} from '@testing-library/react-native'; + +import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; + +import IntlStore from '@src/languages/IntlStore'; +import SearchAdvancedFiltersBase from '@src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase'; + +import type {ReactNode} from 'react'; + +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import {View} from 'react-native'; + +import type * as TestHelper from '../utils/TestHelper'; + +// jest.mock factories can't reference imported bindings, but `mock`-prefixed locals are allowed. +const MockView = View; + +// Stand-in for the filter list that only renders what the page puts inside its scroll area. +jest.mock('@components/Search/FilterComponents/AdvancedFilters/FilterList', () => ({ + __esModule: true, + default: ({children}: {children?: ReactNode}) => {children}, +})); + +jest.mock('@hooks/useShouldFooterBeInsideList', () => jest.fn(() => false)); + +jest.mock('@hooks/useAdvancedSearchFilters', () => jest.fn(() => [])); + +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: () => false}), +})); + +// The default locale context translates every label to an empty string. +jest.mock('@hooks/useLocalize', () => { + const {translateLocal, localeCompare} = jest.requireActual('../utils/TestHelper'); + return () => ({translate: translateLocal, localeCompare}); +}); + +function renderFiltersPage() { + render( + + + , + ); +} + +beforeAll(async () => { + await IntlStore.load('en'); +}); + +describe('SearchAdvancedFiltersBase', () => { + it('pins Apply filters below the list by default', () => { + // Given a layout with room for a fixed footer + jest.mocked(useShouldFooterBeInsideList).mockReturnValue(false); + + // When the Filters page renders + renderFiltersPage(); + + // Then Apply filters is outside the scrolling list + expect(screen.getByText('Apply filters')).toBeOnTheScreen(); + expect(within(screen.getByTestId('filter-list')).queryByText('Apply filters')).not.toBeOnTheScreen(); + }); + + it('scrolls Apply filters with the list in landscape with the keyboard open', () => { + // Given landscape mode with the keyboard open, where a fixed footer would push the search input out of view + jest.mocked(useShouldFooterBeInsideList).mockReturnValue(true); + + // When the Filters page renders + renderFiltersPage(); + + // Then Apply filters is rendered once, inside the scrolling list + expect(screen.getAllByText('Apply filters')).toHaveLength(1); + expect(within(screen.getByTestId('filter-list')).getByText('Apply filters')).toBeOnTheScreen(); + }); +});