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:
Maurice
2026-10-15 23:08:46 +02:00
parent a0884685ef
commit 845979902c
25 changed files with 810 additions and 113 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
-1
View File
@@ -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
+63 -38
View File
@@ -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'
+2 -2
View File
@@ -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 {
+4 -3
View File
@@ -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,
+39
View File
@@ -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 })
})
})
+10
View File
@@ -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);
}
+2 -1
View File
@@ -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,