mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Tours: filter a tour list by source, routing limit and type where it varies (PR1-13)
This commit is contained in:
@@ -717,12 +717,17 @@
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
"desktop": [
|
||||
"src/components/Tours/TourDetailDialog.tsx",
|
||||
"src/components/Tours/ToursSidebar.tsx",
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx",
|
||||
"src/components/Tours/tourParts.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list facets",
|
||||
"hooks": ["src/components/Tours/useTourListFilters.ts"],
|
||||
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking detail view: reservationFiles",
|
||||
"hooks": ["src/utils/reservationFiles.ts"],
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { InfoDot } from './tourParts';
|
||||
import type { TourFilter } from './tourPresentation';
|
||||
import type { TourListFilters } from './useTourListFilters';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** How each origin is named in a list, the words the source badge uses. */
|
||||
export const ORIGIN_LABEL_KEY = {
|
||||
trek: 'tours.badge.planned',
|
||||
gpx: 'tours.badge.gpx',
|
||||
wanderer: 'tours.badge.wanderer',
|
||||
} as const;
|
||||
|
||||
export function statusLabel(status: TourFilter, t: Translate): string {
|
||||
if (status === 'unplanned') return t('places.unplanned');
|
||||
if (status === 'planned') return t('places.planned');
|
||||
return t('places.all');
|
||||
}
|
||||
|
||||
function MenuRow({
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{count}
|
||||
</span>
|
||||
<span className="grid w-3.5 flex-none place-items-center">
|
||||
{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="flex flex-col gap-px border-t border-edge-faint pt-1.5 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<div className="flex items-center gap-1 px-2 pb-0.5">
|
||||
<span className="font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <InfoDot label={label} description={hint} />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter of a tour list (PR1-13): one button that says what is set, and a
|
||||
* menu with the day status and, where the list varies in them, the source,
|
||||
* the stored routing limit and the tour type, each with how many tours it
|
||||
* keeps. Choosing a set value again clears it.
|
||||
*/
|
||||
export default function TourListFilterMenu({ filters }: { filters: TourListFilters }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { facets, selection, statusCounts } = filters;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative min-w-0">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
aria-label={t('places.filterShow')}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<span className="min-w-0 truncate text-start">{statusLabel(filters.status, t)}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>
|
||||
{filters.filtered.length}
|
||||
</span>
|
||||
{filters.activeFacets > 0 && (
|
||||
<span
|
||||
className="rounded-full bg-accent px-1.5 font-geist font-semibold leading-[16px] text-accent-text"
|
||||
style={fs(10)}
|
||||
>
|
||||
+{filters.activeFacets}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
strokeWidth={2.2}
|
||||
className={`ms-auto flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
|
||||
<MenuSection label={t('tours.filter.status')}>
|
||||
{(['all', 'unplanned', 'planned'] as const).map((status) => (
|
||||
<MenuRow
|
||||
key={status}
|
||||
active={filters.status === status}
|
||||
label={statusLabel(status, t)}
|
||||
count={statusCounts[status]}
|
||||
onClick={() => filters.setStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
{facets.origin.length > 0 && (
|
||||
<MenuSection label={t('tours.filter.source')}>
|
||||
{facets.origin.map((option) => (
|
||||
<MenuRow
|
||||
key={option.value}
|
||||
active={selection.origin === option.value}
|
||||
label={t(ORIGIN_LABEL_KEY[option.value])}
|
||||
count={option.count}
|
||||
onClick={() => filters.select('origin', option.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
)}
|
||||
{facets.grade.length > 0 && (
|
||||
<MenuSection label={t('tours.filter.grade')} hint={t('tours.filter.gradeHint')}>
|
||||
{facets.grade.map((option) => (
|
||||
<MenuRow
|
||||
key={option.value}
|
||||
active={selection.grade === option.value}
|
||||
label={`T${option.value}`}
|
||||
count={option.count}
|
||||
onClick={() => filters.select('grade', option.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
)}
|
||||
{facets.type.length > 0 && (
|
||||
<MenuSection label={t('tours.filter.type')}>
|
||||
{facets.type.map((option) => (
|
||||
<MenuRow
|
||||
key={option.value}
|
||||
active={selection.type === option.value}
|
||||
label={t(`tourTypes.${option.value}`)}
|
||||
count={option.count}
|
||||
onClick={() => filters.select('type', option.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, EyeOff, FileDown } from 'lucide-react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { EyeOff, FileDown } from 'lucide-react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import type { Day } from '../../types'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -9,7 +9,8 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import { filterTours, type TourFilter } from './tourPresentation'
|
||||
import TourListFilterMenu from './TourListFilterMenu'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import { useTourGpxImport } from './useTourGpxImport'
|
||||
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
|
||||
import { useTourMapVisibility } from './useTourMapVisibility'
|
||||
@@ -44,10 +45,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
const { t } = useTranslation()
|
||||
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
|
||||
const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
|
||||
const [filter, setFilter] = useState<TourFilter>('all')
|
||||
const filterRef = useRef<HTMLDivElement>(null)
|
||||
const filterButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const filters = useTourListFilters(tours)
|
||||
|
||||
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
|
||||
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
|
||||
@@ -66,35 +64,6 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
row?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [selectedPlaceId])
|
||||
|
||||
const counts = {
|
||||
all: tours.length,
|
||||
unplanned: tours.filter(tr => !tr.planned).length,
|
||||
planned: tours.filter(tr => tr.planned).length,
|
||||
}
|
||||
const filtered = filterTours(tours, filter)
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterOpen) return
|
||||
const onDown = (event: MouseEvent) => {
|
||||
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
|
||||
}
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.stopPropagation()
|
||||
setFilterOpen(false)
|
||||
filterButtonRef.current?.focus()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [filterOpen])
|
||||
|
||||
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
|
||||
const filterCount = counts[filter]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
|
||||
<div className="flex flex-none flex-col gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: NEUTRAL_TINT }}>
|
||||
@@ -110,31 +79,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div ref={filterRef} className="relative min-w-0">
|
||||
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
|
||||
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
|
||||
style={fs(12, 'body')}>
|
||||
<span className="min-w-0 truncate text-start">{filterLabel}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
|
||||
<ChevronDown size={13} strokeWidth={2.2} className={`ms-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{filterOpen && (
|
||||
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
|
||||
{(['all', 'unplanned', 'planned'] as const).map(id => {
|
||||
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
|
||||
const active = filter === id
|
||||
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
|
||||
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
|
||||
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TourListFilterMenu filters={filters} />
|
||||
{visibility.hiddenCount > 0 && (
|
||||
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
@@ -150,12 +95,21 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
</div>
|
||||
|
||||
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
|
||||
{!loading && filtered.length === 0 ? (
|
||||
{!loading && filters.emptyByFilter ? (
|
||||
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
) : !loading && filters.filtered.length === 0 ? (
|
||||
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
|
||||
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
|
||||
) : (
|
||||
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
|
||||
{filtered.map(tour => (
|
||||
{filters.filtered.map(tour => (
|
||||
<TourListRow
|
||||
key={tour.place_id}
|
||||
tour={tour}
|
||||
|
||||
@@ -46,6 +46,14 @@ import type { TourPlannerController } from './planner/useTourPlanner'
|
||||
import { TourPlannerRail, TourPlannerToursRail } from './planner/TourPlannerPanels'
|
||||
import TourDetailDialog from './TourDetailDialog'
|
||||
import MToursSelectionList from '../../mobile/screens/trip/places/MToursSelectionList'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
|
||||
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
|
||||
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
|
||||
const filters = useTourListFilters(props.planner.tours)
|
||||
return <MToursSelectionList {...props} filters={filters} />
|
||||
}
|
||||
|
||||
const tour = { place_id: 42, name: 'Ridge', tour_type: 'hike', max_hiking_difficulty: 2, planned: false } as TourListItem
|
||||
const days = [{ id: 3, title: 'Summit' }] as never
|
||||
@@ -133,7 +141,7 @@ describe('Tours permissions without database setup', () => {
|
||||
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
|
||||
const view = vi.fn()
|
||||
const openSheet = vi.fn()
|
||||
rerender(<MToursSelectionList planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} filter="all" canEdit={canEdit} canAssign={canAssign} />)
|
||||
rerender(<List planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
|
||||
fireEvent.click(screen.getByRole('option'))
|
||||
expect(view).toHaveBeenCalledWith(42)
|
||||
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { TourListItem, TourOrigin, TourTypeKey } from '@trek/shared';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { filterTours, tourSource, type TourFilter } from './tourPresentation';
|
||||
|
||||
export type TourFacet = 'origin' | 'grade' | 'type';
|
||||
|
||||
export interface TourFacetOption<V> {
|
||||
value: V;
|
||||
/** How many tours of the list carry it, under the other filters as they are set. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TourFacetSelection {
|
||||
origin: TourOrigin | null;
|
||||
grade: number | null;
|
||||
type: TourTypeKey | null;
|
||||
}
|
||||
|
||||
const NO_SELECTION: TourFacetSelection = { origin: null, grade: null, type: null };
|
||||
|
||||
const ORIGIN_ORDER: TourOrigin[] = ['trek', 'gpx', 'wanderer'];
|
||||
|
||||
function facetValue(tour: TourListItem, facet: TourFacet): string | number | null {
|
||||
if (facet === 'origin') return tourSource(tour);
|
||||
if (facet === 'grade') return tour.max_hiking_difficulty;
|
||||
return tour.tour_type;
|
||||
}
|
||||
|
||||
function matches(tour: TourListItem, selection: TourFacetSelection, except?: TourFacet): boolean {
|
||||
return (['origin', 'grade', 'type'] as const).every(
|
||||
(facet) => facet === except || selection[facet] === null || facetValue(tour, facet) === selection[facet]
|
||||
);
|
||||
}
|
||||
|
||||
/** The values a facet takes in the list, each with its count; a value no tour carries is not offered. */
|
||||
function optionsOf<V extends string | number>(
|
||||
tours: readonly TourListItem[],
|
||||
facet: TourFacet,
|
||||
order: (a: V, b: V) => number
|
||||
): TourFacetOption<V>[] {
|
||||
const counts = new Map<V, number>();
|
||||
for (const tour of tours) {
|
||||
const value = facetValue(tour, facet) as V | null;
|
||||
if (value !== null) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].sort(([a], [b]) => order(a, b)).map(([value, count]) => ({ value, count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* PR1-13: the filters of a tour list, the same on the desktop rail and the
|
||||
* phone list. Next to the day status (all, not yet on a day, on a day) it
|
||||
* offers the source, the stored routing limit and the tour type, but only a
|
||||
* dimension the list really varies in (two values or more), so a small list
|
||||
* keeps its single filter. Filtering only narrows what the list shows; tours
|
||||
* and their days are not touched.
|
||||
*/
|
||||
export function useTourListFilters(tours: readonly TourListItem[]) {
|
||||
const [status, setStatus] = useState<TourFilter>('all');
|
||||
const [selection, setSelection] = useState<TourFacetSelection>(NO_SELECTION);
|
||||
|
||||
const byStatus = useMemo(() => filterTours([...tours], status), [tours, status]);
|
||||
|
||||
const facets = useMemo(() => {
|
||||
// Each dimension counts under the others as set, so its numbers say what choosing it would show.
|
||||
const base = (facet: TourFacet) => byStatus.filter((tour) => matches(tour, selection, facet));
|
||||
const origin = optionsOf<TourOrigin>(
|
||||
base('origin'),
|
||||
'origin',
|
||||
(a, b) => ORIGIN_ORDER.indexOf(a) - ORIGIN_ORDER.indexOf(b)
|
||||
);
|
||||
const grade = optionsOf<number>(base('grade'), 'grade', (a, b) => a - b);
|
||||
const type = optionsOf<TourTypeKey>(base('type'), 'type', (a, b) => a.localeCompare(b));
|
||||
// A dimension the whole list does not vary in is no choice at all.
|
||||
const varies = (facet: TourFacet) =>
|
||||
new Set(tours.map((tour) => facetValue(tour, facet)).filter((v) => v !== null)).size >= 2;
|
||||
return {
|
||||
origin: varies('origin') ? origin : [],
|
||||
grade: varies('grade') ? grade : [],
|
||||
type: varies('type') ? type : [],
|
||||
};
|
||||
}, [byStatus, selection, tours]);
|
||||
|
||||
const filtered = useMemo(() => byStatus.filter((tour) => matches(tour, selection)), [byStatus, selection]);
|
||||
|
||||
const select = useCallback(<F extends TourFacet>(facet: F, value: TourFacetSelection[F]) => {
|
||||
setSelection((current) => ({ ...current, [facet]: current[facet] === value ? null : value }));
|
||||
}, []);
|
||||
const reset = useCallback(() => {
|
||||
setStatus('all');
|
||||
setSelection(NO_SELECTION);
|
||||
}, []);
|
||||
|
||||
const activeFacets = (['origin', 'grade', 'type'] as const).filter((facet) => selection[facet] !== null).length;
|
||||
|
||||
return {
|
||||
status,
|
||||
setStatus,
|
||||
selection,
|
||||
select,
|
||||
facets,
|
||||
/** The list has more to choose by than the day status. */
|
||||
hasFacets: facets.origin.length > 0 || facets.grade.length > 0 || facets.type.length > 0,
|
||||
filtered,
|
||||
statusCounts: {
|
||||
all: tours.length,
|
||||
unplanned: tours.filter((tour) => !tour.planned).length,
|
||||
planned: tours.filter((tour) => tour.planned).length,
|
||||
},
|
||||
/** How many filters beyond the status narrow the list. */
|
||||
activeFacets,
|
||||
/** Something is set and nothing is left: the list says so and offers to clear it. */
|
||||
emptyByFilter: tours.length > 0 && filtered.length === 0 && (status !== 'all' || activeFacets > 0),
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export type TourListFilters = ReturnType<typeof useTourListFilters>;
|
||||
@@ -20,7 +20,7 @@ import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
|
||||
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
|
||||
import MPlacesToursModeSwitch from './MPlacesToursModeSwitch'
|
||||
import MToursSelectionList from './MToursSelectionList'
|
||||
import type { TourFilter } from '../../../../components/Tours/tourPresentation'
|
||||
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
|
||||
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
|
||||
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
@@ -47,7 +47,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
// Top level of the places browser, mirroring the desktop right add-panel's
|
||||
// Places <-> Tours switch placement.
|
||||
const [toursMode, setToursMode] = useState(false)
|
||||
const [toursFilter, setToursFilter] = useState<TourFilter>('all')
|
||||
const tourFilters = useTourListFilters(planner.tours)
|
||||
|
||||
const poolPlaces = useMemo(
|
||||
// Places that are tours stay out of the Places pool while the addon is on.
|
||||
@@ -124,11 +124,11 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
{/* All / Unplanned / Planned only — no Tracks chip, no Import/Add/Select
|
||||
buttons: tours are attached here, never created or bulk-managed. */}
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<FilterChip active={toursFilter === 'all'} label={t('places.all')} onClick={() => setToursFilter('all')} />
|
||||
<FilterChip active={toursFilter === 'unplanned'} label={t('places.unplanned')} onClick={() => setToursFilter('unplanned')} />
|
||||
<FilterChip active={toursFilter === 'planned'} label={t('places.planned')} onClick={() => setToursFilter('planned')} />
|
||||
<FilterChip active={tourFilters.status === 'all'} label={t('places.all')} onClick={() => tourFilters.setStatus('all')} />
|
||||
<FilterChip active={tourFilters.status === 'unplanned'} label={t('places.unplanned')} onClick={() => tourFilters.setStatus('unplanned')} />
|
||||
<FilterChip active={tourFilters.status === 'planned'} label={t('places.planned')} onClick={() => tourFilters.setStatus('planned')} />
|
||||
</div>
|
||||
<MToursSelectionList planner={planner} shell={shell} filter={toursFilter} />
|
||||
<MToursSelectionList planner={planner} shell={shell} filters={tourFilters} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -7,6 +7,14 @@ import { resetAllStores, seedStore } from '../../../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import type { TripPlanner, MTripShellApi } from '../MTripShell'
|
||||
import MToursSelectionList from './MToursSelectionList'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useTourListFilters } from '../../../../components/Tours/useTourListFilters'
|
||||
|
||||
/** The list as the places browser mounts it: its filters from the shared hook (PR1-13). */
|
||||
function List(props: Omit<ComponentProps<typeof MToursSelectionList>, 'filters'>) {
|
||||
const filters = useTourListFilters(props.planner.tours)
|
||||
return <MToursSelectionList {...props} filters={filters} />
|
||||
}
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: 42,
|
||||
@@ -70,19 +78,19 @@ describe('MToursSelectionList', () => {
|
||||
const value = de[key] ?? en[key]
|
||||
return typeof value === 'string' ? value : key
|
||||
} }
|
||||
const { rerender } = render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
|
||||
const { rerender } = render(<List planner={germanPlanner} shell={shell()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument()
|
||||
expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern')
|
||||
expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument()
|
||||
rerender(<MToursSelectionList planner={{ ...germanPlanner, tours: [] }} shell={shell()} filter="all" />)
|
||||
rerender(<List planner={{ ...germanPlanner, tours: [] }} shell={shell()} />)
|
||||
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
|
||||
expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument()
|
||||
expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/)
|
||||
})
|
||||
|
||||
it('gives a tour name the full row and up to two lines', () => {
|
||||
render(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
|
||||
render(<List planner={planner()} shell={shell()} />)
|
||||
|
||||
const name = screen.getByText(tour.name)
|
||||
expect(name).toHaveClass('line-clamp-2', 'break-words')
|
||||
@@ -91,7 +99,7 @@ describe('MToursSelectionList', () => {
|
||||
})
|
||||
|
||||
it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => {
|
||||
const { container } = render(<MToursSelectionList planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} filter="all" canEdit canAssign />)
|
||||
const { container } = render(<List planner={{ ...planner(), selectedPlaceId: tour.place_id }} shell={shell()} canEdit canAssign />)
|
||||
expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true')
|
||||
const metrics = screen.getByTestId('mobile-tour-metrics')
|
||||
expect(metrics).toHaveTextContent('12.5 km')
|
||||
@@ -120,7 +128,7 @@ describe('MToursSelectionList', () => {
|
||||
return typeof value === 'string' ? value : key
|
||||
},
|
||||
}
|
||||
render(<MToursSelectionList planner={germanPlanner} shell={shell()} filter="all" />)
|
||||
render(<List planner={germanPlanner} shell={shell()} />)
|
||||
expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('GPX-Import')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument()
|
||||
@@ -129,7 +137,7 @@ describe('MToursSelectionList', () => {
|
||||
|
||||
it('shows no T-badge for an imported tour that carries no difficulty', () => {
|
||||
const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null }
|
||||
render(<MToursSelectionList planner={{ ...planner(), tours: [imported] }} shell={shell()} filter="all" />)
|
||||
render(<List planner={{ ...planner(), tours: [imported] }} shell={shell()} />)
|
||||
|
||||
const row = screen.getByRole('option', { name: new RegExp(tour.name) })
|
||||
expect(row).not.toHaveTextContent(/T\d/)
|
||||
@@ -139,9 +147,9 @@ describe('MToursSelectionList', () => {
|
||||
})
|
||||
|
||||
it('does not refresh Tours merely because the mobile assignment sheet closes', () => {
|
||||
const { rerender } = render(<MToursSelectionList planner={planner()} shell={shell({ id: 'bract' })} filter="all" />)
|
||||
const { rerender } = render(<List planner={planner()} shell={shell({ id: 'bract' })} />)
|
||||
|
||||
rerender(<MToursSelectionList planner={planner()} shell={shell()} filter="all" />)
|
||||
rerender(<List planner={planner()} shell={shell()} />)
|
||||
|
||||
expect(reloadTourPlaceIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -2,15 +2,34 @@ import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucid
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units'
|
||||
import { filterTours, hikeSourceBadgeLabel, type TourFilter } from '../../../../components/Tours/tourPresentation'
|
||||
import { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation'
|
||||
import { ORIGIN_LABEL_KEY } from '../../../../components/Tours/TourListFilterMenu'
|
||||
import type { TourListFilters } from '../../../../components/Tours/useTourListFilters'
|
||||
import type { TripPlanner, MTripShellApi } from '../MTripShell'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
|
||||
|
||||
interface MToursSelectionListProps extends TourPermissionProps {
|
||||
planner: TripPlanner
|
||||
shell: MTripShellApi
|
||||
/** All / Unplanned / Planned — same three states as the Places pool. */
|
||||
filter: TourFilter
|
||||
/** The list's filters (PR1-13): the day status chips sit above, the other dimensions here. */
|
||||
filters: TourListFilters
|
||||
}
|
||||
|
||||
/** One value of a dimension as a chip of the row; tapping a chosen one clears it. */
|
||||
function FacetChip({ active, label, count, onClick }: { active: boolean; label: string; count: number; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3 text-[0.75rem] font-semibold ${
|
||||
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<span className="font-geist text-[0.6875rem] opacity-60">{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,13 +41,42 @@ interface MToursSelectionListProps extends TourPermissionProps {
|
||||
* (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a
|
||||
* Place under the hood, so no new linking code is needed here either.
|
||||
*/
|
||||
export default function MToursSelectionList({ planner, shell, filter, canAssign: assignPermission }: MToursSelectionListProps) {
|
||||
export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) {
|
||||
const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission })
|
||||
const { t } = planner
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const tours: TourListItem[] = planner.tours
|
||||
const filtered: TourListItem[] = filters.filtered
|
||||
const { facets, selection } = filters
|
||||
const facetRow = filters.hasFacets && (
|
||||
<div className="-mx-4 mt-2 flex gap-[6px] overflow-x-auto px-4 pb-1">
|
||||
{facets.origin.map(option => (
|
||||
<FacetChip key={`o-${option.value}`} active={selection.origin === option.value} label={t(ORIGIN_LABEL_KEY[option.value])}
|
||||
count={option.count} onClick={() => filters.select('origin', option.value)} />
|
||||
))}
|
||||
{facets.grade.map(option => (
|
||||
<FacetChip key={`g-${option.value}`} active={selection.grade === option.value} label={`T${option.value}`}
|
||||
count={option.count} onClick={() => filters.select('grade', option.value)} />
|
||||
))}
|
||||
{facets.type.map(option => (
|
||||
<FacetChip key={`t-${option.value}`} active={selection.type === option.value} label={t(`tourTypes.${option.value}`)}
|
||||
count={option.count} onClick={() => filters.select('type', option.value)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const filtered = filterTours(tours, filter)
|
||||
if (filters.emptyByFilter) {
|
||||
return (
|
||||
<>
|
||||
{facetRow}
|
||||
<div role="status" className="mt-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-6 text-center">
|
||||
<p className="font-geist text-[0.78125rem] text-m-muted">{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset} className="mt-3 rounded-full bg-m-act px-4 py-2 text-[0.75rem] font-semibold text-m-actfg">
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
@@ -50,6 +98,7 @@ export default function MToursSelectionList({ planner, shell, filter, canAssign:
|
||||
<p className="mt-3 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.75rem] text-m-muted">
|
||||
{t('tours.planner.mobileHint')}
|
||||
</p>
|
||||
{facetRow}
|
||||
<ul className="mt-3 space-y-2">
|
||||
{filtered.map(tour => {
|
||||
const isSelected = planner.selectedPlaceId === tour.place_id
|
||||
|
||||
Reference in New Issue
Block a user