mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
refactor(places): share the places pool filters and selection between desktop and phone
usePlacesPool holds the category filter, the search, the multi select and the bulk mark visited of the places pool, for usePlacesSidebar and for MPlacesBrowser with its filter controls and sheet. When selected places are deleted, the desktop leaves select mode once none of them is left and the phone drops only the missing ids. The hook keeps both.
This commit is contained in:
@@ -7,7 +7,8 @@ import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { Eyebrow } from './bookings/bookingParts'
|
||||
import { tintOf } from './planParts'
|
||||
import { CategoryTile } from './PlacesBulkCategoryModal'
|
||||
import type { PlacesFilter, SidebarState } from './usePlacesSidebar'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
import { placesFilterTabs } from './usePlacesPool'
|
||||
import { PLACES_SORTS, type PlacesSort } from './placesSort'
|
||||
import { RATING_FLOORS, UNCATEGORIZED } from '../../utils/placesFilter'
|
||||
|
||||
@@ -69,7 +70,7 @@ export function PlacesDropOverlay({ t }: SidebarState) {
|
||||
* says so.
|
||||
*/
|
||||
export function PlacesHeader(S: SidebarState) {
|
||||
const { canEditPlaces, t, selectMode, setSelectMode, setSelectedIds } = S
|
||||
const { canEditPlaces, t, selectMode, toggleSelectMode } = S
|
||||
return (
|
||||
<div className="flex flex-none flex-col gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: NEUTRAL_TINT }}>
|
||||
{canEditPlaces && <AddRow {...S} />}
|
||||
@@ -80,7 +81,7 @@ export function PlacesHeader(S: SidebarState) {
|
||||
label={t('common.select')}
|
||||
on={selectMode}
|
||||
ariaPressed={selectMode}
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
onClick={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? <X size={15} strokeWidth={2.4} /> : <Check size={15} strokeWidth={2.4} />}
|
||||
</BandButton>
|
||||
@@ -98,14 +99,14 @@ export function PlacesHeader(S: SidebarState) {
|
||||
)
|
||||
}
|
||||
|
||||
function SearchField({ t, search, setSearch, selectMode, setSelectedIds }: SidebarState) {
|
||||
function SearchField({ t, search, setSearch, updateSearch }: SidebarState) {
|
||||
return (
|
||||
<label className={`${CONTROL} flex min-w-0 flex-1 items-center gap-2 bg-surface-card ps-2.5 pe-1.5 text-content-faint focus-within:ring-2 focus-within:ring-[color:var(--text-primary)]`}>
|
||||
<Search size={13} strokeWidth={2} className="flex-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
onChange={e => updateSearch(e.target.value)}
|
||||
placeholder={t('common.search')}
|
||||
aria-label={t('common.search')}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
|
||||
@@ -136,22 +137,11 @@ function CategoryChoice({ on, onClick, tile, label }: { on: boolean; onClick: ()
|
||||
)
|
||||
}
|
||||
|
||||
/** The "show" choices; tracks only once a place has one. */
|
||||
function filterTabs({ t, hasTracks }: Pick<SidebarState, 't' | 'hasTracks'>) {
|
||||
const tabs: Array<{ id: PlacesFilter; label: string }> = [
|
||||
{ id: 'all', label: t('places.all') },
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
{ id: 'planned', label: t('places.planned') },
|
||||
]
|
||||
if (hasTracks) tabs.push({ id: 'tracks', label: t('places.filterTracks') })
|
||||
return tabs
|
||||
}
|
||||
|
||||
/** What the list shows (every place, the unplanned, the planned, the tracks) as one dropdown with their counts. */
|
||||
function ShowDropdown(S: SidebarState) {
|
||||
const { t, filter, pickFilter, filterCounts } = S
|
||||
const [open, setOpen] = useState(false)
|
||||
const tabs = filterTabs(S)
|
||||
const tabs = placesFilterTabs(t, S.hasTracks)
|
||||
const current = tabs.find(tab => tab.id === filter) ?? tabs[0]
|
||||
return (
|
||||
<FilterDropdown
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// FE-PLANNER-POOL-001 to FE-PLANNER-POOL-016: the places pool's search, filters and
|
||||
// multi-select behind the desktop sidebar ('exit') and the phone browser ('prune').
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildPlace } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import {
|
||||
placesFilterTabs,
|
||||
poolHasTracks,
|
||||
usePlacesCategoryFilter,
|
||||
usePlacesPool,
|
||||
type PlacesPoolOptions,
|
||||
} from './usePlacesPool';
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key;
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
type HookProps = Partial<PlacesPoolOptions>;
|
||||
|
||||
function setup(initial: HookProps = {}) {
|
||||
const places = initial.places ?? [];
|
||||
const base: PlacesPoolOptions = {
|
||||
tripId: 7,
|
||||
places,
|
||||
poolPlaces: places,
|
||||
toursEnabled: false,
|
||||
t,
|
||||
staleSelection: 'exit',
|
||||
...initial,
|
||||
};
|
||||
return renderHook((p: HookProps) => usePlacesPool({ ...base, ...p }), { initialProps: {} as HookProps });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('places pool helpers', () => {
|
||||
it('FE-PLANNER-POOL-001: the Tracks choice needs a track and stays away while tours are on', () => {
|
||||
const track = buildPlace({ route_geometry: '[[1,2],[3,4]]' });
|
||||
const pin = buildPlace();
|
||||
expect(poolHasTracks([pin], false)).toBe(false);
|
||||
expect(poolHasTracks([pin, track], false)).toBe(true);
|
||||
expect(poolHasTracks([pin, track], undefined)).toBe(true);
|
||||
expect(poolHasTracks([pin, track], true)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-002: the show choices list all, unplanned and planned, then tracks when there are some', () => {
|
||||
expect(placesFilterTabs(t, false)).toEqual([
|
||||
{ id: 'all', label: 'places.all' },
|
||||
{ id: 'unplanned', label: 'places.unplanned' },
|
||||
{ id: 'planned', label: 'places.planned' },
|
||||
]);
|
||||
expect(placesFilterTabs(t, true).map((tab) => tab.id)).toEqual(['all', 'unplanned', 'planned', 'tracks']);
|
||||
expect(placesFilterTabs(t, true)[3].label).toBe('places.filterTracks');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-003: the category filter toggles ids in the trip store, a new set each time', () => {
|
||||
const { result } = renderHook(() => usePlacesCategoryFilter());
|
||||
const before = result.current.categoryFilters;
|
||||
act(() => result.current.toggleCategoryFilter('3'));
|
||||
expect([...result.current.categoryFilters]).toEqual(['3']);
|
||||
expect(result.current.categoryFilters).not.toBe(before);
|
||||
act(() => result.current.toggleCategoryFilter('uncategorized'));
|
||||
act(() => result.current.toggleCategoryFilter('3'));
|
||||
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized']);
|
||||
act(() => result.current.setCategoryFilters(new Set()));
|
||||
expect(useTripStore.getState().placesCategoryFilter.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePlacesPool', () => {
|
||||
it('FE-PLANNER-POOL-004: reads the shared filters from the trip store and writes them back', () => {
|
||||
seedStore(useTripStore, { placesFilter: 'planned', placesRatingFilter: 4 });
|
||||
const { result } = setup();
|
||||
expect(result.current.filter).toBe('planned');
|
||||
expect(result.current.ratingFilter).toBe(4);
|
||||
act(() => result.current.setRatingFilter('all'));
|
||||
expect(useTripStore.getState().placesRatingFilter).toBe('all');
|
||||
act(() => result.current.setFilter('tracks'));
|
||||
expect(useTripStore.getState().placesFilter).toBe('tracks');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-005: hasTracks follows the listed places and the tours addon', () => {
|
||||
const track = buildPlace({ route_geometry: '[[1,2],[3,4]]' });
|
||||
const { result, rerender } = setup({ places: [track], poolPlaces: [] });
|
||||
expect(result.current.hasTracks).toBe(false);
|
||||
rerender({ poolPlaces: [track] });
|
||||
expect(result.current.hasTracks).toBe(true);
|
||||
rerender({ poolPlaces: [track], toursEnabled: true });
|
||||
expect(result.current.hasTracks).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-006: the select switch flips the mode and always starts an empty selection', () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.toggleSelectMode());
|
||||
expect(result.current.selectMode).toBe(true);
|
||||
act(() => result.current.toggleSelected(1));
|
||||
act(() => result.current.toggleSelected(2));
|
||||
act(() => result.current.toggleSelected(1));
|
||||
expect([...result.current.selectedIds]).toEqual([2]);
|
||||
act(() => result.current.toggleSelectMode());
|
||||
expect(result.current.selectMode).toBe(false);
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-007: toggleSelected keeps its identity across renders', () => {
|
||||
const { result, rerender } = setup();
|
||||
const first = result.current.toggleSelected;
|
||||
act(() => result.current.toggleSelected(5));
|
||||
rerender({});
|
||||
expect(result.current.toggleSelected).toBe(first);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-008: a new show choice lands in the store and drops the selection', () => {
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.setSelectMode(true);
|
||||
result.current.toggleSelected(3);
|
||||
});
|
||||
act(() => result.current.pickFilter('unplanned'));
|
||||
expect(useTripStore.getState().placesFilter).toBe('unplanned');
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
expect(result.current.selectMode).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-009: a new search drops the selection only while picking', () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.setSelectedIds(new Set([1])));
|
||||
act(() => result.current.updateSearch('louvre'));
|
||||
expect(result.current.search).toBe('louvre');
|
||||
expect([...result.current.selectedIds]).toEqual([1]);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectMode(true);
|
||||
result.current.setSelectedIds(new Set([1, 2]));
|
||||
});
|
||||
act(() => result.current.updateSearch('lou'));
|
||||
expect(result.current.search).toBe('lou');
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
|
||||
act(() => result.current.setSelectedIds(new Set([4])));
|
||||
act(() => result.current.setSearch(''));
|
||||
expect([...result.current.selectedIds]).toEqual([4]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-010: marking the selection visited reports the matches and leaves select mode', async () => {
|
||||
const mark = vi.spyOn(collectionsApi, 'setStatusFromTrip').mockResolvedValue({ updated: 2, places: 3 });
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.setSelectMode(true);
|
||||
result.current.setSelectedIds(new Set([11, 12]));
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.markSelectionVisited();
|
||||
});
|
||||
expect(mark).toHaveBeenCalledWith(7, [11, 12], 'visited');
|
||||
expect(addToast).toHaveBeenCalledWith('collections.markedVisitedTrip:{"count":3}', 'success', undefined);
|
||||
expect(result.current.selectMode).toBe(false);
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
expect(result.current.markVisitedBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-011: nothing found in the library says so, and a missing count reads as zero', async () => {
|
||||
vi.spyOn(collectionsApi, 'setStatusFromTrip')
|
||||
.mockResolvedValueOnce({ updated: 0 })
|
||||
.mockResolvedValueOnce({ updated: 1 });
|
||||
const { result } = setup();
|
||||
act(() => result.current.setSelectedIds(new Set([11])));
|
||||
await act(async () => {
|
||||
await result.current.markSelectionVisited();
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith('collections.markVisitedNone', 'info', undefined);
|
||||
|
||||
act(() => result.current.setSelectedIds(new Set([12])));
|
||||
await act(async () => {
|
||||
await result.current.markSelectionVisited();
|
||||
});
|
||||
expect(addToast).toHaveBeenLastCalledWith('collections.markedVisitedTrip:{"count":0}', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-012: a failure keeps the selection for a retry', async () => {
|
||||
vi.spyOn(collectionsApi, 'setStatusFromTrip').mockRejectedValue(new Error('offline'));
|
||||
const { result } = setup();
|
||||
act(() => {
|
||||
result.current.setSelectMode(true);
|
||||
result.current.setSelectedIds(new Set([11]));
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.markSelectionVisited();
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith('common.error', 'error', undefined);
|
||||
expect(result.current.selectMode).toBe(true);
|
||||
expect([...result.current.selectedIds]).toEqual([11]);
|
||||
expect(result.current.markVisitedBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-013: an empty selection or a run in flight sends nothing more', async () => {
|
||||
let release: (v: { updated: number; places?: number }) => void = () => {};
|
||||
const mark = vi.spyOn(collectionsApi, 'setStatusFromTrip').mockReturnValue(
|
||||
new Promise<{ updated: number; places?: number }>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
);
|
||||
const { result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.markSelectionVisited();
|
||||
});
|
||||
expect(mark).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.setSelectedIds(new Set([11])));
|
||||
let first!: Promise<void>;
|
||||
act(() => {
|
||||
first = result.current.markSelectionVisited();
|
||||
});
|
||||
expect(result.current.markVisitedBusy).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.markSelectionVisited();
|
||||
});
|
||||
expect(mark).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
release({ updated: 1, places: 1 });
|
||||
await first;
|
||||
});
|
||||
expect(result.current.markVisitedBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-014: the desktop leaves select mode once every selected place is gone, not before', () => {
|
||||
const a = buildPlace();
|
||||
const b = buildPlace();
|
||||
const { result, rerender } = setup({ places: [a, b], staleSelection: 'exit' });
|
||||
act(() => {
|
||||
result.current.setSelectMode(true);
|
||||
result.current.setSelectedIds(new Set([a.id, b.id]));
|
||||
});
|
||||
rerender({ places: [b] });
|
||||
expect(result.current.selectMode).toBe(true);
|
||||
expect([...result.current.selectedIds]).toEqual([a.id, b.id]);
|
||||
rerender({ places: [] });
|
||||
expect(result.current.selectMode).toBe(false);
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-015: the desktop only looks again when the places change, and only while picking', () => {
|
||||
const a = buildPlace();
|
||||
const { result, rerender } = setup({ places: [a], staleSelection: 'exit' });
|
||||
// Not in select mode: a stale id stays where it is.
|
||||
act(() => result.current.setSelectedIds(new Set([999])));
|
||||
rerender({ places: [a, buildPlace()] });
|
||||
expect([...result.current.selectedIds]).toEqual([999]);
|
||||
// In select mode, but the places did not change since: nothing happens yet.
|
||||
act(() => result.current.setSelectMode(true));
|
||||
expect(result.current.selectMode).toBe(true);
|
||||
rerender({ places: [a] });
|
||||
expect(result.current.selectMode).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-POOL-016: the phone drops the ids that are gone and stays in select mode', () => {
|
||||
const a = buildPlace();
|
||||
const b = buildPlace();
|
||||
const { result, rerender } = setup({ places: [a, b], staleSelection: 'prune' });
|
||||
act(() => {
|
||||
result.current.setSelectMode(true);
|
||||
result.current.setSelectedIds(new Set([a.id, b.id]));
|
||||
});
|
||||
const kept = result.current.selectedIds;
|
||||
rerender({ places: [a, b, buildPlace()] });
|
||||
expect(result.current.selectedIds).toBe(kept);
|
||||
rerender({ places: [b] });
|
||||
expect([...result.current.selectedIds]).toEqual([b.id]);
|
||||
rerender({ places: [] });
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
expect(result.current.selectMode).toBe(true);
|
||||
// A stale id picked up later is dropped as soon as it is in the selection.
|
||||
act(() => result.current.setSelectedIds(new Set([12345])));
|
||||
expect(result.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import type { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Place } from '../../types';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** What the pool shows: everything, what is not on a day yet, what is, or the tracks. */
|
||||
export type PlacesFilter = 'all' | 'unplanned' | 'planned' | 'tracks';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** Whether the "Tracks" choice belongs in the pool: never while tours are on, since tracks are tours then. */
|
||||
export function poolHasTracks(places: Place[], toursEnabled: boolean | undefined): boolean {
|
||||
return !toursEnabled && places.some((p) => p.route_geometry);
|
||||
}
|
||||
|
||||
/** The "show" choices; tracks only once a place has one. */
|
||||
export function placesFilterTabs(t: Translate, hasTracks: boolean) {
|
||||
const tabs: Array<{ id: PlacesFilter; label: string }> = [
|
||||
{ id: 'all', label: t('places.all') },
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
{ id: 'planned', label: t('places.planned') },
|
||||
];
|
||||
if (hasTracks) tabs.push({ id: 'tracks', label: t('places.filterTracks') });
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category filter of the places pool: a set of category ids (plus the
|
||||
* no-category bucket), many at once. It lives in the trip store, so the list
|
||||
* and the map markers filter on the same values (#1541).
|
||||
*/
|
||||
export function usePlacesCategoryFilter() {
|
||||
const categoryFilters = useTripStore((s) => s.placesCategoryFilter);
|
||||
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter);
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
const next = new Set(categoryFilters);
|
||||
if (next.has(catId)) next.delete(catId);
|
||||
else next.add(catId);
|
||||
setCategoryFilters(next);
|
||||
};
|
||||
return { categoryFilters, setCategoryFilters, toggleCategoryFilter };
|
||||
}
|
||||
|
||||
export interface PlacesPoolOptions {
|
||||
tripId: number;
|
||||
/** Every place of the trip: a selected place that is gone from here is gone. */
|
||||
places: Place[];
|
||||
/** The places the pool lists; the "Tracks" choice appears once one of them has a track. */
|
||||
poolPlaces: Place[];
|
||||
toursEnabled: boolean | undefined;
|
||||
t: Translate;
|
||||
/**
|
||||
* What a selection does once its places are removed (a bulk delete, a remote edit).
|
||||
* `exit` (desktop): select mode ends once none of the selected places is left.
|
||||
* `prune` (phone): the ids that are gone drop out, so the toolbar count stays honest.
|
||||
*/
|
||||
staleSelection: 'exit' | 'prune';
|
||||
}
|
||||
|
||||
/**
|
||||
* The places pool's search, filters and multi-select: the one logic path behind the
|
||||
* desktop sidebar and the phone's places browser, which render their own markup over
|
||||
* it. A new "show" choice, a new search while picking and the select switch all start
|
||||
* a fresh selection, and the selection can be marked visited in the library in one go.
|
||||
*/
|
||||
export function usePlacesPool({ tripId, places, poolPlaces, toursEnabled, t, staleSelection }: PlacesPoolOptions) {
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
// Filter state lives in the trip store so it survives the Plan tab
|
||||
// unmounting (tab switch, mobile sheet close) and stays in lockstep with the
|
||||
// map markers, which filter on the same values (#1541).
|
||||
const filter = useTripStore((s) => s.placesFilter);
|
||||
const setFilter = useTripStore((s) => s.setPlacesFilter);
|
||||
const { categoryFilters, setCategoryFilters, toggleCategoryFilter } = usePlacesCategoryFilter();
|
||||
// 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.
|
||||
// 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);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false);
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/** The select switch: either way the selection starts empty. */
|
||||
const toggleSelectMode = () => {
|
||||
setSelectMode((v) => !v);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleSelected = useCallback(
|
||||
(id: number) =>
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
/** A new "show" choice starts a fresh selection, as picking it from the old select did. */
|
||||
const pickFilter = (next: PlacesFilter) => {
|
||||
setFilter(next);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/** A new search while picking starts a fresh selection too. */
|
||||
const updateSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
if (selectMode) setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/**
|
||||
* "I have been to these" for the selection, applied wherever the places are
|
||||
* saved in the library (#1469). The server does the matching, so a place saved
|
||||
* under a different name in a list is still found.
|
||||
*/
|
||||
const markSelectionVisited = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0 || markVisitedBusy) return;
|
||||
setMarkVisitedBusy(true);
|
||||
try {
|
||||
const { updated, places: matchedPlaces } = await collectionsApi.setStatusFromTrip(tripId, ids, 'visited');
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'));
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 }));
|
||||
exitSelectMode();
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setMarkVisitedBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete).
|
||||
const exitIfSelectionGone = useEffectEvent(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return;
|
||||
const placeIdSet = new Set(places.map((p) => p.id));
|
||||
if ([...selectedIds].every((id) => !placeIdSet.has(id))) {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (staleSelection === 'exit') exitIfSelectionGone();
|
||||
}, [places, staleSelection]);
|
||||
|
||||
// A bulk delete (or a remote edit) can remove selected places: drop the
|
||||
// stale ids so the toolbar count stays honest.
|
||||
useEffect(() => {
|
||||
if (staleSelection !== 'prune' || selectedIds.size === 0) return;
|
||||
const alive = new Set(places.map((p) => p.id));
|
||||
if ([...selectedIds].some((id) => !alive.has(id))) {
|
||||
setSelectedIds((prev) => new Set([...prev].filter((id) => alive.has(id))));
|
||||
}
|
||||
}, [places, selectedIds, staleSelection]);
|
||||
|
||||
const hasTracks = useMemo(() => poolHasTracks(poolPlaces, toursEnabled), [poolPlaces, toursEnabled]);
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
updateSearch,
|
||||
filter,
|
||||
setFilter,
|
||||
pickFilter,
|
||||
categoryFilters,
|
||||
setCategoryFilters,
|
||||
toggleCategoryFilter,
|
||||
ratingFilter,
|
||||
setRatingFilter,
|
||||
selectMode,
|
||||
setSelectMode,
|
||||
toggleSelectMode,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
toggleSelected,
|
||||
exitSelectMode,
|
||||
markSelectionVisited,
|
||||
markVisitedBusy,
|
||||
hasTracks,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Pencil, Trash2, ExternalLink, Navigation, CalendarDays, Bookmark } from
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useContextMenu } from '../shared/ContextMenu'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
@@ -21,13 +20,11 @@ import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
||||
import type { MenuEntry } from './planParts'
|
||||
import { useListImport, type ListImportProvider } from './useListImport'
|
||||
import { usePlacesPool, type PlacesFilter } from './usePlacesPool'
|
||||
|
||||
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
|
||||
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
|
||||
|
||||
/** What the pool shows: everything, what is not on a day yet, what is, or the tracks. */
|
||||
export type PlacesFilter = 'all' | 'unplanned' | 'planned' | 'tracks'
|
||||
|
||||
export interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
places: Place[]
|
||||
@@ -144,22 +141,11 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const availableListImportProviders: ListImportProvider[] = ['google', 'naver']
|
||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
// Filter state lives in the trip store so it survives the Plan tab
|
||||
// unmounting (tab switch, mobile sheet close) and stays in lockstep with the
|
||||
// map markers, which filter on the same values (#1541).
|
||||
const filter = useTripStore((s) => s.placesFilter)
|
||||
const setFilter = useTripStore((s) => s.setPlacesFilter)
|
||||
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
// 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.
|
||||
// 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)
|
||||
const {
|
||||
search, setSearch, updateSearch, filter, setFilter, pickFilter, categoryFilters, setCategoryFilters, toggleCategoryFilter,
|
||||
ratingFilter, setRatingFilter, selectMode, setSelectMode, toggleSelectMode, selectedIds, setSelectedIds, toggleSelected,
|
||||
exitSelectMode, markSelectionVisited, markVisitedBusy, hasTracks,
|
||||
} = usePlacesPool({ tripId, places, poolPlaces, toursEnabled: props.toursEnabled, t, staleSelection: 'exit' })
|
||||
// The list's order (#2093), remembered on this device.
|
||||
const [placesSort, setPlacesSortState] = useState<PlacesSort>(readPlacesSort)
|
||||
const setPlacesSort = useCallback((sort: PlacesSort) => { setPlacesSortState(sort); writePlacesSort(sort) }, [])
|
||||
@@ -168,72 +154,15 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [localityFilter, setLocalityFilter] = useState<LocalityFilter | null>(null)
|
||||
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)
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
||||
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
/**
|
||||
* "I have been to these" for the selection, applied wherever the places are
|
||||
* saved in the library (#1469). The server does the matching, so a place saved
|
||||
* under a different name in a list is still found.
|
||||
*/
|
||||
const markSelectionVisited = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0 || markVisitedBusy) return
|
||||
setMarkVisitedBusy(true)
|
||||
try {
|
||||
const { updated, places: matchedPlaces } = await collectionsApi.setStatusFromTrip(props.tripId, ids, 'visited')
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 }))
|
||||
exitSelectMode()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setMarkVisitedBusy(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIds, markVisitedBusy, props.tripId, t])
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
const placeIdSet = new Set(places.map(p => p.id))
|
||||
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [places])
|
||||
|
||||
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
const next = new Set(categoryFilters)
|
||||
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||
setCategoryFilters(next)
|
||||
}
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
|
||||
// One panel holds what used to be three dropdowns (show, categories, rating).
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
/** A new "show" choice starts a fresh selection, as picking it from the old select did. */
|
||||
const pickFilter = (next: PlacesFilter) => { setFilter(next); setSelectedIds(new Set()) }
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const hasTracks = useMemo(
|
||||
() => !props.toursEnabled && poolPlaces.some(p => p.route_geometry),
|
||||
[poolPlaces, props.toursEnabled],
|
||||
)
|
||||
|
||||
const plannedIds = useMemo(
|
||||
() => plannedPlaceIds({ assignments, accommodations, reservations }),
|
||||
[assignments, accommodations, reservations],
|
||||
@@ -365,12 +294,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
listImportLoading: listImport.loading, listImportProvider: listImport.provider, setListImportProvider: listImport.setProvider,
|
||||
listImportEnrich: listImport.enrich, setListImportEnrich: listImport.setEnrich, canEnrichImport: listImport.canEnrich,
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport: listImport.handleImport,
|
||||
search, setSearch, filter, setFilter, pickFilter, filterCounts,
|
||||
search, setSearch, updateSearch, filter, setFilter, pickFilter, filterCounts,
|
||||
categoryFilters, setCategoryFilters,
|
||||
ratingFilter, setRatingFilter,
|
||||
placesSort, setPlacesSort,
|
||||
localityFilter, setLocalityFilter, localities,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
selectMode, setSelectMode, toggleSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
|
||||
markSelectionVisited, markVisitedBusy,
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
SlidersHorizontal, Tag, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useToast } from '../../../../components/shared/Toast'
|
||||
import { collectionsApi } from '../../../../api/collections'
|
||||
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
|
||||
import MarkdownText from '../../../../components/shared/MarkdownText'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
@@ -26,6 +23,7 @@ import MToursSelectionList from './MToursSelectionList'
|
||||
import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBrowserModel'
|
||||
import { MCategoryFilterList, MRatingFloorChips, SquareCheck } from './MPlacesFilterControls'
|
||||
import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
import { usePlacesPool } from '../../../../components/Planner/usePlacesPool'
|
||||
|
||||
/**
|
||||
* Fullscreen places pool (mode === 'browse'): All/Unplanned/Tracks filter
|
||||
@@ -50,19 +48,21 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
const [toursMode, setToursMode] = useState(false)
|
||||
const [toursFilter, setToursFilter] = useState<'all' | 'unplanned' | 'planned'>('all')
|
||||
|
||||
const filter = useTripStore(s => s.placesFilter)
|
||||
const setFilter = useTripStore(s => s.setPlacesFilter)
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const ratingFilter = useTripStore(s => s.placesRatingFilter)
|
||||
const poolPlaces = useMemo(
|
||||
// Places that are tours stay out of the Places pool while the addon is on.
|
||||
// The planner's answer, not a second list of its own: that one is kept up
|
||||
// to date by the trip's realtime events and knows the mark on each place.
|
||||
() => toursEnabled ? places.filter(place => !planner.isTourPlace(place.id)) : places,
|
||||
[places, toursEnabled, planner.isTourPlace],
|
||||
)
|
||||
const {
|
||||
search, updateSearch, filter, setFilter, pickFilter, categoryFilters, ratingFilter, selectMode, toggleSelectMode,
|
||||
selectedIds, setSelectedIds, toggleSelected, exitSelectMode, markSelectionVisited, markVisitedBusy, hasTracks,
|
||||
} = usePlacesPool({ tripId: trip.id, places, poolPlaces, toursEnabled, t, staleSelection: 'prune' })
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
||||
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
||||
const toast = useToast()
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
|
||||
// Entering the browser from the edit segment starts on the unplanned pool.
|
||||
@@ -70,8 +70,6 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
if (shell.browseFromEdit) setFilter('unplanned')
|
||||
}, [shell.browseFromEdit, setFilter])
|
||||
|
||||
const hasTracks = useMemo(() => !toursEnabled && places.some(p => p.route_geometry), [places, toursEnabled])
|
||||
|
||||
// A hotel is linked through its stay and a venue through its booking; neither is
|
||||
// ever dragged onto a day, and the pool used to call both unplanned (#2072).
|
||||
const plannedIds = useMemo(
|
||||
@@ -79,57 +77,10 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
[assignments, planner.tripAccommodations, planner.reservations],
|
||||
)
|
||||
const dayNumberByPlace = useMemo(() => firstPlannedDayNumbers(assignments, days), [assignments, days])
|
||||
const poolPlaces = useMemo(
|
||||
// Places that are tours stay out of the Places pool while the addon is on.
|
||||
// The planner's answer, not a second list of its own: that one is kept up
|
||||
// to date by the trip's realtime events and knows the mark on each place.
|
||||
() => toursEnabled ? places.filter(place => !planner.isTourPlace(place.id)) : places,
|
||||
[places, toursEnabled, planner.isTourPlace],
|
||||
)
|
||||
const filtered = useMemo(() => {
|
||||
return filterPool(poolPlaces, { filter, categoryFilters, ratingFilter, search, plannedIds })
|
||||
}, [poolPlaces, filter, categoryFilters, ratingFilter, search, plannedIds])
|
||||
|
||||
// A bulk delete (or a remote edit) can remove selected places — drop the
|
||||
// stale ids so the toolbar count stays honest.
|
||||
useEffect(() => {
|
||||
if (selectedIds.size === 0) return
|
||||
const alive = new Set(places.map(p => p.id))
|
||||
if ([...selectedIds].some(id => !alive.has(id))) {
|
||||
setSelectedIds(prev => new Set([...prev].filter(id => alive.has(id))))
|
||||
}
|
||||
}, [places, selectedIds])
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const toggleSelected = (id: number) =>
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
/** Mark the selection visited in every list it is saved to (#1469). */
|
||||
const markSelectionVisited = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0 || markVisitedBusy) return
|
||||
setMarkVisitedBusy(true)
|
||||
try {
|
||||
const { updated, places: matched } = await collectionsApi.setStatusFromTrip(trip.id, ids, 'visited')
|
||||
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
||||
else toast.success(t('collections.markedVisitedTrip', { count: matched ?? 0 }))
|
||||
exitSelectMode()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setMarkVisitedBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare the ids, not just the counts: a place removed remotely while another
|
||||
// one is selected keeps the sizes equal without the sets matching.
|
||||
const selectablePlaces = filtered.filter(place => !planner.isTourPlace(place.id))
|
||||
@@ -185,23 +136,23 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
<FilterChip
|
||||
active={filter === 'all'}
|
||||
label={t('places.all')}
|
||||
onClick={() => { setFilter('all'); setSelectedIds(new Set()) }}
|
||||
onClick={() => pickFilter('all')}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === 'unplanned'}
|
||||
label={t('places.unplanned')}
|
||||
onClick={() => { setFilter('unplanned'); setSelectedIds(new Set()) }}
|
||||
onClick={() => pickFilter('unplanned')}
|
||||
/>
|
||||
<FilterChip
|
||||
active={filter === 'planned'}
|
||||
label={t('places.planned')}
|
||||
onClick={() => { setFilter('planned'); setSelectedIds(new Set()) }}
|
||||
onClick={() => pickFilter('planned')}
|
||||
/>
|
||||
{hasTracks && (
|
||||
<FilterChip
|
||||
active={filter === 'tracks'}
|
||||
label={t('places.filterTracks')}
|
||||
onClick={() => { setFilter('tracks'); setSelectedIds(new Set()) }}
|
||||
onClick={() => pickFilter('tracks')}
|
||||
/>
|
||||
)}
|
||||
{canEditPlaces && (
|
||||
@@ -220,7 +171,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
<div className="mt-[10px] flex items-stretch gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
onChange={e => updateSearch(e.target.value)}
|
||||
placeholder={t('places.search')}
|
||||
className="box-border min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-[0.8125rem] font-medium text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
@@ -241,7 +192,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
|
||||
{canEditPlaces && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
onClick={toggleSelectMode}
|
||||
aria-pressed={selectMode}
|
||||
aria-label={t('common.select')}
|
||||
className={`flex w-[42px] flex-none items-center justify-center rounded-full border ${
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTripStore } from '../../../../store/tripStore'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import { RATING_FLOORS, UNCATEGORIZED } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { usePlacesCategoryFilter } from '../../../../components/Planner/usePlacesPool'
|
||||
import type { Category, Place } from '../../../../types'
|
||||
|
||||
/**
|
||||
@@ -50,14 +51,7 @@ function CategoryFilterRow({ checked, onToggle, label, children }: {
|
||||
/** The trip's categories as a multi-select, plus "no category" while a place has none. */
|
||||
export function MCategoryFilterList({ categories, places }: { categories: Category[]; places: Place[] }) {
|
||||
const { t } = useTranslation()
|
||||
const categoryFilters = useTripStore(s => s.placesCategoryFilter)
|
||||
const setCategoryFilters = useTripStore(s => s.setPlacesCategoryFilter)
|
||||
const toggle = (catId: string) => {
|
||||
const next = new Set(categoryFilters)
|
||||
if (next.has(catId)) next.delete(catId)
|
||||
else next.add(catId)
|
||||
setCategoryFilters(next)
|
||||
}
|
||||
const { categoryFilters, toggleCategoryFilter: toggle } = usePlacesCategoryFilter()
|
||||
return (
|
||||
<>
|
||||
{categories.map(c => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { countActivePlacesFilters } from '../../../../utils/placesFilter'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Category, Place } from '../../../../types'
|
||||
import { MCategoryFilterList, MRatingFloorChips } from './MPlacesFilterControls'
|
||||
import { placesFilterTabs, poolHasTracks } from '../../../../components/Planner/usePlacesPool'
|
||||
|
||||
interface MPlacesFilterSheetProps {
|
||||
open: boolean
|
||||
@@ -38,12 +39,7 @@ export default function MPlacesFilterSheet({ open, onClose, places, categories,
|
||||
const resetPlacesFilters = useTripStore(s => s.resetPlacesFilters)
|
||||
const activeCount = countActivePlacesFilters({ filter, categoryFilters, ratingFilter })
|
||||
|
||||
const pools = [
|
||||
{ id: 'all', label: t('places.all') },
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
{ id: 'planned', label: t('places.planned') },
|
||||
]
|
||||
if (!toursEnabled && places.some(p => p.route_geometry)) pools.push({ id: 'tracks', label: t('places.filterTracks') })
|
||||
const pools = placesFilterTabs(t, poolHasTracks(places, toursEnabled))
|
||||
const hasCategoryChoice = categories.length > 0 || places.some(p => p.category_id == null)
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user