From 73ac4b29ad4ae6147a209ec2c4e63c2049233c5a Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 09:17:49 +0100 Subject: [PATCH] feat(collections): filter by category on the phone (#2618) The phone toolbar gets a category chip beside the status and label chips, fed by the same categoryOptions the desktop dropdown uses, and no longer clears a category picked on desktop. Managing labels moves into the label panel so the row keeps four chips at phone width. --- client/scripts/theme-baseline.json | 2 +- .../screens/collections/MCollections.tsx | 176 +++++++++++++----- .../mobile/collections/MCollections.test.tsx | 73 +++++++- 3 files changed, 197 insertions(+), 54 deletions(-) diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 9e39ea653..af357029a 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -182,7 +182,7 @@ "mobile/screens/collections/MCollShareSheet.tsx": 14, "mobile/screens/collections/MCollSheetKit.tsx": 5, "mobile/screens/collections/MCollTripPickerSheet.tsx": 4, - "mobile/screens/collections/MCollections.tsx": 14, + "mobile/screens/collections/MCollections.tsx": 13, "mobile/screens/dashboard/MDashWidgets.tsx": 22, "mobile/screens/dashboard/MDashboard.tsx": 30, "mobile/screens/dashboard/MNewTripSheet.tsx": 12, diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx index c9e0a7edc..41e4c0bda 100644 --- a/client/src/mobile/screens/collections/MCollections.tsx +++ b/client/src/mobile/screens/collections/MCollections.tsx @@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from 'react' import { useSearchParams } from 'react-router' import { Bookmark, Check, CheckCheck, CheckSquare, ChevronDown, Copy, CopyPlus, - FolderInput, Layers, List, Map as MapIcon, Pencil, Plus, Search, Share2, + FolderInput, Layers, List, Map as MapIcon, Pencil, Search, Settings2, Shapes, Share2, Tag, Tags, Trash2, X, } from 'lucide-react' import type { CollectionStatus } from '@trek/shared' @@ -22,7 +22,7 @@ import MCollTripPickerSheet from './MCollTripPickerSheet' import MCollListPickerSheet from './MCollListPickerSheet' import MCollTours from './MCollTours' import MCollInvites from './MCollInvites' -import { STATUS_SPEC } from './collectionsMobileModel' +import { STATUS_SPEC, categoryMeta } from './collectionsMobileModel' import MConfirmSheet from '../settings/MConfirmSheet' import MCollListNav from './MCollListNav' import { useIsSplit } from '../../../hooks/sizeClass' @@ -46,6 +46,30 @@ const CHIP_ON = 'border border-transparent bg-m-act text-m-actfg' const DROP_PANEL = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-m-sheetop shadow-[0_20px_44px_-18px_rgba(0,0,0,.45)]' +/** The filter chips that open a dropdown; at most one is open at a time. */ +type FilterDrop = 'status' | 'category' | 'label' + +/** One row of the status or category dropdown: icon, name, count. */ +function DropRow({ icon, label, count, on, onPick }: { + icon: ReactNode + label: string + count: number + on: boolean + onPick: () => void +}) { + return ( + + ) +} + function EmptyNote({ icon, scene, title }: { icon?: ReactNode; scene?: TrekScene; title: string }) { return (
@@ -73,8 +97,8 @@ export default function MCollections() { // With room for two panes the lists stand in a column beside the places // (MCollListNav) instead of behind the header's dropdown. const split = useIsSplit() - const [statusDrop, setStatusDrop] = useState(false) - const [labelDrop, setLabelDrop] = useState(false) + const [filterDrop, setFilterDrop] = useState(null) + const toggleDrop = (drop: FilterDrop) => setFilterDrop(cur => (cur === drop ? null : drop)) // The bottom-nav "+" hands off via the ?create= contract. const [searchParams, setSearchParams] = useSearchParams() @@ -102,18 +126,17 @@ export default function MCollections() { // Close transient popovers when the active list changes under them. useEffect(() => { setDrop(false) - setStatusDrop(false) - setLabelDrop(false) + setFilterDrop(null) }, [c.activeId]) - // The mobile toolbar has no category or rating filter control, but the shared - // hook still applies whatever was set on desktop — keep both cleared so - // resizing to phone doesn't silently filter the list and map (#1435). - const { categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter } = c + // The mobile toolbar has no rating filter control, but the shared hook still + // applies whatever was set on desktop: keep it cleared so resizing to phone + // doesn't silently filter the list and map (#1435). The category filter has + // its own chip here (#2618), so it carries over. + const { ratingFilter, setRatingFilter } = c useEffect(() => { - if (categoryFilter !== 'all') setCategoryFilter('all') if (ratingFilter !== 'all') setRatingFilter('all') - }, [categoryFilter, setCategoryFilter, ratingFilter, setRatingFilter]) + }, [ratingFilter, setRatingFilter]) const title = c.isAllSaved ? t('collections.allSaved') : (c.activeCollection?.name ?? t('collections.title')) const isRealList = !c.isAllSaved && typeof c.activeId === 'number' @@ -127,6 +150,15 @@ export default function MCollections() { const statusLabel = c.statusFilter === 'all' ? t('collections.status.filterAll') : t(STATUS_SPEC[c.statusFilter].labelKey) + // A category picked on another list (or on desktop) stays set when this list + // has none of it, so the chip stays reachable to clear it again. + const showCategoryChip = c.categoryOptions.length > 0 || c.categoryFilter !== 'all' + const categoryLabel = c.categoryFilter === 'all' + ? t('collections.status.filterAll') + : (c.categoryOptions.find(o => o.id === c.categoryFilter)?.name + ?? c.categories.find(o => o.id === c.categoryFilter)?.name + ?? t('collections.status.filterAll')) + const categoryTotal = c.categoryOptions.reduce((n, o) => n + o.count, 0) const labelLabel = c.labelFilter.length === 0 ? t('collections.status.filterAll') : c.labelFilter.length === 1 @@ -148,7 +180,17 @@ export default function MCollections() { const pickStatusFilter = (f: StatusFilter) => { c.setStatusFilter(f) - setStatusDrop(false) + setFilterDrop(null) + } + + const pickCategoryFilter = (f: number | 'all') => { + c.setCategoryFilter(f) + setFilterDrop(null) + } + + const openLabelManager = () => { + setFilterDrop(null) + c.setShowLabelManager(true) } const toggleLabelFilter = (id: number) => { @@ -280,19 +322,32 @@ export default function MCollections() {
+ {showCategoryChip && ( + + )} {isRealList && ( )} - {canManageLabels && ( - - )} {showSelect && ( )} - {(statusDrop || labelDrop) && ( + {filterDrop && ( + } + label={t('collections.status.filterAll')} + count={c.counts.all} + on={c.statusFilter === 'all'} + onPick={() => pickStatusFilter('all')} + /> {STATUS_ORDER.map(s => { const meta = STATUS_SPEC[s] const Icon = meta.icon return ( - + icon={} + label={t(meta.labelKey)} + count={c.counts[s]} + on={c.statusFilter === s} + onPick={() => pickStatusFilter(s)} + /> ) })}
)} - {labelDrop && ( -
+ {filterDrop === 'category' && ( +
+ } + label={t('collections.status.filterAll')} + count={categoryTotal} + on={c.categoryFilter === 'all'} + onPick={() => pickCategoryFilter('all')} + /> + {c.categoryOptions.map(o => { + const meta = categoryMeta(o) + const Icon = meta?.icon ?? Shapes + return ( + } + label={o.name} + count={o.count} + on={c.categoryFilter === o.id} + onPick={() => pickCategoryFilter(o.id)} + /> + ) + })} +
+ )} + {filterDrop === 'label' && ( +
+ )}
)}
diff --git a/client/tests/unit/mobile/collections/MCollections.test.tsx b/client/tests/unit/mobile/collections/MCollections.test.tsx index b84245062..1a1b77b80 100644 --- a/client/tests/unit/mobile/collections/MCollections.test.tsx +++ b/client/tests/unit/mobile/collections/MCollections.test.tsx @@ -1,4 +1,4 @@ -// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-034 +// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-038 import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render' @@ -169,6 +169,7 @@ function makeHook(over: Record = {}): Record { view: 'list', statusFilter: 'all', categoryFilter: 'all', + categoryOptions: [], ratingFilter: 'all', search: '', selectedPlaceId: null, @@ -366,7 +367,7 @@ describe('MCollections', () => { expect(fn('setLabelFilter')).toHaveBeenCalledWith([2, 1]) }) - it('FE-MOB-COLSCR-013: a list without labels says so, and the manage chip opens the manager', () => { + it('FE-MOB-COLSCR-013: a list without labels says so, and the label panel opens the manager', () => { render() // Status chip and label chip both read "all" while nothing is filtered. @@ -375,8 +376,10 @@ describe('MCollections', () => { fireEvent.click(chips[1]) expect(screen.getByText('collections.labels.empty')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ })) + fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' })) expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true) + // Opening the manager closes the panel. + expect(screen.queryByText('collections.labels.empty')).not.toBeInTheDocument() }) it('FE-MOB-COLSCR-014: the backdrop under an open dropdown closes it again', () => { @@ -653,12 +656,12 @@ describe('MCollections', () => { expect(fn('setShowAddPlace')).not.toHaveBeenCalled() }) - it('FE-MOB-COLSCR-029: desktop-only category and rating filters are cleared on phone (#1435)', async () => { + it('FE-MOB-COLSCR-029: the desktop-only rating filter is cleared on phone, the category filter is kept (#1435, #2618)', async () => { mocks.coll = makeHook({ categoryFilter: 3, ratingFilter: 4 }) render() - await waitFor(() => expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all')) - expect(fn('setRatingFilter')).toHaveBeenCalledWith('all') + await waitFor(() => expect(fn('setRatingFilter')).toHaveBeenCalledWith('all')) + expect(fn('setCategoryFilter')).not.toHaveBeenCalled() }) it('FE-MOB-COLSCR-030: the add sheet has no fixed target while the union is active', () => { @@ -671,6 +674,64 @@ describe('MCollections', () => { }) }) +// #2618: the phone could filter by status and label, but not by category. +describe('MCollections: category filter (#2618)', () => { + const categoryOptions = [ + { id: 3, name: 'Restaurant', color: '#E8843D', icon: 'utensils', count: 2 }, + { id: 5, name: 'Museum', color: null, icon: null, count: 1 }, + ] + + it('FE-MOB-COLSCR-035: the chip lists the categories present with their counts and picks one', () => { + mocks.coll = makeHook({ categoryOptions }) + render() + + const chip = screen.getByRole('button', { name: 'categories.title: collections.status.filterAll' }) + expect(chip).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(chip) + expect(chip).toHaveAttribute('aria-expanded', 'true') + // The "all" row counts every categorised place. + expect(screen.getByRole('button', { name: /^collections.status.filterAll\s*3$/ })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /^Restaurant\s*2$/ })) + expect(fn('setCategoryFilter')).toHaveBeenCalledWith(3) + // Picking closes the panel. + expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-036: an active category names itself and the "all" row clears it', () => { + mocks.coll = makeHook({ categoryOptions, categoryFilter: 5 }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'categories.title: Museum' })) + fireEvent.click(screen.getByRole('button', { name: /^collections.status.filterAll\s*3$/ })) + expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all') + }) + + it('FE-MOB-COLSCR-037: without categories the chip stays away, unless a category is still set from another list', () => { + const { unmount } = render() + expect(screen.queryByRole('button', { name: /^categories.title/ })).not.toBeInTheDocument() + unmount() + + mocks.coll = makeHook({ categoryFilter: 7, categories: [{ id: 7, name: 'Beach' }] }) + render() + fireEvent.click(screen.getByRole('button', { name: 'categories.title: Beach' })) + fireEvent.click(screen.getByRole('button', { name: /^collections.status.filterAll\s*0$/ })) + expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all') + }) + + it('FE-MOB-COLSCR-038: opening one filter panel closes the other', () => { + mocks.coll = makeHook({ categoryOptions }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'categories.title: collections.status.filterAll' })) + expect(screen.getByRole('button', { name: /^Museum\s*1$/ })).toBeInTheDocument() + // The status chip is the first chip reading "all". + fireEvent.click(screen.getAllByRole('button', { name: 'collections.status.filterAll' })[0]) + expect(screen.queryByRole('button', { name: /^Museum\s*1$/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /collections.status.idea/ })).toBeInTheDocument() + }) +}) + // #2645: the phone had no way to answer a list invite, and the notification's // link landed on "Start your first collection". describe('MCollections: invites (#2645)', () => {