mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 561,
|
||||
"react-hooks/refs": 560,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1409,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2372,
|
||||
"src/components/Map/MapViewGL.tsx": 2371,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
|
||||
@@ -42,7 +42,6 @@ import { ratingBadgeHtml } from './ratingBadge'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { poiPinParts } from './poiMarker'
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Place } from '../../types';
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers';
|
||||
|
||||
function fakeMap() {
|
||||
const layers = new Map<string, { filter?: unknown }>();
|
||||
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
|
||||
const map = {
|
||||
addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })),
|
||||
getSource: (id: string) => sources.get(id),
|
||||
addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })),
|
||||
getLayer: (id: string) => layers.get(id),
|
||||
setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })),
|
||||
};
|
||||
return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map };
|
||||
}
|
||||
|
||||
describe('gpxTrackLayers (PR1-08)', () => {
|
||||
it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => {
|
||||
const { map, layers } = fakeMap();
|
||||
expect(hasGpxTrackLayers(map)).toBe(false);
|
||||
addGpxTrackLayers(map);
|
||||
expect(hasGpxTrackLayers(map)).toBe(true);
|
||||
expect([...layers.keys()]).toEqual([
|
||||
'trip-gpx-casing',
|
||||
'trip-gpx-line',
|
||||
'trip-gpx-selected-casing',
|
||||
'trip-gpx-selected',
|
||||
GPX_HIT_LAYER_ID,
|
||||
]);
|
||||
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
|
||||
});
|
||||
|
||||
it('puts only places with a drawable track into the source, with their colour and rim', () => {
|
||||
const { map, sources } = fakeMap();
|
||||
addGpxTrackLayers(map);
|
||||
const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place;
|
||||
const plain = { id: 6, route_geometry: null } as Place;
|
||||
setGpxTracks(map, [track, plain]);
|
||||
const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0];
|
||||
expect(data.features).toHaveLength(1);
|
||||
expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 });
|
||||
expect(data.features[0].geometry.coordinates).toEqual([
|
||||
[11, 47],
|
||||
[11.1, 47.1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('points the picked layers at the selected place, and at none again when nothing is picked', () => {
|
||||
const { map, layers } = fakeMap();
|
||||
addGpxTrackLayers(map);
|
||||
selectGpxTrack(map, 5);
|
||||
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
|
||||
expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
|
||||
selectGpxTrack(map, null);
|
||||
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
|
||||
});
|
||||
|
||||
it('does nothing before the style has its layers', () => {
|
||||
const { map, raw } = fakeMap();
|
||||
selectGpxTrack(map, 5);
|
||||
setGpxTracks(map, []);
|
||||
expect(raw.setFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { TourListItem } from '@trek/shared';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import '../../styles/fadeEnd.css';
|
||||
import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
@@ -95,7 +96,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
|
||||
return (
|
||||
// One line like the row's name: when the hover controls come out, the description
|
||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||
<div className="mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
{place.place_time && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}
|
||||
|
||||
@@ -3,24 +3,9 @@ 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 { ORIGIN_LABEL_KEY, statusLabel } 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,
|
||||
@@ -132,7 +117,11 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
|
||||
/>
|
||||
</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">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('places.filterShow')}
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../styles/fadeEnd.css'
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
@@ -38,9 +39,7 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
|
||||
aria-label={label}
|
||||
aria-pressed={toggle.hidden}
|
||||
onClick={event => { event.stopPropagation(); toggle.onToggle() }}
|
||||
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden
|
||||
? 'bg-surface-tertiary text-content-muted ring-edge-faint hover:text-content'
|
||||
: 'bg-surface-card text-content-muted ring-edge-faint hover:text-content'}`}
|
||||
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden ? 'bg-surface-tertiary' : 'bg-surface-card'}`}
|
||||
>
|
||||
{toggle.hidden ? <EyeOff size={13} strokeWidth={2.2} /> : <Eye size={13} strokeWidth={2.2} />}
|
||||
</button>
|
||||
@@ -48,6 +47,65 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
|
||||
)
|
||||
}
|
||||
|
||||
/** The tile, the name and one line of badges with the description trailing after them. */
|
||||
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
|
||||
tour: TourListItem
|
||||
nameId: string
|
||||
tourDays: ReturnType<typeof useTourDays>
|
||||
dimmed: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${dimmed ? 'opacity-55' : ''}`}>
|
||||
<TourGradeTile tour={tour} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
|
||||
style={fs(13, 'body')}>{tour.name}</span>
|
||||
{/* One line like a plan row's: the badges keep their place, the description gives way,
|
||||
and what does not fit fades out instead of ending at a hard edge. */}
|
||||
<div
|
||||
data-testid="tour-metrics"
|
||||
className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
|
||||
>
|
||||
<TourCautionBadge tour={tour} />
|
||||
<TourDayRowPills tour={tour} />
|
||||
<TourDaysBadge tourDays={tourDays} />
|
||||
{tour.description && (
|
||||
<span
|
||||
data-testid="tour-description-preview"
|
||||
className="min-w-0 flex-1 truncate text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{tour.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The eye and the row's actions on the right. A hidden tour keeps its eye in
|
||||
* sight; the rest come out on hover, focus or selection, as a plan row's
|
||||
* controls do, so the name and badges have the room otherwise.
|
||||
*/
|
||||
function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps, 'action' | 'mapToggle'> & { selected: boolean }) {
|
||||
const hoverToggle = mapToggle && !mapToggle.hidden ? mapToggle : null
|
||||
return (
|
||||
<>
|
||||
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
|
||||
{(action || hoverToggle) && (
|
||||
<div data-testid="tour-action-row" role="presentation"
|
||||
onClick={event => event.stopPropagation()}
|
||||
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
|
||||
{hoverToggle && <MapToggleButton toggle={hoverToggle} />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour in a list, laid out like a stop in the day plan: its tile with the
|
||||
* grade, the name, and one line of the plan's white badges (time, distance,
|
||||
@@ -89,41 +147,8 @@ export default function TourListRow({ tour,
|
||||
// The line's colour on the map, as a plan row shows its category.
|
||||
<span aria-hidden="true" className="-me-1 h-8 w-[3px] flex-none rounded-full" style={{ background: trackColor }} />
|
||||
)}
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${mapToggle?.hidden ? 'opacity-55' : ''}`}>
|
||||
<TourGradeTile tour={tour} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
|
||||
style={fs(13, 'body')}>{tour.name}</span>
|
||||
{/* One line like a plan row's: the badges keep their place, the description gives way. */}
|
||||
<div
|
||||
data-testid="tour-metrics"
|
||||
className="mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
|
||||
>
|
||||
<TourCautionBadge tour={tour} />
|
||||
<TourDayRowPills tour={tour} />
|
||||
<TourDaysBadge tourDays={tourDays} />
|
||||
{tour.description && (
|
||||
<span
|
||||
data-testid="tour-description-preview"
|
||||
className="min-w-0 flex-1 truncate text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{tour.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
|
||||
{(action || (mapToggle && !mapToggle.hidden)) && (
|
||||
// Out on hover, focus or selection, as a plan row's controls are: the name and badges have the room otherwise.
|
||||
<div data-testid="tour-action-row" role="presentation"
|
||||
onClick={event => event.stopPropagation()}
|
||||
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
|
||||
{mapToggle && !mapToggle.hidden && <MapToggleButton toggle={mapToggle} />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
|
||||
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
|
||||
</div>
|
||||
{children}
|
||||
</li>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { toursApi } from '../../api/client'
|
||||
import ToursSidebar from './ToursSidebar'
|
||||
@@ -304,4 +306,39 @@ describe('ToursSidebar', () => {
|
||||
expect(badge).toHaveTextContent('Days 1, 3')
|
||||
expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit')
|
||||
})
|
||||
|
||||
it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => {
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[
|
||||
{ ...tour, place_id: 1, name: 'Drawn', origin: 'trek', max_hiking_difficulty: 2 },
|
||||
{ ...tour, place_id: 2, name: 'Imported', origin: 'gpx', max_hiking_difficulty: null },
|
||||
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
|
||||
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
|
||||
const source = screen.getByRole('group', { name: 'Source' })
|
||||
fireEvent.click(within(source).getByRole('button', { name: /GPX import\s*1/ }))
|
||||
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
|
||||
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
|
||||
// Only one tour type in the list: no type filter.
|
||||
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ }))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('PR1-07/08: a row shows its line colour, and its eye hides the tour from the map until shown again', () => {
|
||||
seedStore(useAuthStore, { user: { id: 3 } as never })
|
||||
seedStore(useTripStore, { places: [{ id: 42, route_color: '#e11d48' }] as never })
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
const row = screen.getByRole('option', { name: /Selected ridge walk/ })
|
||||
expect(row.querySelector('[aria-hidden="true"][style]')).toHaveStyle({ background: '#e11d48' })
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Hide on the map' }))
|
||||
expect(useTourMapVisibilityStore.getState().hidden).toEqual([42])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Hidden on the map')
|
||||
// A hidden tour keeps its eye in sight, not only on hover.
|
||||
expect(within(row).getByRole('button', { name: 'Show on the map' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show all' }))
|
||||
expect(useTourMapVisibilityStore.getState().hidden).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import TourProvenanceSection from './TourProvenanceSection';
|
||||
import type { SourceCheck } from './useSourceCheck';
|
||||
|
||||
const base: TourProvenance = {
|
||||
origin: 'trek',
|
||||
createdAt: '2026-07-01 10:00:00',
|
||||
source: { kind: 'trek' },
|
||||
derivedFrom: null,
|
||||
plannedOver: false,
|
||||
editedLocally: null,
|
||||
};
|
||||
|
||||
function check(over: Partial<SourceCheck> = {}): SourceCheck {
|
||||
return {
|
||||
checking: false,
|
||||
result: null,
|
||||
check: vi.fn(),
|
||||
canReimport: false,
|
||||
confirming: false,
|
||||
askReimport: vi.fn(),
|
||||
cancelReimport: vi.fn(),
|
||||
reimporting: false,
|
||||
reimport: vi.fn(),
|
||||
...over,
|
||||
} as SourceCheck;
|
||||
}
|
||||
|
||||
describe('TourProvenanceSection (P2C-02, MF-03)', () => {
|
||||
it('names a planned tour as planned here, with no local state and no check', () => {
|
||||
render(<TourProvenanceSection provenance={base} />);
|
||||
expect(screen.getByText('tours.provenance.label')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.badge.planned')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.provenance.createdAt')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tours.provenance.localState')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /tours.provenance.check/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a GPX import with its file, and that it was planned over keeping its track', () => {
|
||||
render(
|
||||
<TourProvenanceSection
|
||||
provenance={{ ...base, origin: 'gpx', source: { kind: 'gpx', fileName: 'ridge.gpx' }, plannedOver: true }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('tours.badge.gpx')).toBeInTheDocument();
|
||||
expect(screen.getByText('ridge.gpx')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.provenance.importedAt')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.provenance.plannedOver')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links a wanderer snapshot to its trail, tells local edits apart and checks the source on purpose', () => {
|
||||
const onCheck = vi.fn();
|
||||
render(
|
||||
<TourProvenanceSection
|
||||
provenance={{
|
||||
...base,
|
||||
origin: 'wanderer',
|
||||
source: {
|
||||
kind: 'wanderer',
|
||||
instance: 'https://trails.example',
|
||||
externalId: 't1',
|
||||
externalUrl: 'https://trails.example/trail/view/@me/t1',
|
||||
externalUpdated: '2026-10-01 08:00:00Z',
|
||||
importedAt: '2026-10-02 09:00:00',
|
||||
},
|
||||
editedLocally: true,
|
||||
}}
|
||||
check={check({ check: onCheck })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('trails.example')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'tours.provenance.openSource' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://trails.example/trail/view/@me/t1'
|
||||
);
|
||||
expect(screen.getByText('tours.provenance.externalUpdated')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.provenance.editedLocally')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.provenance.check$/ }));
|
||||
expect(onCheck).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says what the check found, the parts that changed, and asks before the reimport', () => {
|
||||
const result: TourSourceCheckResponse = {
|
||||
state: 'changed',
|
||||
changes: ['name', 'route'],
|
||||
externalUpdated: '2026-10-14 12:00:00Z',
|
||||
importedUpdated: null,
|
||||
editedLocally: false,
|
||||
reimport: { trailId: 't1', contentHash: 'h' },
|
||||
};
|
||||
const askReimport = vi.fn();
|
||||
const { rerender } = render(
|
||||
<TourProvenanceSection
|
||||
provenance={{
|
||||
...base,
|
||||
origin: 'wanderer',
|
||||
source: {
|
||||
kind: 'wanderer',
|
||||
instance: 'https://t.example',
|
||||
externalId: 't1',
|
||||
externalUrl: null,
|
||||
externalUpdated: null,
|
||||
importedAt: null,
|
||||
},
|
||||
}}
|
||||
check={check({ result, canReimport: true, askReimport })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('tours.provenance.state.changed');
|
||||
expect(screen.getByText('tours.provenance.change.name')).toBeInTheDocument();
|
||||
expect(screen.getByText('tours.provenance.change.route')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours.provenance.reimport/ }));
|
||||
expect(askReimport).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<TourProvenanceSection
|
||||
provenance={{
|
||||
...base,
|
||||
origin: 'wanderer',
|
||||
source: {
|
||||
kind: 'wanderer',
|
||||
instance: 'https://t.example',
|
||||
externalId: 't1',
|
||||
externalUrl: null,
|
||||
externalUpdated: null,
|
||||
importedAt: null,
|
||||
},
|
||||
}}
|
||||
check={check({ result: { ...result, state: 'unavailable', changes: [], reimport: null } })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('tours.provenance.body.unavailable')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /tours.provenance.reimport/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useSourceCheck } from './useSourceCheck';
|
||||
|
||||
const repo = vi.hoisted(() => ({ checkSource: vi.fn(), importTrail: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../../../repo/wandererRepo', () => ({
|
||||
wandererRepo: repo,
|
||||
wandererErrorCode: (error: unknown) => (error as { code?: string })?.code ?? 'unknown',
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const changed: TourSourceCheckResponse = {
|
||||
state: 'changed',
|
||||
changes: ['route'],
|
||||
externalUpdated: '2026-10-14',
|
||||
importedUpdated: '2026-10-01',
|
||||
editedLocally: false,
|
||||
reimport: { trailId: 't1', contentHash: 'h' },
|
||||
};
|
||||
const newTour = { place_id: 99, name: 'Ridge' } as TourListItem;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.checkSource.mockResolvedValue(changed);
|
||||
repo.importTrail.mockResolvedValue({ tour: newTour });
|
||||
});
|
||||
|
||||
describe('useSourceCheck (MF-03)', () => {
|
||||
it('asks only on purpose, and offers the reimport for a change when someone takes it in', async () => {
|
||||
const onReimported = vi.fn();
|
||||
const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported }));
|
||||
expect(repo.checkSource).not.toHaveBeenCalled();
|
||||
expect(result.current.result).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
expect(repo.checkSource).toHaveBeenCalledWith(1, 7);
|
||||
expect(result.current.result).toEqual(changed);
|
||||
expect(result.current.canReimport).toBe(true);
|
||||
|
||||
act(() => result.current.askReimport());
|
||||
expect(result.current.confirming).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.reimport();
|
||||
});
|
||||
expect(repo.importTrail).toHaveBeenCalledWith(1, { trailId: 't1', contentHash: 'h' });
|
||||
expect(onReimported).toHaveBeenCalledWith(newTour);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.confirming).toBe(false);
|
||||
});
|
||||
|
||||
it('without anyone to take the new snapshot in, or for an unchanged trail, offers no reimport', async () => {
|
||||
repo.checkSource.mockResolvedValue({ ...changed, state: 'unchanged', changes: [], reimport: null });
|
||||
const unchanged = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported: vi.fn() }));
|
||||
await act(async () => {
|
||||
await unchanged.result.current.check();
|
||||
});
|
||||
expect(unchanged.result.current.canReimport).toBe(false);
|
||||
|
||||
repo.checkSource.mockResolvedValue(changed);
|
||||
const nobody = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7 }));
|
||||
await act(async () => {
|
||||
await nobody.result.current.check();
|
||||
});
|
||||
expect(nobody.result.current.canReimport).toBe(false);
|
||||
});
|
||||
|
||||
it('a failed check or reimport says so and changes nothing', async () => {
|
||||
repo.checkSource.mockRejectedValueOnce({ code: 'unreachable' });
|
||||
const onReimported = vi.fn();
|
||||
const { result } = renderHook(() => useSourceCheck({ tripId: 1, placeId: 7, onReimported }));
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
repo.importTrail.mockRejectedValueOnce({ code: 'preview_stale' });
|
||||
await act(async () => {
|
||||
await result.current.reimport();
|
||||
});
|
||||
expect(onReimported).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('another tour in the same dialog starts unchecked', async () => {
|
||||
const { result, rerender } = renderHook(({ placeId }) => useSourceCheck({ tripId: 1, placeId }), {
|
||||
initialProps: { placeId: 7 },
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
rerender({ placeId: 8 });
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem, TourSourceCheckResponse } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { wandererErrorCode, wandererRepo } from '../../../repo/wandererRepo';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
@@ -22,15 +22,24 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<TourSourceCheckResponse | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [reimporting, setReimporting] = useState(false);
|
||||
|
||||
// Another tour shown in the same dialog starts unchecked.
|
||||
useEffect(() => {
|
||||
setResult(null);
|
||||
setConfirming(false);
|
||||
}, [placeId]);
|
||||
// The answer and the question belong to the tour they were asked for:
|
||||
// another tour shown in the same dialog starts unchecked.
|
||||
const [state, setState] = useState<{ placeId: number; result: TourSourceCheckResponse | null; confirming: boolean }>({
|
||||
placeId,
|
||||
result: null,
|
||||
confirming: false,
|
||||
});
|
||||
const result = state.placeId === placeId ? state.result : null;
|
||||
const confirming = state.placeId === placeId && state.confirming;
|
||||
const setResult = useCallback(
|
||||
(next: TourSourceCheckResponse | null) => setState({ placeId, result: next, confirming: false }),
|
||||
[placeId]
|
||||
);
|
||||
const setConfirming = useCallback(
|
||||
(next: boolean) => setState((current) => ({ ...current, placeId, confirming: next })),
|
||||
[placeId]
|
||||
);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (checking) return;
|
||||
@@ -43,7 +52,7 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [checking, placeId, t, toast, tripId]);
|
||||
}, [checking, placeId, setResult, t, toast, tripId]);
|
||||
|
||||
const reimport = useCallback(async () => {
|
||||
const payload = result?.reimport;
|
||||
@@ -61,7 +70,7 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
|
||||
} finally {
|
||||
setReimporting(false);
|
||||
}
|
||||
}, [onReimported, reimporting, result, t, toast, tripId]);
|
||||
}, [onReimported, reimporting, result, setConfirming, setResult, t, toast, tripId]);
|
||||
|
||||
return {
|
||||
checking,
|
||||
@@ -70,8 +79,8 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt
|
||||
/** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */
|
||||
canReimport: !!result?.reimport && !!onReimported,
|
||||
confirming,
|
||||
askReimport: useCallback(() => setConfirming(true), []),
|
||||
cancelReimport: useCallback(() => setConfirming(false), []),
|
||||
askReimport: useCallback(() => setConfirming(true), [setConfirming]),
|
||||
cancelReimport: useCallback(() => setConfirming(false), [setConfirming]),
|
||||
reimporting,
|
||||
reimport,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourListItem, TourProvenance } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useTourProvenance } from './useTourProvenance';
|
||||
|
||||
const repo = vi.hoisted(() => ({ detail: vi.fn() }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: repo }));
|
||||
|
||||
const provenance: TourProvenance = {
|
||||
origin: 'trek',
|
||||
createdAt: null,
|
||||
source: { kind: 'trek' },
|
||||
derivedFrom: null,
|
||||
plannedOver: false,
|
||||
editedLocally: null,
|
||||
};
|
||||
const tour = { place_id: 7, name: 'Ridge', distance: 4 } as TourListItem;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.detail.mockResolvedValue({ tour, waypoints: [], provenance });
|
||||
});
|
||||
|
||||
describe('useTourProvenance (P2C-02)', () => {
|
||||
it('reads the provenance with the detail, and asks again when the tour changes', async () => {
|
||||
const { result, rerender } = renderHook(({ current }) => useTourProvenance(1, current), {
|
||||
initialProps: { current: tour },
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
await waitFor(() => expect(result.current).toEqual(provenance));
|
||||
expect(repo.detail).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
|
||||
|
||||
rerender({ current: { ...tour, name: 'Renamed' } });
|
||||
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('shows none when the detail cannot be had, or carries none', async () => {
|
||||
repo.detail.mockRejectedValueOnce(new Error('offline'));
|
||||
const failed = renderHook(() => useTourProvenance(1, tour));
|
||||
await waitFor(() => expect(repo.detail).toHaveBeenCalled());
|
||||
expect(failed.result.current).toBeNull();
|
||||
|
||||
repo.detail.mockResolvedValueOnce({ tour, waypoints: [] });
|
||||
const none = renderHook(() => useTourProvenance(1, { ...tour, place_id: 8 }));
|
||||
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
|
||||
expect(none.result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,13 @@ describe('tourPresentation', () => {
|
||||
expect(hikeSourceBadgeLabel({ wanderer_ref: 'wanderer:123' }, t)).toBe('tours.badge.wanderer')
|
||||
})
|
||||
|
||||
it('P2C-02: takes the origin the server recorded over the shape of the tour', () => {
|
||||
// An import planned over in place has waypoints, and is still an import.
|
||||
expect(tourSource({ wanderer_ref: null, has_waypoints: true, origin: 'gpx' })).toBe('gpx')
|
||||
expect(tourSource({ wanderer_ref: null, has_waypoints: false, origin: 'trek' })).toBe('trek')
|
||||
expect(tourSource({ wanderer_ref: 'w#1', origin: 'wanderer' })).toBe('wanderer')
|
||||
})
|
||||
|
||||
it('filters tours by All, Unplanned and Planned, keeping their order', () => {
|
||||
const tours = [
|
||||
{ id: 1, planned: true },
|
||||
|
||||
@@ -86,3 +86,13 @@ export function filterTours<T extends Pick<TourListItem, 'planned'>>(tours: T[],
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
/** The day status a tour list is filtered by, in words. */
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { AssignmentsMap } from '../../types';
|
||||
import { isTourOnDay, useTourDayAssignment } from './useTourDayAssignment';
|
||||
|
||||
const assignments = { '3': [{ id: 31, place_id: 42 }], '4': [] } as unknown as AssignmentsMap;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { assignments });
|
||||
});
|
||||
|
||||
describe('isTourOnDay (PR1-18)', () => {
|
||||
it('reads the day lists: a tour is on a day once it has an assignment there', () => {
|
||||
expect(isTourOnDay(assignments, 42, 3)).toBe(true);
|
||||
expect(isTourOnDay(assignments, 42, 4)).toBe(false);
|
||||
expect(isTourOnDay(assignments, 42, 99)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTourDayAssignment (PR1-18)', () => {
|
||||
it('offers only a day that does not have the tour yet, and nothing without the right', () => {
|
||||
const allowed = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay: vi.fn() }));
|
||||
expect(allowed.result.current.isOnDay(42, 3)).toBe(true);
|
||||
expect(allowed.result.current.isAvailable(42, 3)).toBe(false);
|
||||
expect(allowed.result.current.isAvailable(42, 4)).toBe(true);
|
||||
|
||||
const denied = renderHook(() => useTourDayAssignment({ canAssign: false, onAssignToDay: vi.fn() }));
|
||||
expect(denied.result.current.isAvailable(42, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('sends one write per tour and day while it is on its way, and says whether it happened', async () => {
|
||||
let finish: (value: boolean) => void = () => undefined;
|
||||
const onAssignToDay = vi.fn(() => new Promise<boolean>((resolve) => (finish = resolve)));
|
||||
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
|
||||
|
||||
let first: Promise<boolean> = Promise.resolve(false);
|
||||
let second: Promise<boolean> = Promise.resolve(false);
|
||||
act(() => {
|
||||
first = result.current.assign(42, 4);
|
||||
second = result.current.assign(42, 4);
|
||||
});
|
||||
expect(onAssignToDay).toHaveBeenCalledTimes(1);
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, undefined);
|
||||
expect(await second).toBe(false);
|
||||
expect(result.current.isPending(42, 4)).toBe(true);
|
||||
expect(result.current.isAvailable(42, 4)).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
finish(true);
|
||||
expect(await first).toBe(true);
|
||||
});
|
||||
expect(result.current.isPending(42, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a refused write as not done, and a day that has the tour is not even asked', async () => {
|
||||
const onAssignToDay = vi.fn(async () => false);
|
||||
const { result } = renderHook(() => useTourDayAssignment({ canAssign: true, onAssignToDay }));
|
||||
await act(async () => {
|
||||
expect(await result.current.assign(42, 4, 2)).toBe(false);
|
||||
expect(await result.current.assign(42, 3)).toBe(false);
|
||||
});
|
||||
expect(onAssignToDay).toHaveBeenCalledTimes(1);
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(42, 4, 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useTourListFilters } from './useTourListFilters';
|
||||
|
||||
const tour = (over: Partial<TourListItem>): TourListItem =>
|
||||
({
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 5,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
origin: 'trek',
|
||||
...over,
|
||||
}) as TourListItem;
|
||||
|
||||
const tours = [
|
||||
tour({ place_id: 1, origin: 'trek', max_hiking_difficulty: 2, planned: true }),
|
||||
tour({ place_id: 2, origin: 'gpx', max_hiking_difficulty: null }),
|
||||
tour({ place_id: 3, origin: 'wanderer', max_hiking_difficulty: 1 }),
|
||||
tour({ place_id: 4, origin: 'gpx', max_hiking_difficulty: 3, planned: true }),
|
||||
];
|
||||
const ids = (list: readonly TourListItem[]) => list.map((item) => item.place_id);
|
||||
|
||||
describe('useTourListFilters (PR1-13)', () => {
|
||||
it('offers only the dimensions the list varies in, each value with its count', () => {
|
||||
const { result } = renderHook(() => useTourListFilters(tours));
|
||||
expect(result.current.facets.origin).toEqual([
|
||||
{ value: 'trek', count: 1 },
|
||||
{ value: 'gpx', count: 2 },
|
||||
{ value: 'wanderer', count: 1 },
|
||||
]);
|
||||
expect(result.current.facets.grade.map((option) => option.value)).toEqual([1, 2, 3]);
|
||||
// One tour type only: no type filter.
|
||||
expect(result.current.facets.type).toEqual([]);
|
||||
expect(result.current.hasFacets).toBe(true);
|
||||
});
|
||||
|
||||
it('a small list that does not vary keeps its single status filter', () => {
|
||||
const { result } = renderHook(() => useTourListFilters([tour({ place_id: 1 }), tour({ place_id: 2 })]));
|
||||
expect(result.current.hasFacets).toBe(false);
|
||||
});
|
||||
|
||||
it('narrows by status and dimensions together, counts follow, a second choice of a value clears it', () => {
|
||||
const { result } = renderHook(() => useTourListFilters(tours));
|
||||
act(() => result.current.select('origin', 'gpx'));
|
||||
expect(ids(result.current.filtered)).toEqual([2, 4]);
|
||||
expect(result.current.activeFacets).toBe(1);
|
||||
// A GPX import without a grade drops out under a grade, it is not counted as one.
|
||||
act(() => result.current.select('grade', 3));
|
||||
expect(ids(result.current.filtered)).toEqual([4]);
|
||||
act(() => result.current.setStatus('unplanned'));
|
||||
expect(result.current.filtered).toEqual([]);
|
||||
expect(result.current.emptyByFilter).toBe(true);
|
||||
expect(result.current.statusCounts).toEqual({ all: 4, unplanned: 2, planned: 2 });
|
||||
|
||||
act(() => result.current.select('grade', 3));
|
||||
expect(ids(result.current.filtered)).toEqual([2]);
|
||||
act(() => result.current.reset());
|
||||
expect(ids(result.current.filtered)).toEqual([1, 2, 3, 4]);
|
||||
expect(result.current.activeFacets).toBe(0);
|
||||
});
|
||||
|
||||
it('an empty list is empty, not empty by a filter', () => {
|
||||
const { result } = renderHook(() => useTourListFilters([]));
|
||||
expect(result.current.emptyByFilter).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { tourVisibilityScope, useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore';
|
||||
import { useTourMapVisibility } from './useTourMapVisibility';
|
||||
|
||||
const KEY = (userId: number, tripId: number) => `trek:hidden-tours:${tourVisibilityScope(userId, tripId)}`;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
window.localStorage.clear();
|
||||
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
|
||||
seedStore(useAuthStore, { user: { id: 3 } as never });
|
||||
});
|
||||
|
||||
describe('useTourMapVisibility (PR1-07)', () => {
|
||||
it('hides and shows a tour for this person in this trip, and keeps it over a reload', () => {
|
||||
const tours = new Set([10, 11]);
|
||||
const first = renderHook(() => useTourMapVisibility(7, tours));
|
||||
act(() => first.result.current.toggle(10));
|
||||
expect(first.result.current.isHidden(10)).toBe(true);
|
||||
expect(first.result.current.hiddenCount).toBe(1);
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY(3, 7)) ?? '[]')).toEqual([10]);
|
||||
|
||||
// A reload reads it back.
|
||||
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
|
||||
const reloaded = renderHook(() => useTourMapVisibility(7, tours));
|
||||
expect(reloaded.result.current.isHidden(10)).toBe(true);
|
||||
|
||||
act(() => reloaded.result.current.toggle(10));
|
||||
expect(reloaded.result.current.isHidden(10)).toBe(false);
|
||||
expect(window.localStorage.getItem(KEY(3, 7))).toBeNull();
|
||||
});
|
||||
|
||||
it('belongs to one user and one trip only', () => {
|
||||
window.localStorage.setItem(KEY(3, 7), JSON.stringify([10]));
|
||||
expect(renderHook(() => useTourMapVisibility(8, new Set([10]))).result.current.hiddenCount).toBe(0);
|
||||
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
|
||||
seedStore(useAuthStore, { user: { id: 4 } as never });
|
||||
expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('a tour that is gone hides nothing, and show all clears the choice', () => {
|
||||
window.localStorage.setItem(KEY(3, 7), JSON.stringify([10, 99, 'x', -1]));
|
||||
const { result } = renderHook(() => useTourMapVisibility(7, new Set([10, 11])));
|
||||
expect([...result.current.hiddenIds]).toEqual([10]);
|
||||
act(() => result.current.showAll());
|
||||
expect(result.current.hiddenCount).toBe(0);
|
||||
expect(window.localStorage.getItem(KEY(3, 7))).toBeNull();
|
||||
});
|
||||
|
||||
it('a broken stored entry or no signed-in user shows every tour', () => {
|
||||
window.localStorage.setItem(KEY(3, 7), '{not json');
|
||||
expect(renderHook(() => useTourMapVisibility(7, new Set([10]))).result.current.hiddenCount).toBe(0);
|
||||
useTourMapVisibilityStore.setState({ scope: null, hidden: [] });
|
||||
seedStore(useAuthStore, { user: null });
|
||||
const { result } = renderHook(() => useTourMapVisibility(7, new Set([10])));
|
||||
act(() => result.current.toggle(10));
|
||||
expect(result.current.hiddenCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@ 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 { hikeSourceBadgeLabel } from '../../../../components/Tours/tourPresentation'
|
||||
import { ORIGIN_LABEL_KEY } from '../../../../components/Tours/TourListFilterMenu'
|
||||
import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY } from '../../../../components/Tours/tourPresentation'
|
||||
import type { TourListFilters } from '../../../../components/Tours/useTourListFilters'
|
||||
import type { TripPlanner, MTripShellApi } from '../MTripShell'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions'
|
||||
|
||||
@@ -31,7 +31,7 @@ import { getHelpContext } from '../help/registry'
|
||||
import { X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { Tooltip } from '../components/shared/Tooltip'
|
||||
import type { Place, Reservation } from '../types'
|
||||
import type { Reservation } from '../types'
|
||||
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage'
|
||||
import { useBookingExpenseEditor } from './tripPlanner/useBookingExpenseEditor'
|
||||
import { applyTripCoverUpdate } from './tripPlanner/tripCover'
|
||||
@@ -127,7 +127,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
toursEnabled, toursMode, setToursMode, tours, toursLoading, tourDataReady, tourPlaceIds,
|
||||
reloadTourPlaceIds, invalidateTourPlaceIds, selectedTour,
|
||||
invalidateTourPlaceIds, selectedTour,
|
||||
mapTileUrl, fontStyle, splashDone,
|
||||
} = pageState.planner
|
||||
const {
|
||||
|
||||
@@ -17,7 +17,8 @@ interface AdminUsersTabProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
const TH = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const TH_BASE = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const TH = `${TH_BASE} text-start`
|
||||
const TD = 'px-3.5 py-3 align-middle'
|
||||
|
||||
/** An icon action of a row, named by its tooltip and its aria-label. */
|
||||
@@ -74,7 +75,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
<th className={TH}>{t('admin.table.role')}</th>
|
||||
<th className={TH}>{t('admin.table.created')}</th>
|
||||
<th className={TH}>{t('admin.table.lastActive')}</th>
|
||||
<th className={`${TH} text-end`}>{t('admin.table.actions')}</th>
|
||||
<th className={`${TH_BASE} text-center`}>{t('admin.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="trek-stagger divide-y divide-edge-faint">
|
||||
@@ -118,7 +119,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
{formatLastActive(u.last_active ?? u.last_login, locale, hour12)}
|
||||
</td>
|
||||
<td className={TD}>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<RowIconButton label={t('admin.editUser')} onClick={() => handleEditUser(u)}>
|
||||
<Edit2 size={14} strokeWidth={2} />
|
||||
</RowIconButton>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Place } from '../../types';
|
||||
import type { useTripPlanner } from './useTripPlanner';
|
||||
|
||||
/**
|
||||
* What the tour's detail does beyond a place's: a rename reloads the tours,
|
||||
* a reimport from its source check (MF-03) takes the new snapshot in and opens
|
||||
* it, and "save as new tour" (P2C-07) makes the copy and opens it under a name
|
||||
* that says it is one, so renaming it is the next step.
|
||||
*/
|
||||
export function useTourDetailActions(planner: ReturnType<typeof useTripPlanner>) {
|
||||
const { invalidateTourPlaceIds, reloadTourPlaceIds, t, toast, tripActions, tripId, upsertTour } = planner;
|
||||
|
||||
/** A tour that just came in: in the lists and on the map, then open. */
|
||||
const takeIn = async (tour: TourListItem) => {
|
||||
upsertTour(tour);
|
||||
await useTripStore.getState().refreshPlaces(tripId);
|
||||
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
|
||||
planner.handlePlaceClick(tour.place_id);
|
||||
};
|
||||
|
||||
return {
|
||||
onUpdatePlace: async (placeId: number, data: Partial<Place>) => {
|
||||
await tripActions.updatePlace(tripId, placeId, data);
|
||||
await reloadTourPlaceIds();
|
||||
},
|
||||
onSourceReimported: takeIn,
|
||||
onSaveAsNew: async (tour: TourListItem) => {
|
||||
try {
|
||||
const name = t('tours.copy.name', { name: tour.name }).slice(0, 255);
|
||||
const { tour: copy } = await tourRepo.copy(tripId, tour.place_id, name);
|
||||
await takeIn(copy);
|
||||
toast.success(t('tours.copy.saved'));
|
||||
} catch {
|
||||
toast.error(t('tours.copy.failed'));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,9 @@ import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
|
||||
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
|
||||
import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Place } from '../../types';
|
||||
import { useTourDetailActions } from './useTourDetailActions';
|
||||
import type { useTripPlanner } from './useTripPlanner';
|
||||
|
||||
/**
|
||||
@@ -118,37 +117,7 @@ export function useTourWorkspace(
|
||||
},
|
||||
});
|
||||
|
||||
// What the tour's detail does beyond a place's: a rename reloads the tours, a reimport from its
|
||||
// source check takes the new snapshot in and opens it.
|
||||
const tourDetailActions = {
|
||||
onUpdatePlace: async (placeId: number, data: Partial<Place>) => {
|
||||
await tripActions.updatePlace(tripId, placeId, data);
|
||||
await reloadTourPlaceIds();
|
||||
},
|
||||
onSourceReimported: async (tour: TourListItem) => {
|
||||
upsertTour(tour);
|
||||
await useTripStore.getState().refreshPlaces(tripId);
|
||||
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
|
||||
planner.handlePlaceClick(tour.place_id);
|
||||
},
|
||||
// P2C-07: the copy opens at once, under a name that says it is one; renaming it is the next step.
|
||||
onSaveAsNew: async (tour: TourListItem) => {
|
||||
try {
|
||||
const { tour: copy } = await tourRepo.copy(
|
||||
tripId,
|
||||
tour.place_id,
|
||||
t('tours.copy.name', { name: tour.name }).slice(0, 255)
|
||||
);
|
||||
upsertTour(copy);
|
||||
await useTripStore.getState().refreshPlaces(tripId);
|
||||
void invalidateTourPlaceIds({ placeIds: [copy.place_id] });
|
||||
planner.handlePlaceClick(copy.place_id);
|
||||
toast.success(t('tours.copy.saved'));
|
||||
} catch {
|
||||
toast.error(t('tours.copy.failed'));
|
||||
}
|
||||
},
|
||||
};
|
||||
const tourDetailActions = useTourDetailActions(planner);
|
||||
|
||||
return {
|
||||
tourPlanner,
|
||||
|
||||
@@ -243,3 +243,42 @@ describe('replaceTripTours', () => {
|
||||
expect(rows.find(r => r.place_id === 2)!.waypoints).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tourRepo provenance and copies (P2C-02, P2C-07)', () => {
|
||||
const provenance = {
|
||||
origin: 'gpx' as const,
|
||||
createdAt: '2026-07-01 10:00:00',
|
||||
source: { kind: 'gpx' as const, fileName: 'ridge.gpx' },
|
||||
derivedFrom: null,
|
||||
plannedOver: false,
|
||||
editedLocally: null,
|
||||
}
|
||||
|
||||
it('FE-REPO-TOUR-020: the detail caches where the tour came from, offline gives it back, and a list refresh keeps it', async () => {
|
||||
server.use(http.get('/api/trips/3/tours/40', () => HttpResponse.json({ tour: tour(), waypoints: body.waypoints, provenance })))
|
||||
await tourRepo.detail(3, 40)
|
||||
expect((await offlineDb.tours.get(40))?.provenance).toEqual(provenance)
|
||||
|
||||
await replaceTripTours(3, [tour({ name: 'Renamed' })])
|
||||
expect((await offlineDb.tours.get(40))?.provenance).toEqual(provenance)
|
||||
|
||||
setOnline(false)
|
||||
const offline = await tourRepo.detail(3, 40)
|
||||
expect(offline.provenance).toEqual(provenance)
|
||||
// The list item handed out carries none of the cache's own fields.
|
||||
expect(offline.tour).not.toHaveProperty('provenance')
|
||||
})
|
||||
|
||||
it('FE-REPO-TOUR-021: a copy is asked of the server under its name and cached as a tour of the trip', async () => {
|
||||
let sent: unknown = null
|
||||
server.use(http.post('/api/trips/3/tours/40/copy', async ({ request }) => {
|
||||
sent = await request.json()
|
||||
return HttpResponse.json({ tour: tour({ place_id: 41, name: 'Ridge walk (copy)', derived_from_place_id: 40 }), waypoints: body.waypoints }, { status: 201 })
|
||||
}))
|
||||
const result = await tourRepo.copy(3, 40, 'Ridge walk (copy)')
|
||||
expect(sent).toEqual({ name: 'Ridge walk (copy)' })
|
||||
expect(result.tour.place_id).toBe(41)
|
||||
expect(await offlineDb.tours.get(41)).toMatchObject({ trip_id: 3, derived_from_place_id: 40, waypoints: body.waypoints })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/* A row that runs out of room fades at its end instead of ending at a hard edge,
|
||||
e.g. a tour's or a day plan row's chips when the hover actions take their space. */
|
||||
.trek-fade-end {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 1.75rem), transparent);
|
||||
}
|
||||
[dir='rtl'] .trek-fade-end {
|
||||
-webkit-mask-image: linear-gradient(to left, #000 calc(100% - 1.75rem), transparent);
|
||||
mask-image: linear-gradient(to left, #000 calc(100% - 1.75rem), transparent);
|
||||
}
|
||||
@@ -354,7 +354,8 @@ export class ToursService {
|
||||
this.toursRepo.findProvenance(tid, pid),
|
||||
this.placesRepo.findInTrip(pid, tid),
|
||||
]);
|
||||
if (!source || !provenance || !place) throw new NotFoundException('Tour not found');
|
||||
// A tour without a line has nothing to copy.
|
||||
if (!source || !provenance || !place?.route_geometry) throw new NotFoundException('Tour not found');
|
||||
const start = parseRouteGeometry(place.route_geometry)[0];
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: tid,
|
||||
|
||||
Reference in New Issue
Block a user