diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx index fb9fe9252..19da6cdc4 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -23,6 +23,7 @@ import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet' import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet' import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel' import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls' +import { countActivePlacesFilters } from '../../../../utils/placesFilter' /** * Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter @@ -142,8 +143,10 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) shell.openSheet('bract', { placeId: place.id, dayPicker: false }) } - // The panel holds the categories and the rating floor; the badge counts both. - const panelFilterCount = categoryFilters.size + (ratingFilter === 'all' ? 0 : 1) + // The panel holds the categories and the rating floor; its badge counts them the + // way the map's Filters badge does (one per kind of filter). The pool is left out: + // its chips sit in plain sight above the panel. + const panelFilterCount = countActivePlacesFilters({ filter: 'all', categoryFilters, ratingFilter }) return (
@@ -196,7 +199,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) type="button" onClick={() => setCatOpen(v => !v)} aria-expanded={catOpen} - aria-label={t('places.allCategories')} + aria-label={t('places.filters')} className="relative flex w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted" > @@ -289,7 +292,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {catOpen && (
- + setSelectedIds(new Set())} />
diff --git a/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx b/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx index e9835b2f4..320e4dc78 100644 --- a/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesFilterControls.tsx @@ -86,15 +86,19 @@ export function MCategoryFilterList({ categories, places }: { categories: Catego ) } -/** A minimum of stars, the same floors as the desktop list and the collections bar (#1435). */ -export function MRatingFloorChips() { +/** + * A minimum of stars, the same floors as the desktop list and the collections bar + * (#1435). `onPick` runs after a floor is chosen: the places browser drops its + * selection then, as it does for a new pool. + */ +export function MRatingFloorChips({ onPick }: { onPick?: () => void } = {}) { const { t } = useTranslation() const ratingFilter = useTripStore(s => s.placesRatingFilter) const setRatingFilter = useTripStore(s => s.setPlacesRatingFilter) return (
{RATING_FLOORS.map(floor => ( - setRatingFilter(floor)}> + { setRatingFilter(floor); onPick?.() }}> {floor === 'all' ? t('common.all') : ( <> diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx index 55ff3ec67..d13024ada 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -10,7 +10,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types' -// FE-MOB-PBROW-001 to FE-MOB-PBROW-034 +// FE-MOB-PBROW-001 to FE-MOB-PBROW-036 const CATEGORIES = [ { id: 1, name: 'Sights', color: '#123456', icon: 'landmark' }, @@ -152,7 +152,7 @@ describe('MPlacesBrowser', () => { it('FE-MOB-PBROW-010: the category panel toggles the shared filter set and badges its size', () => { renderBrowser() - const catBtn = screen.getByRole('button', { name: 'places.allCategories' }) + const catBtn = screen.getByRole('button', { name: 'places.filters' }) expect(catBtn).toHaveAttribute('aria-expanded', 'false') fireEvent.click(catBtn) expect(catBtn).toHaveAttribute('aria-expanded', 'true') @@ -173,7 +173,7 @@ describe('MPlacesBrowser', () => { places: [{ ...LOUVRE, rating_avg: 4.5 }, { ...EIFFEL, rating_avg: 3 }, SEINE], } as Partial) renderBrowser(planner) - const panelBtn = screen.getByRole('button', { name: 'places.allCategories' }) + const panelBtn = screen.getByRole('button', { name: 'places.filters' }) fireEvent.click(panelBtn) const floors = screen.getByRole('group', { name: 'Filter by rating' }) fireEvent.click(within(floors).getByRole('button', { name: '4+' })) @@ -189,6 +189,31 @@ describe('MPlacesBrowser', () => { expect(screen.getByText('Seine Track')).toBeInTheDocument() }) + it('FE-MOB-PBROW-035: the panel badge counts kinds of filter, like the map badge', () => { + renderBrowser() + const panelBtn = screen.getByRole('button', { name: 'places.filters' }) + fireEvent.click(panelBtn) + fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Food' })) + // Two categories are one kind of filter. + expect(panelBtn).toHaveTextContent('1') + fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '4+' })) + expect(panelBtn).toHaveTextContent('2') + // The pool has its own chips in plain sight, so the panel badge leaves it out. + fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' })) + expect(panelBtn).toHaveTextContent('2') + }) + + it('FE-MOB-PBROW-036: a new rating floor starts a fresh selection, as a new pool does', () => { + renderBrowser() + fireEvent.click(screen.getByRole('button', { name: 'common.select' })) + fireEvent.click(row('Louvre')) + expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'places.filters' })) + fireEvent.click(within(screen.getByRole('group', { name: 'Filter by rating' })).getByRole('button', { name: '1+' })) + expect(screen.queryByText('places.selectionCount:1')).not.toBeInTheDocument() + }) + it('FE-MOB-PBROW-033: a floor set from the map sheet already thins the list on arrival', () => { seedStore(useTripStore, { placesRatingFilter: 5 }) renderBrowser(makePlanner({ places: [{ ...LOUVRE, rating_avg: 5 }, EIFFEL] } as Partial)) @@ -198,7 +223,7 @@ describe('MPlacesBrowser', () => { it('FE-MOB-PBROW-011: the uncategorized row filters the places without a category', () => { renderBrowser() - fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' })) + fireEvent.click(screen.getByRole('button', { name: 'places.filters' })) fireEvent.click(screen.getByRole('checkbox', { name: 'No Category' })) expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized']) expect(screen.getByText('Eiffel Tower')).toBeInTheDocument() @@ -208,7 +233,7 @@ describe('MPlacesBrowser', () => { it('FE-MOB-PBROW-012: the uncategorized row is dropped when every place has a category', () => { const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial) renderBrowser(planner) - fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' })) + fireEvent.click(screen.getByRole('button', { name: 'places.filters' })) expect(screen.queryByRole('checkbox', { name: 'No Category' })).not.toBeInTheDocument() expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument() }) @@ -433,7 +458,7 @@ describe('MPlacesBrowser', () => { // Narrow to the two categorised places: two visible, two selected — but not // the same two, so the toolbar must still offer select-all. - fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' })) + fireEvent.click(screen.getByRole('button', { name: 'places.filters' })) fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' })) fireEvent.click(screen.getByRole('checkbox', { name: 'Food' }))