mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
merge: update Tours branch with current Places filters
This commit is contained in:
@@ -6,7 +6,7 @@ import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
* One round on/off control in the frosted shell every map control wears, so a new
|
||||
* one lines up with the compass, the layer switcher and the overview toggle.
|
||||
*/
|
||||
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left' }: {
|
||||
export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPlacement = 'left', badge = 0, opensDialog = false }: {
|
||||
active: boolean
|
||||
onToggle: () => void
|
||||
label: string
|
||||
@@ -14,9 +14,18 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
|
||||
testId?: string
|
||||
/** Away from the edge the control hugs: left on the right edge, right on the left one. */
|
||||
tooltipPlacement?: 'left' | 'right'
|
||||
/** A count pinned to the top right corner while above zero (how many filters are on, say). */
|
||||
badge?: number
|
||||
/**
|
||||
* The control opens a dialog instead of switching something on and off (the phone
|
||||
* map's places filter): it then announces the dialog, not a pressed state it
|
||||
* does not have. `active` still lights it.
|
||||
*/
|
||||
opensDialog?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
@@ -30,7 +39,8 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
aria-pressed={opensDialog ? undefined : active}
|
||||
aria-haspopup={opensDialog ? 'dialog' : undefined}
|
||||
data-testid={testId}
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
@@ -45,6 +55,14 @@ export function MapTogglePill({ active, onToggle, label, icon, testId, tooltipPl
|
||||
{icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -right-[3px] -top-[3px] box-border flex h-4 min-w-[16px] items-center justify-center rounded-full bg-accent px-1 font-geist text-[0.5625rem] font-bold tabular-nums text-accent-text"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { tintOf } from './planParts'
|
||||
import { CategoryTile } from './PlacesBulkCategoryModal'
|
||||
import type { PlacesFilter, SidebarState } from './usePlacesSidebar'
|
||||
import { PLACES_SORTS, type PlacesSort } from './placesSort'
|
||||
import { RATING_FLOORS, UNCATEGORIZED } from '../../utils/placesFilter'
|
||||
|
||||
/**
|
||||
* Below this the two labels stop fitting side by side and both buttons fall back
|
||||
@@ -48,8 +49,6 @@ function BandButton({ label, onClick, on = false, lit = false, ariaPressed, aria
|
||||
)
|
||||
}
|
||||
|
||||
const RATING_FLOORS = ['all', 5, 4, 3, 2, 1] as const
|
||||
|
||||
export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
return (
|
||||
<div
|
||||
@@ -260,7 +259,7 @@ function CategoryFilter(S: SidebarState) {
|
||||
let label = t('places.allCategories')
|
||||
if (categoryFilters.size === 1) {
|
||||
const only = categories.find(c => categoryFilters.has(String(c.id)))
|
||||
if (categoryFilters.has('uncategorized')) label = t('places.noCategory')
|
||||
if (categoryFilters.has(UNCATEGORIZED)) label = t('places.noCategory')
|
||||
else if (only) label = only.name
|
||||
} else if (categoryFilters.size > 1) {
|
||||
label = `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
@@ -279,8 +278,8 @@ function CategoryFilter(S: SidebarState) {
|
||||
))}
|
||||
{places.some(p => p.category_id == null) && (
|
||||
<CategoryChoice
|
||||
on={categoryFilters.has('uncategorized')}
|
||||
onClick={() => toggleCategoryFilter('uncategorized')}
|
||||
on={categoryFilters.has(UNCATEGORIZED)}
|
||||
onClick={() => toggleCategoryFilter(UNCATEGORIZED)}
|
||||
tile={<CategoryTile size={22} />}
|
||||
label={t('places.noCategory')}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-059
|
||||
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-060
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
|
||||
@@ -188,13 +188,46 @@ describe('usePlacesSidebar filtering', () => {
|
||||
expect(names()).toEqual(['GPX Track']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-008: a tracks filter without any track falls back to "all"', async () => {
|
||||
seedStore(useTripStore, { placesFilter: 'tracks' });
|
||||
render(<Host {...makeProps({ places: [buildPlace({ name: 'Plain Spot' })] })} />);
|
||||
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'));
|
||||
expect(names()).toEqual(['Plain Spot']);
|
||||
it('Tours addon excludes Tour-backed Places from rows, counts, and localities', () => {
|
||||
const legacyTrack = buildPlace({ id: 41, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]', rating_avg: 4, address: 'Vienna, Austria' });
|
||||
const ordinary = buildPlace({ id: 42, name: 'Ordinary place', address: 'Linz, Austria' });
|
||||
const tour = buildPlace({ id: 43, name: 'Tour place', tour_place_id: 43, route_geometry: '[[5,6],[7,8]]', address: 'Denver, United States' });
|
||||
render(<Host {...makeProps({
|
||||
places: [legacyTrack, ordinary, tour],
|
||||
toursEnabled: true,
|
||||
excludePlaceIds: new Set([tour.id]),
|
||||
})} />);
|
||||
|
||||
expect(S.hasTracks).toBe(false);
|
||||
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Ordinary place']));
|
||||
expect(names()).not.toContain('Tour place');
|
||||
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 1 });
|
||||
expect(S.localities).toEqual([{ country: 'Austria', count: 2, regions: [] }]);
|
||||
|
||||
act(() => { S.setRatingFilter(4); });
|
||||
expect(names()).toEqual(['Legacy track']);
|
||||
expect(S.filterCounts.all).toBe(2);
|
||||
});
|
||||
|
||||
it('Tours addon OFF preserves legacy track Places and ignores the exclusion set', () => {
|
||||
const legacyTrack = buildPlace({ id: 51, name: 'Legacy track', route_geometry: '[[1,2],[3,4]]' });
|
||||
const dormantTour = buildPlace({ id: 52, name: 'Dormant Tour', tour_place_id: 52, route_geometry: '[[5,6],[7,8]]' });
|
||||
render(<Host {...makeProps({
|
||||
places: [legacyTrack, dormantTour],
|
||||
toursEnabled: false,
|
||||
excludePlaceIds: new Set([dormantTour.id]),
|
||||
})} />);
|
||||
|
||||
expect(S.hasTracks).toBe(true);
|
||||
expect(S.filterCounts).toEqual({ all: 2, unplanned: 2, planned: 0, tracks: 2 });
|
||||
act(() => { S.setFilter('tracks'); });
|
||||
expect(names()).toEqual(expect.arrayContaining(['Legacy track', 'Dormant Tour']));
|
||||
});
|
||||
|
||||
// FE-PLANNER-PSHOOK-008 (a tracks filter without any track falls back to "all")
|
||||
// moved to useTripPlanner (FE-TP-HOOK-177): the fallback now lives there, so it
|
||||
// also holds while no places list is mounted.
|
||||
|
||||
it('FE-PLANNER-PSHOOK-009: a category filter keeps only that category', () => {
|
||||
const tagged = buildPlace({ name: 'Tagged', category_id: 4 });
|
||||
const other = buildPlace({ name: 'Other', category_id: 5 });
|
||||
@@ -245,6 +278,17 @@ describe('usePlacesSidebar filtering', () => {
|
||||
expect(names()).toEqual(['Unrated', 'Rated']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-060: the star floor lives in the trip store, so the map and the phone list follow it', () => {
|
||||
const places = [buildPlace({ name: 'Good', rating_avg: 4 }), buildPlace({ name: 'Meh', rating_avg: 2 })];
|
||||
render(<Host {...makeProps({ places })} />);
|
||||
act(() => { S.setRatingFilter(3); });
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe(3);
|
||||
// And back: a floor picked elsewhere (the phone's filter sheet) narrows this list too.
|
||||
act(() => { useTripStore.getState().setPlacesRatingFilter('all'); });
|
||||
expect(S.ratingFilter).toBe('all');
|
||||
expect(names()).toEqual(['Good', 'Meh']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
|
||||
const places = [
|
||||
buildPlace({ name: 'Cafe Nord', category_id: 4 }),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { placeMatchesSearch } from '../../utils/placeSearch'
|
||||
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import type { MenuEntry } from './planParts'
|
||||
@@ -193,19 +194,20 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
|
||||
// Minimum average stars, matching the collections filter (#1435): 'all', or a
|
||||
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
|
||||
// which put the best first but still left everything else on the list — no
|
||||
// help at all when the point is to see only what the group actually rated.
|
||||
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
|
||||
// In the trip store with the other filters, so the map markers follow it too.
|
||||
const ratingFilter = useTripStore((s) => s.placesRatingFilter)
|
||||
const setRatingFilter = useTripStore((s) => s.setPlacesRatingFilter)
|
||||
// The list's order (#2093), remembered on this device.
|
||||
const [placesSort, setPlacesSortState] = useState<PlacesSort>(readPlacesSort)
|
||||
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
|
||||
// Country, or country and region, from each place's resolved position (#2537). List-only,
|
||||
// like the rating floor.
|
||||
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
|
||||
const localityOf = useMemo(() => new Map(places.map(p => [p.id, placeLocality(p, language)])), [places, language])
|
||||
const localityOf = useMemo(() => new Map(poolPlaces.map(p => [p.id, placeLocality(p, language)])), [poolPlaces, language])
|
||||
const localities = useMemo(() => localityGroups(poolPlaces.map(p => localityOf.get(p.id)!)), [poolPlaces, localityOf])
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
@@ -272,7 +274,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
|
||||
[poolPlaces, props.toursEnabled],
|
||||
)
|
||||
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
||||
|
||||
const plannedIds = useMemo(
|
||||
() => plannedPlaceIds({ assignments, accommodations, reservations }),
|
||||
@@ -301,16 +302,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const list = poolPlaces.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'planned' && !plannedFilterIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (!matchesPlacesFilter(p, { filter, categoryFilters, ratingFilter }, { plannedIds, plannedFilterIds })) return false
|
||||
if (!placeMatchesSearch(p, search)) return false
|
||||
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
||||
if (localityFilter && !matchesLocality(localityOf.get(p.id), localityFilter)) return false
|
||||
return true
|
||||
})
|
||||
@@ -325,14 +318,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
* different questions.
|
||||
*/
|
||||
const filterCounts = useMemo(() => {
|
||||
const base = poolPlaces.filter(p => {
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
return placeMatchesSearch(p, search)
|
||||
})
|
||||
const base = poolPlaces.filter(p => matchesCategoryFilter(p, categoryFilters) && placeMatchesSearch(p, search))
|
||||
return {
|
||||
all: base.length,
|
||||
unplanned: base.filter(p => !plannedIds.has(p.id)).length,
|
||||
|
||||
Reference in New Issue
Block a user