mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(tours): take a tour over from another trip, the dialog (LIB-02)
The Tours tab's list of the trip's tours gains a button beside the GPX import: take a tour over from another trip. Its dialog lists the tours of the user's other trips, searched by name and filtered by trip and type, each with its trip and the measures it has (time, distance, ascent; one it lacks is left out, a paddle tour's climb too). Choosing one shows it on a map with where it comes from, its type, distance, time, heights and number of waypoints, and the name it gets in this trip, its own name to start with. The note under the name says the copy is a tour of this trip of its own, on no day yet, and that later changes stay apart. Only the button in the foot takes it over. The new tour then comes into the trip's lists and map and opens in the editor, unless the editor holds unsaved work, which it never replaces. Nothing is written before the confirm, and closing writes nothing. The library is online only: offline, or when the list fails, the dialog says so and offers to try again; a list cut at 100 tours says to search or filter. A tour taken over names its source in its provenance: "Taken over from" with the trip and the tour as they were called then.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
TourCreateResponse,
|
||||
TourLibraryDetailResponse,
|
||||
TourLibraryQuery,
|
||||
TourLibraryResponse,
|
||||
TourTakeOverRequest,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** LIB-02: the tours of the user's other trips, one's preview, and taking one over into a trip. */
|
||||
export const tourLibraryApi = {
|
||||
list: (query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> =>
|
||||
apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data),
|
||||
detail: (placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> =>
|
||||
apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data),
|
||||
takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { TourLibraryItem, TourListItem, TourTypeKey } from '@trek/shared';
|
||||
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { OFM_POSITRON } from '../../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { MapViewAuto } from '../../Map/MapViewAuto';
|
||||
import { BarButton } from '../../Planner/planParts';
|
||||
import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { TourDayRowPill } from '../TourDayRowFacts';
|
||||
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
|
||||
import { useTourLibrary, type TourLibrary } from './useTourLibrary';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
type Unit = Parameters<typeof formatDistance>[1];
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
|
||||
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
|
||||
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
|
||||
return [
|
||||
minutes != null && {
|
||||
key: 'time',
|
||||
icon: Clock,
|
||||
value: formatPlannedTourDuration(minutes),
|
||||
label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`,
|
||||
},
|
||||
tour.distance != null && {
|
||||
key: 'distance',
|
||||
icon: Ruler,
|
||||
value: formatDistance(tour.distance, unit),
|
||||
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
|
||||
},
|
||||
tour.elevation_gain != null &&
|
||||
tour.tour_type !== 'kayak' && {
|
||||
key: 'ascent',
|
||||
icon: ArrowUp,
|
||||
value: formatElevation(tour.elevation_gain, unit),
|
||||
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
|
||||
},
|
||||
].filter((badge) => !!badge);
|
||||
}
|
||||
|
||||
/** One tour of the list: its name, the trip it is in, its measures. */
|
||||
function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChoose}
|
||||
aria-pressed={selected}
|
||||
data-testid={`tour-library-row-${tour.place_id}`}
|
||||
className={`flex w-full min-w-0 flex-col gap-1 rounded-[10px] px-2.5 py-2 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{tour.name}
|
||||
</span>
|
||||
<span className="flex-none text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-content-muted" style={fs(11, 'body')}>
|
||||
{tour.trip.title}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{measureBadges(tour, unit, t).map((badge) => (
|
||||
<TourDayRowPill
|
||||
key={badge.key}
|
||||
icon={badge.icon}
|
||||
value={badge.value}
|
||||
label={badge.label}
|
||||
testId={`tour-library-${badge.key}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** A line of the list's state: loading, offline, failed with a retry, empty, or cut short. */
|
||||
function ListState({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const { listStatus, list } = library;
|
||||
if (listStatus === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.library.offline')}</TourNotice>;
|
||||
if (listStatus === 'error')
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{t('tours.library.loadFailed')}
|
||||
<button type="button" onClick={library.retry} className="font-semibold underline underline-offset-2">
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
if (listStatus === 'loading' && !list)
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 py-4 text-content-faint" style={fs(12, 'body')}>
|
||||
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
if (list && list.tours.length === 0)
|
||||
return (
|
||||
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
|
||||
{list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')}
|
||||
</p>
|
||||
);
|
||||
if (list?.truncated) return <TourNotice>{t('tours.library.truncated')}</TourNotice>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The search box and the two filters over the list. */
|
||||
function ListFilters({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const searchId = useId();
|
||||
const trips = library.list?.trips ?? [];
|
||||
const tripOptions = [
|
||||
{ value: ALL, label: t('tours.library.allTrips') },
|
||||
...trips.map((trip) => ({ value: String(trip.id), label: trip.title, hint: String(trip.tour_count) })),
|
||||
];
|
||||
const typeOptions = [
|
||||
{ value: ALL, label: t('tours.library.allTypes') },
|
||||
...(['hike', 'kayak'] as const).map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-2">
|
||||
<Search size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
<label htmlFor={searchId} className="sr-only">
|
||||
{t('tours.library.search')}
|
||||
</label>
|
||||
<input
|
||||
id={searchId}
|
||||
autoFocus
|
||||
value={library.filters.query}
|
||||
onChange={(event) => library.filter({ query: event.target.value })}
|
||||
placeholder={t('tours.library.search')}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
|
||||
style={fs(12.5, 'body')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.library.trip')}
|
||||
value={library.filters.tripFilter === null ? ALL : String(library.filters.tripFilter)}
|
||||
options={tripOptions}
|
||||
onChange={(value) => library.filter({ tripFilter: value === ALL ? null : Number(value) })}
|
||||
/>
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.filter.type')}
|
||||
value={library.filters.typeFilter ?? ALL}
|
||||
options={typeOptions}
|
||||
onChange={(value) => library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
|
||||
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
|
||||
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
|
||||
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
|
||||
return [
|
||||
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
|
||||
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
|
||||
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
|
||||
fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)),
|
||||
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
|
||||
formatElevation(tour.elevation_gain ?? 0, unit)
|
||||
),
|
||||
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
|
||||
formatElevation(tour.elevation_loss ?? 0, unit)
|
||||
),
|
||||
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
|
||||
].filter((field): field is TourMetricField => field !== null);
|
||||
}
|
||||
|
||||
/** The chosen tour: on a map, its facts, and the name the copy gets. */
|
||||
function Preview({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const nameId = useId();
|
||||
const line = library.preview?.line;
|
||||
const route = useMemo(
|
||||
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
|
||||
() =>
|
||||
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
|
||||
[line]
|
||||
);
|
||||
const { selected } = library;
|
||||
if (!selected)
|
||||
return (
|
||||
<div className="grid h-full min-h-[240px] place-items-center rounded-[14px] border border-dashed border-edge px-6 text-center">
|
||||
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('tours.library.chooseHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3" data-testid="tour-library-preview">
|
||||
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
|
||||
{route.length > 0 && (
|
||||
<MapViewAuto
|
||||
places={[]}
|
||||
route={route}
|
||||
tileUrl={tileUrl}
|
||||
focusPoints={route[0]}
|
||||
focusKey={String(selected.place_id)}
|
||||
followSelection={false}
|
||||
/>
|
||||
)}
|
||||
{library.previewStatus === 'loading' && (
|
||||
<span className="absolute inset-0 grid place-items-center text-content-faint">
|
||||
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
{(library.previewStatus === 'error' || library.previewStatus === 'offline') && (
|
||||
<span
|
||||
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RailFacts facts={previewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t)} />
|
||||
<EditorField label={t('tours.library.nameHere')} htmlFor={nameId} hint={t('tours.library.independent')}>
|
||||
<input
|
||||
id={nameId}
|
||||
className={INPUT}
|
||||
value={library.name}
|
||||
maxLength={255}
|
||||
onChange={(event) => library.setName(event.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: the dialog to take a tour of another trip over into this one. The
|
||||
* list on the left, the chosen tour on the right; the copy is made only by the
|
||||
* button in its foot.
|
||||
*/
|
||||
export function TourLibraryDialog({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
if (!library.open) return null;
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={library.close}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
align="top"
|
||||
blocked={library.taking}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Library size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={library.close}
|
||||
eyebrow={t('tours.library.eyebrow')}
|
||||
title={t('tours.library.title')}
|
||||
sub={t('tours.library.sub')}
|
||||
subWraps
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={library.close} disabled={library.taking}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
data-testid="tour-library-confirm"
|
||||
icon={
|
||||
library.taking ? (
|
||||
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
|
||||
) : (
|
||||
<CopyPlus size={14} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
disabled={!library.canConfirm}
|
||||
onClick={() => void library.confirm()}
|
||||
>
|
||||
{t('tours.library.confirm')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="grid min-h-0 grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
|
||||
<section aria-label={t('tours.library.title')} className="flex min-w-0 flex-col gap-3">
|
||||
<ListFilters library={library} />
|
||||
<ListState library={library} />
|
||||
{library.list && library.list.tours.length > 0 && (
|
||||
<ul
|
||||
className={`m-0 flex max-h-[420px] list-none flex-col gap-0.5 overflow-y-auto p-0 transition-opacity ${library.listStatus === 'loading' ? 'opacity-60' : ''}`}
|
||||
data-testid="tour-library-list"
|
||||
>
|
||||
{library.list.tours.map((tour) => (
|
||||
<LibraryRow
|
||||
key={tour.place_id}
|
||||
tour={tour}
|
||||
selected={library.selected?.place_id === tour.place_id}
|
||||
onChoose={() => library.choose(tour)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<Preview library={library} />
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rail head's way in, beside the GPX import: a button, and the dialog it opens. */
|
||||
export function TourLibraryButton({ library, disabled }: { library: TourLibrary; disabled?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<BarButton
|
||||
label={t('tours.library.open')}
|
||||
testId="tour-rail-library"
|
||||
disabled={disabled}
|
||||
onClick={library.show}
|
||||
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Library size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<TourLibraryDialog library={library} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours rail's way in, with the dialog's state: a tour taken over comes
|
||||
* into the trip and opens in the editor, unless the editor holds unsaved work.
|
||||
*/
|
||||
export function TourLibraryEntry({
|
||||
planner,
|
||||
onTakenOver,
|
||||
disabled,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'tripId' | 'hasUnsavedChanges' | 'openTour'>;
|
||||
/** Where the tour goes into the trip; without it there is no way in. */
|
||||
onTakenOver?: (tour: TourListItem) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const library = useTourLibrary({
|
||||
tripId: Number(planner.tripId),
|
||||
onTakenOver: async (tour) => {
|
||||
await onTakenOver?.(tour);
|
||||
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
|
||||
},
|
||||
});
|
||||
return onTakenOver ? <TourLibraryButton library={library} disabled={disabled} /> : null;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourCreateResponse, TourLibraryItem, TourLibraryResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { TourLibraryOfflineError, tourLibraryRepo } from '../../../repo/tourLibraryRepo';
|
||||
import { TourLibraryButton } from './TourLibraryDialog';
|
||||
import { useTourLibrary } from './useTourLibrary';
|
||||
|
||||
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
|
||||
|
||||
vi.mock('../../Map/MapViewAuto', () => ({
|
||||
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
|
||||
<div data-testid="library-map" data-points={route[0]?.length ?? 0} />
|
||||
),
|
||||
}));
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
function item(over: Partial<TourLibraryItem> & Pick<TourLibraryItem, 'place_id' | 'name'>): TourLibraryItem {
|
||||
return {
|
||||
description: null,
|
||||
tour_type: 'hike',
|
||||
distance: 8.2,
|
||||
elevation_gain: 600,
|
||||
elevation_loss: 590,
|
||||
duration: 180,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
max_hiking_difficulty: 2,
|
||||
has_waypoints: true,
|
||||
origin: 'trek',
|
||||
trip: { id: 2, title: 'Alps' },
|
||||
...over,
|
||||
} as TourLibraryItem;
|
||||
}
|
||||
|
||||
const RIDGE = item({ place_id: 11, name: 'Ridge' });
|
||||
const LAKE = item({
|
||||
place_id: 12,
|
||||
name: 'Lake loop',
|
||||
tour_type: 'kayak',
|
||||
distance: null,
|
||||
elevation_gain: 40,
|
||||
trip: { id: 3, title: 'Lakes' },
|
||||
});
|
||||
const LIST: TourLibraryResponse = {
|
||||
tours: [RIDGE, LAKE],
|
||||
trips: [
|
||||
{ id: 3, title: 'Lakes', tour_count: 1 },
|
||||
{ id: 2, title: 'Alps', tour_count: 1 },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
let list: ReturnType<typeof vi.spyOn>;
|
||||
let detail: ReturnType<typeof vi.spyOn>;
|
||||
let takeOver: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
toast.success.mockReset();
|
||||
toast.error.mockReset();
|
||||
list = vi.spyOn(tourLibraryRepo, 'list').mockResolvedValue(LIST);
|
||||
detail = vi.spyOn(tourLibraryRepo, 'detail').mockResolvedValue({
|
||||
tour: RIDGE,
|
||||
line: [
|
||||
[47, 11],
|
||||
[47.1, 11.1],
|
||||
],
|
||||
waypoint_count: 4,
|
||||
});
|
||||
takeOver = vi.spyOn(tourLibraryRepo, 'takeOver').mockResolvedValue({
|
||||
tour: { place_id: 99, name: 'My ridge' },
|
||||
waypoints: [],
|
||||
} as unknown as TourCreateResponse);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('useTourLibrary', () => {
|
||||
it('FE-TOUR-LIBRARY-001: asks nothing until opened, then lists the other trips’ tours, the asking trip left out', async () => {
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
act(() => result.current.show());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
|
||||
expect(list).toHaveBeenCalledWith({ exclude_trip_id: 5 }, expect.any(AbortSignal));
|
||||
expect(result.current.list?.tours).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-002: narrows by name after a pause, and by trip and type at once', async () => {
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
|
||||
act(() => result.current.show());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
|
||||
act(() => result.current.filter({ query: ' rid ', tripFilter: 2, typeFilter: 'hike' }));
|
||||
expect(result.current.listStatus).toBe('loading');
|
||||
await waitFor(() =>
|
||||
expect(list).toHaveBeenLastCalledWith(
|
||||
{ q: 'rid', trip_id: 2, type: 'hike', exclude_trip_id: 5 },
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-003: says when it is offline or failed, and asks again on retry', async () => {
|
||||
list.mockRejectedValueOnce(new TourLibraryOfflineError()).mockRejectedValueOnce(new Error('500'));
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
|
||||
act(() => result.current.show());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('offline'));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('error'));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-004: previews the tour chosen and takes it over under the name given, then hands it in', async () => {
|
||||
const onTakenOver = vi.fn();
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.choose(RIDGE));
|
||||
expect(result.current.name).toBe('Ridge');
|
||||
await waitFor(() => expect(result.current.previewStatus).toBe('ready'));
|
||||
expect(detail).toHaveBeenCalledWith(11, expect.any(AbortSignal));
|
||||
act(() => result.current.setName(' My ridge '));
|
||||
await act(() => result.current.confirm());
|
||||
expect(takeOver).toHaveBeenCalledWith(5, { source_trip_id: 2, source_place_id: 11, name: 'My ridge' });
|
||||
expect(onTakenOver).toHaveBeenCalledWith(expect.objectContaining({ place_id: 99 }));
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-005: nothing is taken over without a name, and a failed take-over keeps the dialog', async () => {
|
||||
takeOver.mockRejectedValueOnce(new Error('500'));
|
||||
const onTakenOver = vi.fn();
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.choose(RIDGE));
|
||||
act(() => result.current.setName(' '));
|
||||
expect(result.current.canConfirm).toBe(false);
|
||||
await act(() => result.current.confirm());
|
||||
expect(takeOver).not.toHaveBeenCalled();
|
||||
act(() => result.current.setName('Ridge'));
|
||||
await act(() => result.current.confirm());
|
||||
expect(onTakenOver).not.toHaveBeenCalled();
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourLibraryDialog', () => {
|
||||
function Harness({ onTakenOver = vi.fn() }: { onTakenOver?: () => void }) {
|
||||
const library = useTourLibrary({ tripId: 5, onTakenOver });
|
||||
return <TourLibraryButton library={library} />;
|
||||
}
|
||||
|
||||
it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
const ridge = await screen.findByTestId('tour-library-row-11');
|
||||
expect(ridge).toHaveTextContent('Ridge');
|
||||
expect(ridge).toHaveTextContent('Alps');
|
||||
expect(within(ridge).getByTestId('tour-library-distance')).toHaveTextContent('8.2 km');
|
||||
const lake = screen.getByTestId('tour-library-row-12');
|
||||
// No distance known, and a paddle tour's climb says nothing: neither is shown.
|
||||
expect(within(lake).queryByTestId('tour-library-distance')).toBeNull();
|
||||
expect(within(lake).queryByTestId('tour-library-ascent')).toBeNull();
|
||||
expect(screen.getByTestId('tour-library-confirm')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-007: choosing a tour shows it on a map with its facts and the name field; the button takes it over', async () => {
|
||||
const onTakenOver = vi.fn();
|
||||
render(<Harness onTakenOver={onTakenOver} />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
fireEvent.click(await screen.findByTestId('tour-library-row-11'));
|
||||
const preview = screen.getByTestId('tour-library-preview');
|
||||
expect(await within(preview).findByTestId('library-map')).toHaveAttribute('data-points', '2');
|
||||
expect(preview).toHaveTextContent('Alps');
|
||||
expect(preview).toHaveTextContent('4');
|
||||
expect(within(preview).getByDisplayValue('Ridge')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('tour-library-confirm'));
|
||||
await waitFor(() => expect(onTakenOver).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
|
||||
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
expect(await screen.findByText('Your other trips have no tours yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type {
|
||||
TourLibraryDetailResponse,
|
||||
TourLibraryItem,
|
||||
TourLibraryResponse,
|
||||
TourListItem,
|
||||
TourTypeKey,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { TourLibraryOfflineError, tourLibraryRepo } from '../../../repo/tourLibraryRepo';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/** How long typing rests before the list is asked again. */
|
||||
const SEARCH_PAUSE_MS = 250;
|
||||
|
||||
type LoadStatus = 'loading' | 'ready' | 'error' | 'offline';
|
||||
|
||||
/** An answer with the request it belongs to, so a newer request reads as loading, never as an older answer. */
|
||||
interface Answer<T> {
|
||||
key: string;
|
||||
status: Exclude<LoadStatus, 'loading'>;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
const failure = (err: unknown): 'error' | 'offline' => (err instanceof TourLibraryOfflineError ? 'offline' : 'error');
|
||||
|
||||
interface ListFilters {
|
||||
query: string;
|
||||
tripFilter: number | null;
|
||||
typeFilter: TourTypeKey | null;
|
||||
}
|
||||
|
||||
/** The list while the dialog is open: asked at once on opening, then after a pause in typing; the asking trip left out. */
|
||||
function useLibraryList(
|
||||
open: boolean,
|
||||
tripId: number,
|
||||
{ query, tripFilter, typeFilter }: ListFilters,
|
||||
attempt: number
|
||||
) {
|
||||
const [answer, setAnswer] = useState<Answer<TourLibraryResponse> | null>(null);
|
||||
const askedRef = useRef(false);
|
||||
const q = query.trim();
|
||||
const key = JSON.stringify([q, tripFilter, typeFilter, attempt]);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
askedRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const ask = () =>
|
||||
tourLibraryRepo
|
||||
.list(
|
||||
{
|
||||
q: q || undefined,
|
||||
trip_id: tripFilter ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
exclude_trip_id: tripId,
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
.then((data) => setAnswer({ key, status: 'ready', data }))
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
|
||||
});
|
||||
const timer = window.setTimeout(() => void ask(), askedRef.current ? SEARCH_PAUSE_MS : 0);
|
||||
askedRef.current = true;
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, key, q, tripFilter, typeFilter, tripId]);
|
||||
return {
|
||||
status: (answer?.key === key ? answer.status : 'loading') as LoadStatus,
|
||||
/** The last list that came in, kept while a newer one loads so the rows do not flicker away. */
|
||||
data: answer?.data ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The preview of the tour chosen, read anew for each choice. */
|
||||
function useLibraryPreview(open: boolean, selected: TourLibraryItem | null, attempt: number) {
|
||||
const [answer, setAnswer] = useState<Answer<TourLibraryDetailResponse> | null>(null);
|
||||
const placeId = selected?.place_id ?? null;
|
||||
const key = `${placeId ?? ''}:${attempt}`;
|
||||
useEffect(() => {
|
||||
if (!open || placeId === null) return undefined;
|
||||
const controller = new AbortController();
|
||||
tourLibraryRepo
|
||||
.detail(placeId, controller.signal)
|
||||
.then((data) => setAnswer({ key, status: 'ready', data }))
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [open, placeId, key]);
|
||||
const status: LoadStatus | null = placeId === null ? null : answer?.key === key ? answer.status : 'loading';
|
||||
return { status, data: status === 'ready' ? (answer?.data ?? null) : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: taking a tour of another trip over into this one. The dialog lists
|
||||
* the tours of the user's other trips, searched by name and filtered by trip
|
||||
* and type, previews the one chosen, and takes it over only on an explicit
|
||||
* confirm, under the name in the field. Nothing is written before that, and
|
||||
* closing writes nothing. The new tour then comes into the trip through
|
||||
* `onTakenOver`, which also opens it.
|
||||
*/
|
||||
export function useTourLibrary({
|
||||
tripId,
|
||||
onTakenOver,
|
||||
}: {
|
||||
tripId: number;
|
||||
onTakenOver: (tour: TourListItem) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>({ query: '', tripFilter: null, typeFilter: null });
|
||||
const [selected, setSelected] = useState<TourLibraryItem | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [taking, setTaking] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const list = useLibraryList(open, tripId, filters, attempt);
|
||||
const preview = useLibraryPreview(open, selected, attempt);
|
||||
|
||||
const reset = () => {
|
||||
setOpen(false);
|
||||
setSelected(null);
|
||||
setFilters({ query: '', tripFilter: null, typeFilter: null });
|
||||
};
|
||||
const close = () => {
|
||||
if (!taking) reset();
|
||||
};
|
||||
const choose = (tour: TourLibraryItem) => {
|
||||
setSelected(tour);
|
||||
setName(tour.name);
|
||||
};
|
||||
const filter = (change: Partial<ListFilters>) => setFilters((current) => ({ ...current, ...change }));
|
||||
|
||||
const confirm = async () => {
|
||||
const chosenName = name.trim();
|
||||
if (!selected || !chosenName || taking) return;
|
||||
setTaking(true);
|
||||
try {
|
||||
const result = await tourLibraryRepo.takeOver(tripId, {
|
||||
source_trip_id: selected.trip.id,
|
||||
source_place_id: selected.place_id,
|
||||
name: chosenName,
|
||||
});
|
||||
setTaking(false);
|
||||
reset();
|
||||
await onTakenOver(result.tour);
|
||||
toast.success(t('tours.library.taken', { name: result.tour.name }));
|
||||
} catch (err: unknown) {
|
||||
setTaking(false);
|
||||
toast.error(t(failure(err) === 'offline' ? 'tours.library.offline' : 'tours.library.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
show: useCallback(() => setOpen(true), []),
|
||||
close,
|
||||
filters,
|
||||
filter,
|
||||
listStatus: list.status,
|
||||
list: list.data,
|
||||
selected,
|
||||
choose,
|
||||
previewStatus: preview.status,
|
||||
preview: preview.data,
|
||||
name,
|
||||
setName,
|
||||
taking,
|
||||
canConfirm: !!selected && name.trim().length > 0 && !taking,
|
||||
confirm,
|
||||
retry: () => setAttempt((value) => value + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export type TourLibrary = ReturnType<typeof useTourLibrary>;
|
||||
@@ -43,6 +43,7 @@ import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisi
|
||||
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
|
||||
import { TourScopeSummary } from '../TourScopeSummary'
|
||||
import { useTourListFilters } from '../useTourListFilters'
|
||||
import { TourLibraryEntry } from '../library/TourLibraryDialog'
|
||||
|
||||
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
dirty: 'tours.planner.status.dirty',
|
||||
@@ -609,6 +610,8 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
|
||||
onDeleteTour?: (placeId: number) => void
|
||||
/** The workspace's GPX import; its button sits beside "New tour". */
|
||||
gpxImport?: TourGpxImport
|
||||
/** LIB-02: takes a tour of another trip in; its button sits beside the import. */
|
||||
onTakenOver?: (tour: TourListItem) => Promise<void>
|
||||
}
|
||||
|
||||
/** The time facts of the overview: the estimate (walking, or paddling for a paddle tour), the breaks and the planned total. */
|
||||
@@ -641,7 +644,7 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate) {
|
||||
}
|
||||
|
||||
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
|
||||
gpxImport, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
|
||||
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
|
||||
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -693,6 +696,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
|
||||
<RailTitle title={t('tours.planner.tripTours')} />
|
||||
{canEdit && gpxImport && <RailImportButton gpxImport={gpxImport} disabled={isSaving} />}
|
||||
{canEdit && <TourLibraryEntry planner={planner} onTakenOver={onTakenOver} disabled={isSaving} />}
|
||||
{canEdit && planner.mode.type !== 'neutral' && (
|
||||
<BarButton label={t('tours.planner.newTour')} testId="tour-rail-new-tour" disabled={isSaving}
|
||||
onClick={() => { if (!isSaving) planner.startNewTour() }}
|
||||
|
||||
@@ -39,6 +39,16 @@ describe('TourProvenanceSection (P2C-02, MF-03)', () => {
|
||||
expect(screen.queryByRole('button', { name: /tours.provenance.check/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('names the trip and tour a tour was taken over from, and nothing for one that was not (LIB-02)', () => {
|
||||
const { rerender } = render(
|
||||
<TourProvenanceSection provenance={{ ...base, takenOverFrom: { tripTitle: 'Alps', tourName: 'Ridge' } }} />
|
||||
);
|
||||
expect(screen.getByText('tours.provenance.takenOverFrom')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-provenance-taken-over')).toHaveTextContent('Alps: Ridge');
|
||||
rerender(<TourProvenanceSection provenance={{ ...base, takenOverFrom: null }} />);
|
||||
expect(screen.queryByTestId('tour-provenance-taken-over')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a GPX import with its file, and that it was planned over keeping its track', () => {
|
||||
render(
|
||||
<TourProvenanceSection
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem, TourProvenance, TourSourceCheckResponse } from '@trek/shared';
|
||||
import { Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
|
||||
import { CopyPlus, Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
@@ -224,6 +224,16 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena
|
||||
<ProvenanceRow icon={sourceIcon} label={t('tours.provenance.source')}>
|
||||
<SourceValue provenance={provenance} t={t} />
|
||||
</ProvenanceRow>
|
||||
{provenance.takenOverFrom && (
|
||||
// LIB-02: the trip and tour it was taken from, as they were called then.
|
||||
<ProvenanceRow icon={<CopyPlus size={13} strokeWidth={2} />} label={t('tours.provenance.takenOverFrom')}>
|
||||
<span data-testid="tour-provenance-taken-over" className="min-w-0 truncate">
|
||||
<span className="font-semibold">{provenance.takenOverFrom.tripTitle}</span>
|
||||
{': '}
|
||||
{provenance.takenOverFrom.tourName}
|
||||
</span>
|
||||
</ProvenanceRow>
|
||||
)}
|
||||
{importedAt && (
|
||||
<ProvenanceRow
|
||||
icon={<Download size={13} strokeWidth={2} />}
|
||||
|
||||
@@ -687,6 +687,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onDeleteTour={handleDeleteTour}
|
||||
gpxImport={tourGpxImport}
|
||||
onViewGpxTour={viewImportedTour}
|
||||
onTakenOver={tourDetailActions.onTakenOver}
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
|
||||
@@ -13,11 +13,15 @@ import type { useTripPlanner } from './useTripPlanner';
|
||||
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) => {
|
||||
/** A tour that just came in: in the lists and on the map. */
|
||||
const bringIn = async (tour: TourListItem) => {
|
||||
upsertTour(tour);
|
||||
await useTripStore.getState().refreshPlaces(tripId);
|
||||
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
|
||||
};
|
||||
/** ...and its detail open. */
|
||||
const takeIn = async (tour: TourListItem) => {
|
||||
await bringIn(tour);
|
||||
planner.handlePlaceClick(tour.place_id);
|
||||
};
|
||||
|
||||
@@ -27,6 +31,8 @@ export function useTourDetailActions(planner: ReturnType<typeof useTripPlanner>)
|
||||
await reloadTourPlaceIds();
|
||||
},
|
||||
onSourceReimported: takeIn,
|
||||
/** LIB-02: a tour taken over from another trip; the tours rail opens it in the planner. */
|
||||
onTakenOver: bringIn,
|
||||
onSaveAsNew: async (tour: TourListItem) => {
|
||||
try {
|
||||
const name = t('tours.copy.name', { name: tour.name }).slice(0, 255);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TourCreateResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tourLibraryApi } from '../api/tourLibrary';
|
||||
import { TourLibraryOfflineError, tourLibraryRepo } from './tourLibraryRepo';
|
||||
|
||||
// FE-REPO-TOUR-LIBRARY-001 to FE-REPO-TOUR-LIBRARY-002
|
||||
|
||||
const network = vi.hoisted(() => ({ offline: false }));
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
|
||||
const cache = vi.hoisted(() => ({ cacheTourAnswer: vi.fn() }));
|
||||
vi.mock('../db/cacheTour', () => cache);
|
||||
|
||||
beforeEach(() => {
|
||||
network.offline = false;
|
||||
cache.cacheTourAnswer.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('tourLibraryRepo', () => {
|
||||
it('FE-REPO-TOUR-LIBRARY-001: reads and takes over only online, never from a cache', async () => {
|
||||
network.offline = true;
|
||||
const list = vi.spyOn(tourLibraryApi, 'list');
|
||||
await expect(tourLibraryRepo.list({})).rejects.toBeInstanceOf(TourLibraryOfflineError);
|
||||
await expect(tourLibraryRepo.detail(3)).rejects.toBeInstanceOf(TourLibraryOfflineError);
|
||||
await expect(tourLibraryRepo.takeOver(5, { source_trip_id: 2, source_place_id: 3 })).rejects.toBeInstanceOf(
|
||||
TourLibraryOfflineError
|
||||
);
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-REPO-TOUR-LIBRARY-002: a tour taken over goes into this trip’s cache', async () => {
|
||||
const answer = { tour: { place_id: 9 }, waypoints: [] } as unknown as TourCreateResponse;
|
||||
vi.spyOn(tourLibraryApi, 'takeOver').mockResolvedValue(answer);
|
||||
vi.spyOn(tourLibraryApi, 'detail').mockResolvedValue({ tour: {} as never, line: [], waypoint_count: 0 });
|
||||
expect(await tourLibraryRepo.takeOver('5', { source_trip_id: 2, source_place_id: 3 })).toBe(answer);
|
||||
expect(cache.cacheTourAnswer).toHaveBeenCalledWith(5, answer);
|
||||
expect((await tourLibraryRepo.detail(3)).waypoint_count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
TourCreateResponse,
|
||||
TourLibraryDetailResponse,
|
||||
TourLibraryQuery,
|
||||
TourLibraryResponse,
|
||||
TourTakeOverRequest,
|
||||
} from '@trek/shared';
|
||||
import { tourLibraryApi } from '../api/tourLibrary';
|
||||
import { cacheTourAnswer } from '../db/cacheTour';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/** What a library call answers while there is no connection: nothing read, and the view says why. */
|
||||
export class TourLibraryOfflineError extends Error {
|
||||
constructor() {
|
||||
super('The tour library needs a connection');
|
||||
this.name = 'TourLibraryOfflineError';
|
||||
}
|
||||
}
|
||||
|
||||
function requireOnline(): void {
|
||||
if (isEffectivelyOffline()) throw new TourLibraryOfflineError();
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02, online only on purpose: the list reads across trips this device may
|
||||
* never have cached and whose access the server decides at the moment it is
|
||||
* asked, so an offline copy could show a tour of a trip the user has left.
|
||||
* The take-over is made from the server's copy of the source, like the in-trip
|
||||
* copy, and the new tour goes into this trip's cache once it exists.
|
||||
*/
|
||||
export const tourLibraryRepo = {
|
||||
async list(query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> {
|
||||
requireOnline();
|
||||
return await tourLibraryApi.list(query, signal);
|
||||
},
|
||||
|
||||
async detail(placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> {
|
||||
requireOnline();
|
||||
return await tourLibraryApi.detail(placeId, signal);
|
||||
},
|
||||
|
||||
async takeOver(tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> {
|
||||
requireOnline();
|
||||
const result = await tourLibraryApi.takeOver(tripId, body);
|
||||
await cacheTourAnswer(Number(tripId), result);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user