mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(tours): the checklist keeps to its tour, its state and its focus
A decision answered after another tour was opened no longer replaces that tour's checklist. A decision made offline on a checklist already older than the route stays marked as such, and once online a bench from a write made offline waits for the queue to replay, then takes in the server's. While the checklist reads again it stays in view, locked, so a panel open on the map keeps its note. A saved tour turned into a paddle tour asks to be saved first, a vanished obstacle stays among the rows, Next follows the list order and keeps the focus in the panel, a note closes only once saved, the earlier choice in review is named for screen readers too, planning a portage is offered to editors only, and the map card scrolls within the screen. A tooltip of several lines breaks at them. A page test waits for the tours row instead of reading it at once.
This commit is contained in:
@@ -7,7 +7,7 @@ import type { ObstacleDecision, ObstacleDecisionRecord, TourObstacleCheck, TourO
|
|||||||
|
|
||||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||||
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
|
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
|
||||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
|
||||||
import { seedStore } from '../../../../tests/helpers/store';
|
import { seedStore } from '../../../../tests/helpers/store';
|
||||||
import { useSettingsStore } from '../../../store/settingsStore';
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
import { ChecklistObstaclePanel, ObstacleDecisionPanel } from './ObstacleDecisionPanel';
|
import { ChecklistObstaclePanel, ObstacleDecisionPanel } from './ObstacleDecisionPanel';
|
||||||
@@ -52,7 +52,7 @@ function decidedEntry(decision: ObstacleDecision = 'pass', note: string | null =
|
|||||||
type PanelProps = ComponentProps<typeof ObstacleDecisionPanel>;
|
type PanelProps = ComponentProps<typeof ObstacleDecisionPanel>;
|
||||||
|
|
||||||
const onDecide = vi.fn<PanelProps['onDecide']>();
|
const onDecide = vi.fn<PanelProps['onDecide']>();
|
||||||
const onNote = vi.fn<PanelProps['onNote']>();
|
const onNote = vi.fn<PanelProps['onNote']>(() => Promise.resolve(true));
|
||||||
const onClear = vi.fn<PanelProps['onClear']>();
|
const onClear = vi.fn<PanelProps['onClear']>();
|
||||||
const onKeep = vi.fn<PanelProps['onKeep']>();
|
const onKeep = vi.fn<PanelProps['onKeep']>();
|
||||||
/** Trip member 1 is Anna; anyone else has left the trip. */
|
/** Trip member 1 is Anna; anyone else has left the trip. */
|
||||||
@@ -98,6 +98,7 @@ function checklistOf(
|
|||||||
stale: false,
|
stale: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
canDecide: true,
|
canDecide: true,
|
||||||
|
canPlanPortage: true,
|
||||||
savingKey: null,
|
savingKey: null,
|
||||||
selectedKey: null,
|
selectedKey: null,
|
||||||
dialogKey: null,
|
dialogKey: null,
|
||||||
@@ -108,10 +109,10 @@ function checklistOf(
|
|||||||
nextOf: () => null,
|
nextOf: () => null,
|
||||||
next: vi.fn<PaddleChecklist['next']>(),
|
next: vi.fn<PaddleChecklist['next']>(),
|
||||||
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
|
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
|
||||||
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve()),
|
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
|
||||||
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve()),
|
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
|
||||||
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve()),
|
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
|
||||||
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve()),
|
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
|
||||||
memberName,
|
memberName,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -166,7 +167,8 @@ describe('ObstacleDecisionPanel', () => {
|
|||||||
it('FE-PADDLE-DECIDE-003: in review the earlier choice keeps a dashed ring, nothing is checked, the reason shows', () => {
|
it('FE-PADDLE-DECIDE-003: in review the earlier choice keeps a dashed ring, nothing is checked, the reason shows', () => {
|
||||||
renderPanel(obstacleEntry({ status: 'review', changed: ['route'], decision: record('pass') }));
|
renderPanel(obstacleEntry({ status: 'review', changed: ['route'], decision: record('pass') }));
|
||||||
|
|
||||||
const earlier = screen.getByRole('radio', { name: 'Paddle through' });
|
// Named with its review state too, so it is told apart by more than its ring.
|
||||||
|
const earlier = screen.getByRole('radio', { name: 'Paddle through, Check again' });
|
||||||
expect(earlier).toHaveAttribute('aria-checked', 'false');
|
expect(earlier).toHaveAttribute('aria-checked', 'false');
|
||||||
expect(earlier).toHaveClass('border-dashed');
|
expect(earlier).toHaveClass('border-dashed');
|
||||||
expect(earlier).not.toHaveClass('bg-accent');
|
expect(earlier).not.toHaveClass('bg-accent');
|
||||||
@@ -251,7 +253,7 @@ describe('ObstacleDecisionPanel', () => {
|
|||||||
expect(screen.getByTestId('obstacle-on-site')).toHaveTextContent('unknown');
|
expect(screen.getByTestId('obstacle-on-site')).toHaveTextContent('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PADDLE-DECIDE-009: a decision takes a note of two rows and at most 500 characters, saved trimmed', () => {
|
it('FE-PADDLE-DECIDE-009: a decision takes a note of two rows and at most 500 characters, saved trimmed', async () => {
|
||||||
renderPanel(decidedEntry());
|
renderPanel(decidedEntry());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
||||||
@@ -264,7 +266,18 @@ describe('ObstacleDecisionPanel', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
expect(onNote).toHaveBeenCalledTimes(1);
|
expect(onNote).toHaveBeenCalledTimes(1);
|
||||||
expect(onNote).toHaveBeenCalledWith('Links an der Bootsrutsche');
|
expect(onNote).toHaveBeenCalledWith('Links an der Bootsrutsche');
|
||||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-DECIDE-021: a note whose save failed stays in the editor with its text', async () => {
|
||||||
|
onNote.mockResolvedValueOnce(false);
|
||||||
|
renderPanel(decidedEntry());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts umtragen' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
await waitFor(() => expect(onNote).toHaveBeenCalledWith('Rechts umtragen'));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Note' })).toHaveValue('Rechts umtragen');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PADDLE-DECIDE-010: a saved note reads clamped; Cancel and an unchanged save keep it, emptying it removes it', () => {
|
it('FE-PADDLE-DECIDE-010: a saved note reads clamped; Cancel and an unchanged save keep it, emptying it removes it', () => {
|
||||||
@@ -385,20 +398,28 @@ describe('ObstacleDecisionPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('ChecklistObstaclePanel', () => {
|
describe('ChecklistObstaclePanel', () => {
|
||||||
it('FE-PADDLE-DECIDE-017: shows nothing for an object the checklist does not list or while it is not ready', () => {
|
it('FE-PADDLE-DECIDE-017: shows nothing without a checklist or for an object it does not list, and stays while it reads again', () => {
|
||||||
const { container, rerender } = render(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/2" />);
|
const { container, rerender } = render(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/2" />);
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
|
||||||
rerender(
|
rerender(<ChecklistObstaclePanel checklist={checklistOf(null, { status: 'loading' })} featureRef="node/1" />);
|
||||||
<ChecklistObstaclePanel checklist={checklistOf(obstacleCheck(), { status: 'loading' })} featureRef="node/1" />
|
|
||||||
);
|
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
|
||||||
|
// Reading again keeps the panel open, locked, so a note being written is not lost.
|
||||||
|
rerender(
|
||||||
|
<ChecklistObstaclePanel
|
||||||
|
checklist={checklistOf(obstacleCheck(), { status: 'loading', canDecide: false })}
|
||||||
|
featureRef="node/1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('radio', { name: 'Paddle through' })).toBeDisabled();
|
||||||
|
|
||||||
rerender(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/1" />);
|
rerender(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/1" />);
|
||||||
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
|
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PADDLE-DECIDE-018: decides, plans the portage, notes, removes and moves on through the checklist', () => {
|
it('FE-PADDLE-DECIDE-018: decides, plans the portage, notes, removes and moves on through the checklist', async () => {
|
||||||
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||||
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
|
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
|
||||||
const checklist = checklistOf(obstacleCheck({ entries: [entry, following] }), {
|
const checklist = checklistOf(obstacleCheck({ entries: [entry, following] }), {
|
||||||
@@ -421,6 +442,37 @@ describe('ChecklistObstaclePanel', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||||
expect(checklist.next).toHaveBeenCalledWith(entry);
|
expect(checklist.next).toHaveBeenCalledWith(entry);
|
||||||
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
|
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-DECIDE-022: someone who may not edit the tour is not offered to plan the portage', () => {
|
||||||
|
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||||
|
render(
|
||||||
|
<ChecklistObstaclePanel
|
||||||
|
checklist={checklistOf(obstacleCheck({ entries: [entry] }), { canPlanPortage: false, canDecide: false })}
|
||||||
|
featureRef="node/1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-DECIDE-023: moving on keeps the focus in the panel when the next entry has no Next of its own', () => {
|
||||||
|
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||||
|
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
|
||||||
|
const check = obstacleCheck({ entries: [entry, following] });
|
||||||
|
let featureRef = 'node/1';
|
||||||
|
const checklist = checklistOf(check, {
|
||||||
|
nextOf: (item) => (item.key === entry.key ? following : null),
|
||||||
|
next: vi.fn<PaddleChecklist['next']>(() => {
|
||||||
|
featureRef = 'node/2';
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { rerender } = render(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
|
||||||
|
const next = screen.getByRole('button', { name: 'Next' });
|
||||||
|
next.focus();
|
||||||
|
fireEvent.click(next);
|
||||||
|
rerender(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
|
||||||
|
expect(screen.getByTestId('obstacle-decision-panel')).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PADDLE-DECIDE-019: a decision about an object the data no longer holds is kept through the checklist', () => {
|
it('FE-PADDLE-DECIDE-019: a decision about an object the data no longer holds is kept through the checklist', () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
Unlink,
|
Unlink,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, type KeyboardEvent, type ReactNode } from 'react';
|
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
@@ -104,6 +104,8 @@ function DecisionChoices({
|
|||||||
>
|
>
|
||||||
<span className="flex-none">{CHOICE_ICON[choice]}</span>
|
<span className="flex-none">{CHOICE_ICON[choice]}</span>
|
||||||
<span className="min-w-0 truncate">{t(`tours.paddle.check.decision.${choice}`)}</span>
|
<span className="min-w-0 truncate">{t(`tours.paddle.check.decision.${choice}`)}</span>
|
||||||
|
{/* The earlier choice is told apart by more than its dashed ring. */}
|
||||||
|
{look === 'earlier' && <span className="sr-only">{`, ${t('tours.paddle.check.status.review')}`}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -236,15 +238,21 @@ function NoteBlock({
|
|||||||
}: {
|
}: {
|
||||||
note: string | null;
|
note: string | null;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onNote: (note: string | null) => void;
|
/** Resolves to whether the note was saved; a failed save keeps the text being written. */
|
||||||
|
onNote: (note: string | null) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// The text being written, or null while the note is only read.
|
// The text being written, or null while the note is only read.
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
const save = (text: string) => {
|
const save = (text: string) => {
|
||||||
const next = text.trim() || null;
|
const next = text.trim() || null;
|
||||||
if (next !== note) onNote(next);
|
if (next === note) {
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void onNote(next).then((saved) => {
|
||||||
|
if (saved) setDraft(null);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
if (draft !== null)
|
if (draft !== null)
|
||||||
return (
|
return (
|
||||||
@@ -364,7 +372,7 @@ export function ObstacleDecisionPanel({
|
|||||||
saving: boolean;
|
saving: boolean;
|
||||||
memberName: (id: number | null) => string | null;
|
memberName: (id: number | null) => string | null;
|
||||||
onDecide: (decision: ObstacleDecision) => void;
|
onDecide: (decision: ObstacleDecision) => void;
|
||||||
onNote: (note: string | null) => void;
|
onNote: (note: string | null) => Promise<boolean>;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onKeep: () => void;
|
onKeep: () => void;
|
||||||
/** Moves on to the next entry still asking for a look; null when there is none. */
|
/** Moves on to the next entry still asking for a look; null when there is none. */
|
||||||
@@ -376,8 +384,28 @@ export function ObstacleDecisionPanel({
|
|||||||
const locked = !canDecide || saving;
|
const locked = !canDecide || saving;
|
||||||
const gone = entry.status === 'gone';
|
const gone = entry.status === 'gone';
|
||||||
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
|
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const movedOn = useRef(false);
|
||||||
|
// Moving on keeps the focus in the panel, also when the next entry has no Next button of its own.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!movedOn.current) return;
|
||||||
|
movedOn.current = false;
|
||||||
|
if (!root.current?.contains(document.activeElement)) root.current?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
const next = onNext
|
||||||
|
? () => {
|
||||||
|
movedOn.current = true;
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div data-testid="obstacle-decision-panel" aria-busy={saving || undefined} className="flex flex-col gap-2.5">
|
<div
|
||||||
|
ref={root}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-testid="obstacle-decision-panel"
|
||||||
|
aria-busy={saving || undefined}
|
||||||
|
className="flex flex-col gap-2.5 outline-none"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-center gap-1.5 empty:hidden">
|
<div className="flex flex-wrap items-center gap-1.5 empty:hidden">
|
||||||
<ObstacleStatusBadges entry={entry} />
|
<ObstacleStatusBadges entry={entry} />
|
||||||
</div>
|
</div>
|
||||||
@@ -391,14 +419,14 @@ export function ObstacleDecisionPanel({
|
|||||||
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
|
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{entry.decision && <NoteBlock note={entry.decision.note} disabled={locked} onNote={onNote} />}
|
{entry.decision && <NoteBlock key={entry.key} note={entry.decision.note} disabled={locked} onNote={onNote} />}
|
||||||
<MetaLine
|
<MetaLine
|
||||||
decision={entry.decision}
|
decision={entry.decision}
|
||||||
memberName={memberName}
|
memberName={memberName}
|
||||||
clearable={!gone}
|
clearable={!gone}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onClear={onClear}
|
onClear={onClear}
|
||||||
onNext={onNext}
|
onNext={next}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -408,17 +436,16 @@ export function ObstacleDecisionPanel({
|
|||||||
export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
|
export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
|
||||||
return (
|
return (
|
||||||
<ObstacleDecisionPanel
|
<ObstacleDecisionPanel
|
||||||
key={entry.key}
|
|
||||||
entry={entry}
|
entry={entry}
|
||||||
canDecide={checklist.canDecide}
|
canDecide={checklist.canDecide}
|
||||||
saving={checklist.savingKey === entry.key}
|
saving={checklist.savingKey === entry.key}
|
||||||
memberName={checklist.memberName}
|
memberName={checklist.memberName}
|
||||||
onDecide={(decision) => void checklist.decide(entry, decision)}
|
onDecide={(decision) => void checklist.decide(entry, decision)}
|
||||||
onNote={(note) => void checklist.setNote(entry, note)}
|
onNote={(note) => checklist.setNote(entry, note)}
|
||||||
onClear={() => void checklist.clear(entry)}
|
onClear={() => void checklist.clear(entry)}
|
||||||
onKeep={() => void checklist.keep(entry)}
|
onKeep={() => void checklist.keep(entry)}
|
||||||
onNext={checklist.nextOf(entry) ? () => checklist.next(entry) : null}
|
onNext={checklist.nextOf(entry) ? () => checklist.next(entry) : null}
|
||||||
onPlanPortage={() => checklist.planPortage(entry)}
|
onPlanPortage={checklist.canPlanPortage ? () => checklist.planPortage(entry) : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,10 +453,11 @@ export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChe
|
|||||||
/**
|
/**
|
||||||
* The checklist's part of the map card of a paddle object (PAD-06): the
|
* The checklist's part of the map card of a paddle object (PAD-06): the
|
||||||
* decision panel of the entry the object belongs to, below its facts, or
|
* decision panel of the entry the object belongs to, below its facts, or
|
||||||
* nothing while the checklist is not ready or does not list the object.
|
* nothing while there is no checklist or it does not list the object.
|
||||||
*/
|
*/
|
||||||
export function ChecklistObstaclePanel({ checklist, featureRef }: { checklist: PaddleChecklist; featureRef: string }) {
|
export function ChecklistObstaclePanel({ checklist, featureRef }: { checklist: PaddleChecklist; featureRef: string }) {
|
||||||
const entry = checklist.status === 'ready' ? checklist.entryForRef(featureRef) : null;
|
// While the checklist reads again it keeps showing, locked, so the panel open on an object stays open.
|
||||||
|
const entry = checklist.check ? checklist.entryForRef(featureRef) : null;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-edge-faint pt-3">
|
<div className="mt-3 border-t border-edge-faint pt-3">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function checklistOf(
|
|||||||
stale: false,
|
stale: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
canDecide: true,
|
canDecide: true,
|
||||||
|
canPlanPortage: true,
|
||||||
savingKey: null,
|
savingKey: null,
|
||||||
selectedKey: null,
|
selectedKey: null,
|
||||||
dialogKey: null,
|
dialogKey: null,
|
||||||
@@ -39,10 +40,10 @@ function checklistOf(
|
|||||||
nextOf: () => null,
|
nextOf: () => null,
|
||||||
next: vi.fn<PaddleChecklist['next']>(),
|
next: vi.fn<PaddleChecklist['next']>(),
|
||||||
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
|
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
|
||||||
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve()),
|
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
|
||||||
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve()),
|
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
|
||||||
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve()),
|
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
|
||||||
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve()),
|
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
|
||||||
memberName: (id) => (id === 1 ? 'Anna' : null),
|
memberName: (id) => (id === 1 ? 'Anna' : null),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,10 +110,12 @@ const ADVISED_LOCK = ownPortage({
|
|||||||
const BYPASSED_LOCK = ownPortage({ kind: 'lock', index: 5, from_m: 5600, to_m: 5600, covers: [], bypassed: true });
|
const BYPASSED_LOCK = ownPortage({ kind: 'lock', index: 5, from_m: 5600, to_m: 5600, covers: [], bypassed: true });
|
||||||
/** All of it out of route order, as the server may list it. */
|
/** All of it out of route order, as the server may list it. */
|
||||||
const ROUTE = obstacleCheck({ entries: [NEAR_LOCK, WEIR, BANNED_WEIR], own: [ADVISED_LOCK, OWN_PORTAGE] });
|
const ROUTE = obstacleCheck({ entries: [NEAR_LOCK, WEIR, BANNED_WEIR], own: [ADVISED_LOCK, OWN_PORTAGE] });
|
||||||
/** The weir, and an earlier decision about a sill the data no longer holds. */
|
/** The weir, and an earlier decision about a sill the route no longer meets. */
|
||||||
const GONE = earlierEntry();
|
const GONE = earlierEntry({ status: 'off_route' });
|
||||||
const GONE_NAME = 'Alte Sohlschwelle';
|
const GONE_NAME = 'Alte Sohlschwelle';
|
||||||
const WITH_EARLIER = obstacleCheck({ entries: [WEIR, GONE] });
|
const WITH_EARLIER = obstacleCheck({ entries: [WEIR, GONE] });
|
||||||
|
/** The weir, and a decision about a sill the complete data no longer holds: it counts, so it stays in view. */
|
||||||
|
const VANISHED = earlierEntry({ key: 'node/8', refs: ['node/8'], name: 'Verschwundenes Wehr' });
|
||||||
|
|
||||||
/** What usePaddleChecklist hands the card: a ready list matching the saved route, nothing selected, every action a spy. */
|
/** What usePaddleChecklist hands the card: a ready list matching the saved route, nothing selected, every action a spy. */
|
||||||
function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist {
|
function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist {
|
||||||
@@ -125,6 +127,7 @@ function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist
|
|||||||
stale: false,
|
stale: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
canDecide: true,
|
canDecide: true,
|
||||||
|
canPlanPortage: true,
|
||||||
savingKey: null,
|
savingKey: null,
|
||||||
selectedKey: null,
|
selectedKey: null,
|
||||||
dialogKey: null,
|
dialogKey: null,
|
||||||
@@ -132,10 +135,10 @@ function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist
|
|||||||
select: vi.fn(),
|
select: vi.fn(),
|
||||||
selectOwn: vi.fn(),
|
selectOwn: vi.fn(),
|
||||||
closeDialog: vi.fn(),
|
closeDialog: vi.fn(),
|
||||||
decide: vi.fn(async () => undefined),
|
decide: vi.fn(async () => true),
|
||||||
setNote: vi.fn(async () => undefined),
|
setNote: vi.fn(async () => true),
|
||||||
clear: vi.fn(async () => undefined),
|
clear: vi.fn(async () => true),
|
||||||
keep: vi.fn(async () => undefined),
|
keep: vi.fn(async () => true),
|
||||||
nextOf: vi.fn(() => null),
|
nextOf: vi.fn(() => null),
|
||||||
next: vi.fn(),
|
next: vi.fn(),
|
||||||
planPortage: vi.fn(),
|
planPortage: vi.fn(),
|
||||||
@@ -366,7 +369,7 @@ describe('PaddleCheckCard', () => {
|
|||||||
const earlier = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
|
const earlier = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
|
||||||
// No longer on the route, so no place along it.
|
// No longer on the route, so no place along it.
|
||||||
expect(earlier).not.toHaveTextContent('km');
|
expect(earlier).not.toHaveTextContent('km');
|
||||||
expect(earlier).toHaveTextContent('status gone');
|
expect(earlier).toHaveTextContent('status off_route');
|
||||||
fireEvent.click(earlier);
|
fireEvent.click(earlier);
|
||||||
expect(select).toHaveBeenCalledWith(GONE);
|
expect(select).toHaveBeenCalledWith(GONE);
|
||||||
});
|
});
|
||||||
@@ -389,6 +392,14 @@ describe('PaddleCheckCard', () => {
|
|||||||
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
|
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-CHECKCARD-018: a decision about an object the data no longer holds stays among the rows, after the route', () => {
|
||||||
|
renderCard(checklistOf({ check: obstacleCheck({ entries: [VANISHED, WEIR] }) }));
|
||||||
|
const rows = rowButtons();
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[1]).toHaveTextContent('Verschwundenes Wehr');
|
||||||
|
expect(screen.queryByRole('button', { name: SAYS.earlier })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PADDLE-CHECKCARD-015: without rows it says nothing is known on covered data, and that there is none where none is', () => {
|
it('FE-PADDLE-CHECKCARD-015: without rows it says nothing is known on covered data, and that there is none where none is', () => {
|
||||||
const empty = obstacleCheck({ entries: [], own: [] });
|
const empty = obstacleCheck({ entries: [], own: [] });
|
||||||
const bench = paddleBench({ entries: 0, open: 0 });
|
const bench = paddleBench({ entries: 0, open: 0 });
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ function EmptyLine({ summary }: { summary: PaddleCheck | null }) {
|
|||||||
/** The rows along the route, then the earlier decisions; dimmed, never hidden, while they may not match the route. */
|
/** The rows along the route, then the earlier decisions; dimmed, never hidden, while they may not match the route. */
|
||||||
function CheckList({ checklist, check }: { checklist: PaddleChecklist; check: TourObstacleCheck }) {
|
function CheckList({ checklist, check }: { checklist: PaddleChecklist; check: TourObstacleCheck }) {
|
||||||
const { rows, earlier } = useMemo(() => checklistRows(check), [check]);
|
const { rows, earlier } = useMemo(() => checklistRows(check), [check]);
|
||||||
const dimmed = checklist.dirty || checklist.stale;
|
// Dimmed while it may not match the route, and while the list it came from reads again.
|
||||||
|
const dimmed = checklist.dirty || checklist.stale || checklist.status === 'loading';
|
||||||
return (
|
return (
|
||||||
<div data-testid="paddle-check-list" className={`flex flex-col gap-1.5 ${dimmed ? 'opacity-60' : ''}`}>
|
<div data-testid="paddle-check-list" className={`flex flex-col gap-1.5 ${dimmed ? 'opacity-60' : ''}`}>
|
||||||
{rows.length > 0 ? (
|
{rows.length > 0 ? (
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export default function PaddleObstacleCard({
|
|||||||
const color = paddleKindCss(feature.kind);
|
const color = paddleKindCss(feature.kind);
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
className={`max-h-[70vh] overflow-y-auto overscroll-contain rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||||
style={{ ...MAP_CARD_GLASS, width }}
|
style={{ ...MAP_CARD_GLASS, width }}
|
||||||
data-testid="paddle-obstacle-card"
|
data-testid="paddle-obstacle-card"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ describe('focusPointsOf', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('checklistRows', () => {
|
describe('checklistRows', () => {
|
||||||
it('PCM-014: lists the obstacles and the own portages and locks in route order, the earlier decisions apart', () => {
|
it('PCM-014: lists the obstacles and the own portages and locks in route order, then the vanished ones, the earlier decisions apart', () => {
|
||||||
const weir = obstacleEntry();
|
const weir = obstacleEntry();
|
||||||
const rapids = obstacleEntry({
|
const rapids = obstacleEntry({
|
||||||
key: 'node/2',
|
key: 'node/2',
|
||||||
@@ -200,9 +200,10 @@ describe('checklistRows', () => {
|
|||||||
});
|
});
|
||||||
const dam = obstacleEntry({ key: 'node/3', refs: ['node/3'], kind: 'dam', along_m: 2600 });
|
const dam = obstacleEntry({ key: 'node/3', refs: ['node/3'], kind: 'dam', along_m: 2600 });
|
||||||
const gone = earlierEntry();
|
const gone = earlierEntry();
|
||||||
|
const offRoute = earlierEntry({ key: 'node/10', refs: ['node/10'], status: 'off_route' });
|
||||||
const portage = ownPortage();
|
const portage = ownPortage();
|
||||||
const lock = ownPortage({ kind: 'lock', index: 2, from_m: 2000, to_m: 2000, covers: [] });
|
const lock = ownPortage({ kind: 'lock', index: 2, from_m: 2000, to_m: 2000, covers: [] });
|
||||||
const check = obstacleCheck({ entries: [dam, weir, gone, rapids], own: [lock, portage] });
|
const check = obstacleCheck({ entries: [dam, weir, gone, offRoute, rapids], own: [lock, portage] });
|
||||||
|
|
||||||
expect(checklistRows(check)).toEqual({
|
expect(checklistRows(check)).toEqual({
|
||||||
rows: [
|
rows: [
|
||||||
@@ -211,8 +212,9 @@ describe('checklistRows', () => {
|
|||||||
{ type: 'obstacle', entry: weir },
|
{ type: 'obstacle', entry: weir },
|
||||||
{ type: 'own', own: lock },
|
{ type: 'own', own: lock },
|
||||||
{ type: 'obstacle', entry: dam },
|
{ type: 'obstacle', entry: dam },
|
||||||
|
{ type: 'obstacle', entry: gone },
|
||||||
],
|
],
|
||||||
earlier: [gone],
|
earlier: [offRoute],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,3 +263,13 @@ describe('nextPendingEntry', () => {
|
|||||||
expect(nextPendingEntry(obstacleCheck({ entries: [first] }), first)).toBeNull();
|
expect(nextPendingEntry(obstacleCheck({ entries: [first] }), first)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('nextPendingEntry at one metre', () => {
|
||||||
|
it('PCM-019: two pending entries at the same metre follow each other in list order', () => {
|
||||||
|
const first = obstacleEntry({ key: 'node/1', along_m: 1200 });
|
||||||
|
const second = obstacleEntry({ key: 'node/2', refs: ['node/2'], along_m: 1200 });
|
||||||
|
const check = obstacleCheck({ entries: [first, second] });
|
||||||
|
expect(nextPendingEntry(check, first)?.key).toBe('node/2');
|
||||||
|
expect(nextPendingEntry(check, second)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -89,27 +89,36 @@ export type ChecklistRow = { type: 'obstacle'; entry: TourObstacleEntry } | { ty
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The rows of a checklist in route order, the paddler's own portages and
|
* The rows of a checklist in route order, the paddler's own portages and
|
||||||
* locks among the obstacles at their metre, and apart from them the earlier
|
* locks among the obstacles at their metre, then the decisions about objects
|
||||||
* decisions the route or the data no longer carries.
|
* the complete data no longer holds (they count as to check again, so they
|
||||||
|
* stay in view). Apart from them the earlier decisions the route no longer
|
||||||
|
* meets or the data cannot tell.
|
||||||
*/
|
*/
|
||||||
export function checklistRows(check: TourObstacleCheck): { rows: ChecklistRow[]; earlier: TourObstacleEntry[] } {
|
export function checklistRows(check: TourObstacleCheck): { rows: ChecklistRow[]; earlier: TourObstacleEntry[] } {
|
||||||
const metreOf = (row: ChecklistRow) => (row.type === 'obstacle' ? (row.entry.along_m ?? 0) : row.own.from_m);
|
const metreOf = (row: ChecklistRow) => (row.type === 'obstacle' ? (row.entry.along_m ?? 0) : row.own.from_m);
|
||||||
const rows: ChecklistRow[] = [
|
const onRoute: ChecklistRow[] = [
|
||||||
...check.entries.filter((entry) => entry.relation !== null).map((entry) => ({ type: 'obstacle' as const, entry })),
|
...check.entries.filter((entry) => entry.relation !== null).map((entry) => ({ type: 'obstacle' as const, entry })),
|
||||||
...check.own.map((own) => ({ type: 'own' as const, own })),
|
...check.own.map((own) => ({ type: 'own' as const, own })),
|
||||||
].sort((a, b) => metreOf(a) - metreOf(b));
|
].sort((a, b) => metreOf(a) - metreOf(b));
|
||||||
return { rows, earlier: check.entries.filter((entry) => entry.relation === null) };
|
const earlier = check.entries.filter((entry) => entry.relation === null);
|
||||||
|
const gone = earlier.filter((entry) => entry.status === 'gone');
|
||||||
|
return {
|
||||||
|
rows: [...onRoute, ...gone.map((entry) => ({ type: 'obstacle' as const, entry }))],
|
||||||
|
earlier: earlier.filter((entry) => entry.status !== 'gone'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PENDING = new Set(['open', 'todo', 'review']);
|
const PENDING = new Set(['open', 'todo', 'review']);
|
||||||
|
|
||||||
/** The next entry along the route still asking for a look; from an earlier decision, the first one. */
|
/**
|
||||||
|
* The next entry still asking for a look, in the order the checklist lists
|
||||||
|
* them (route order, entries at one metre in list order); from an earlier
|
||||||
|
* decision, the first one.
|
||||||
|
*/
|
||||||
export function nextPendingEntry(check: TourObstacleCheck, entry: TourObstacleEntry): TourObstacleEntry | null {
|
export function nextPendingEntry(check: TourObstacleCheck, entry: TourObstacleEntry): TourObstacleEntry | null {
|
||||||
const from = entry.along_m ?? -1;
|
const current = check.entries
|
||||||
return (
|
.filter((candidate) => candidate.relation !== null)
|
||||||
check.entries
|
.sort((a, b) => (a.along_m ?? 0) - (b.along_m ?? 0));
|
||||||
.filter((candidate) => candidate.relation !== null && PENDING.has(candidate.status))
|
const from = current.findIndex((candidate) => candidate.key === entry.key);
|
||||||
.filter((candidate) => candidate.key !== entry.key && (candidate.along_m ?? 0) > from)
|
return current.slice(from + 1).find((candidate) => PENDING.has(candidate.status)) ?? null;
|
||||||
.sort((a, b) => (a.along_m ?? 0) - (b.along_m ?? 0))[0] ?? null
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* UPC-001 to UPC-023: the obstacle checklist of the paddle tour open in the
|
* UPC-001 to UPC-030: the obstacle checklist of the paddle tour open in the
|
||||||
* tours workspace (PAD-06, PAD-09), as its card and the map share it.
|
* tours workspace (PAD-06, PAD-09), as its card and the map share it.
|
||||||
*
|
*
|
||||||
* Pinned: off for anything but an open paddle tour in an active workspace,
|
* Pinned: off for anything but an open paddle tour in an active workspace,
|
||||||
@@ -219,7 +219,7 @@ beforeEach(() => {
|
|||||||
describe('usePaddleChecklist: what it reads', () => {
|
describe('usePaddleChecklist: what it reads', () => {
|
||||||
it('UPC-001: is off for a hike, a neutral planner and a new tour that is no paddle tour, and reads nothing', () => {
|
it('UPC-001: is off for a hike, a neutral planner and a new tour that is no paddle tour, and reads nothing', () => {
|
||||||
const hike = paddleTour({ tour_type: 'hike', boat_type: null, water_context: null, paddle_check: null });
|
const hike = paddleTour({ tour_type: 'hike', boat_type: null, water_context: null, paddle_check: null });
|
||||||
const { result, rerender } = renderChecklist(options({ tours: [hike] }));
|
const { result, rerender } = renderChecklist(options({ tours: [hike], planner: planner({ isPaddle: false }) }));
|
||||||
expect(result.current).toMatchObject({ status: 'off', tour: null, check: null, summary: null, canDecide: false });
|
expect(result.current).toMatchObject({ status: 'off', tour: null, check: null, summary: null, canDecide: false });
|
||||||
|
|
||||||
rerender(options({ planner: planner({ mode: { type: 'neutral' } }) }));
|
rerender(options({ planner: planner({ mode: { type: 'neutral' } }) }));
|
||||||
@@ -375,7 +375,7 @@ describe('usePaddleChecklist: deciding', () => {
|
|||||||
const onSaved = vi.fn<(tour: TourListItem) => void>();
|
const onSaved = vi.fn<(tour: TourListItem) => void>();
|
||||||
const { result, rerender } = await renderReady(options({ onSaved }));
|
const { result, rerender } = await renderReady(options({ onSaved }));
|
||||||
|
|
||||||
let saving: Promise<void> = Promise.resolve();
|
let saving: Promise<boolean> = Promise.resolve(true);
|
||||||
act(() => {
|
act(() => {
|
||||||
saving = result.current.decide(ENTRY, 'pass');
|
saving = result.current.decide(ENTRY, 'pass');
|
||||||
});
|
});
|
||||||
@@ -436,7 +436,7 @@ describe('usePaddleChecklist: deciding', () => {
|
|||||||
const onSaved = vi.fn<(tour: TourListItem) => void>();
|
const onSaved = vi.fn<(tour: TourListItem) => void>();
|
||||||
const { result } = await renderReady(options({ onSaved }));
|
const { result } = await renderReady(options({ onSaved }));
|
||||||
|
|
||||||
let saving: Promise<void> = Promise.resolve();
|
let saving: Promise<boolean> = Promise.resolve(true);
|
||||||
act(() => {
|
act(() => {
|
||||||
saving = result.current.decide(ENTRY, 'portage');
|
saving = result.current.decide(ENTRY, 'portage');
|
||||||
});
|
});
|
||||||
@@ -662,3 +662,102 @@ describe('usePaddleChecklist: the map and the route', () => {
|
|||||||
expect(setSelectedWaypointId).toHaveBeenCalledTimes(1);
|
expect(setSelectedWaypointId).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('usePaddleChecklist after review', () => {
|
||||||
|
const answer = (check: typeof CHECK, revision: string, place = 7): TourDetailResponse => ({
|
||||||
|
tour: paddleTour({ place_id: place, paddle_check: paddleBench({ revision }) }),
|
||||||
|
waypoints: [],
|
||||||
|
obstacle_check: check,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-024: a save answered after another tour opened leaves that tour as it read itself', async () => {
|
||||||
|
const other = paddleTour({ place_id: 8, paddle_check: paddleBench({ revision: 'rev-8' }) });
|
||||||
|
const save = deferred<TourDetailResponse>();
|
||||||
|
repo.saveObstacleReviews.mockReturnValueOnce(save.promise);
|
||||||
|
const props = options({ tours: [TOUR, other] });
|
||||||
|
const { result, rerender } = await renderReady(props);
|
||||||
|
act(() => {
|
||||||
|
void result.current.decide(ENTRY, 'pass');
|
||||||
|
});
|
||||||
|
const otherCheck = obstacleCheck({
|
||||||
|
revision: 'rev-8',
|
||||||
|
entries: [obstacleEntry({ key: 'node/8', refs: ['node/8'] })],
|
||||||
|
});
|
||||||
|
repo.obstacleCheck.mockResolvedValue({ check: otherCheck, stale: false });
|
||||||
|
rerender({ ...props, planner: planner({ mode: { type: 'edit-saved', placeId: 8 } }) });
|
||||||
|
await waitFor(() => expect(result.current.check?.entries[0]?.key).toBe('node/8'));
|
||||||
|
expect(result.current.savingKey).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
save.resolve(
|
||||||
|
answer(obstacleCheck({ revision: 'rev-2', entries: [obstacleEntry({ status: 'decided' })] }), 'rev-2')
|
||||||
|
);
|
||||||
|
await save.promise;
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe('ready');
|
||||||
|
expect(result.current.check?.entries[0]?.key).toBe('node/8');
|
||||||
|
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ place_id: 7 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-025: a save whose checklist is of another revision than its bench stays marked stale', async () => {
|
||||||
|
const { result, rerender } = await renderReady();
|
||||||
|
repo.saveObstacleReviews.mockResolvedValueOnce(answer(obstacleCheck({ revision: 'rev-1' }), 'local:u1'));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.decide(ENTRY, 'pass');
|
||||||
|
});
|
||||||
|
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'local:u1' }) })] }));
|
||||||
|
expect(result.current.stale).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-026: a read that finds a newer bench on the server hands the tour on and is not stale', async () => {
|
||||||
|
const newer = paddleTour({ paddle_check: paddleBench({ revision: 'rev-9' }) });
|
||||||
|
repo.obstacleCheck.mockResolvedValue({ check: obstacleCheck({ revision: 'rev-9' }), stale: true, tour: newer });
|
||||||
|
const props = options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'local:u1' }) })] });
|
||||||
|
const { result, rerender } = renderChecklist(props);
|
||||||
|
await waitFor(() => expect(props.onSaved).toHaveBeenCalledWith(newer));
|
||||||
|
rerender({ ...props, tours: [newer] });
|
||||||
|
expect(result.current).toMatchObject({ status: 'ready', stale: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-027: while the same tour reads again its last checklist stays, locked', async () => {
|
||||||
|
const { result, rerender } = await renderReady();
|
||||||
|
const again = deferred<CheckRead>();
|
||||||
|
repo.obstacleCheck.mockReturnValueOnce(again.promise);
|
||||||
|
rerender(options({ tours: [paddleTour({ paddle_check: paddleBench({ revision: 'rev-3' }) })] }));
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
expect(result.current.check).toEqual(CHECK);
|
||||||
|
expect(result.current.canDecide).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
again.resolve({ check: obstacleCheck({ revision: 'rev-3' }), stale: false });
|
||||||
|
await again.promise;
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-028: a saved tour turned into a paddle tour and not saved yet asks to be saved first', () => {
|
||||||
|
const hike = paddleTour({ tour_type: 'hike', boat_type: null, water_context: null, paddle_check: null });
|
||||||
|
const { result } = renderChecklist(options({ tours: [hike], planner: planner({ isPaddle: true }) }));
|
||||||
|
expect(result.current).toMatchObject({ status: 'new', tour: null, canDecide: false });
|
||||||
|
expect(repo.obstacleCheck).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-029: planning a portage is offered to editors only', async () => {
|
||||||
|
expect((await renderReady()).result.current.canPlanPortage).toBe(true);
|
||||||
|
expect((await renderReady(options({ canEdit: false }))).result.current.canPlanPortage).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPC-030: a note whose save failed answers false, one that went through true', async () => {
|
||||||
|
const { result } = await renderReady();
|
||||||
|
repo.saveObstacleReviews.mockRejectedValueOnce(new Error('offline'));
|
||||||
|
let saved: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.setNote(ENTRY, 'Links');
|
||||||
|
});
|
||||||
|
expect(saved).toBe(false);
|
||||||
|
repo.saveObstacleReviews.mockResolvedValueOnce(answer(CHECK, 'rev-1'));
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.setNote(ENTRY, 'Links');
|
||||||
|
});
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type TourOwnEntry,
|
type TourOwnEntry,
|
||||||
type TripMember,
|
type TripMember,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { applyObstacleOps } from '../../../repo/obstacleCheckLocal';
|
import { applyObstacleOps } from '../../../repo/obstacleCheckLocal';
|
||||||
@@ -43,6 +43,7 @@ interface PaddleChecklistOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Loaded {
|
interface Loaded {
|
||||||
|
placeId: number;
|
||||||
key: string;
|
key: string;
|
||||||
check: TourObstacleCheck | null;
|
check: TourObstacleCheck | null;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
@@ -57,27 +58,59 @@ function openPaddleTour(mode: TourPlannerController['mode'], tours: readonly Tou
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Reads the checklist for the bench the list shows, again whenever its revision or the connection changes. */
|
/** Reads the checklist for the bench the list shows, again whenever its revision or the connection changes. */
|
||||||
function useChecklistRead(tripId: number, tour: TourListItem | null, active: boolean) {
|
/** The read key of a tour's checklist: a new bench revision or a change of the connection reads it again. */
|
||||||
|
const readKey = (placeId: number | null, revision: string | null, offline: boolean) =>
|
||||||
|
`${placeId}:${revision}:${offline}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the checklist for the bench the list shows, again whenever its
|
||||||
|
* revision or the connection changes. A read that finds the server further
|
||||||
|
* than the list (a queued write replayed meanwhile) hands the newer tour on,
|
||||||
|
* so the list and its bench catch up.
|
||||||
|
*/
|
||||||
|
function useChecklistRead(
|
||||||
|
tripId: number,
|
||||||
|
tour: TourListItem | null,
|
||||||
|
active: boolean,
|
||||||
|
onNewer: (tour: TourListItem) => void
|
||||||
|
) {
|
||||||
const { offline } = useNetworkMode();
|
const { offline } = useNetworkMode();
|
||||||
const placeId = tour?.place_id ?? null;
|
const placeId = tour?.place_id ?? null;
|
||||||
const revision = tour?.paddle_check?.revision ?? null;
|
const revision = tour?.paddle_check?.revision ?? null;
|
||||||
const key = `${placeId}:${revision}:${offline}`;
|
const key = readKey(placeId, revision, offline);
|
||||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||||
|
// Handing the newer tour on is no reason to read again.
|
||||||
|
const takeNewer = useEffectEvent(onNewer);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || placeId === null) return;
|
if (!active || placeId === null) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
tourRepo
|
tourRepo
|
||||||
.obstacleCheck(tripId, placeId, revision, controller.signal)
|
.obstacleCheck(tripId, placeId, revision, controller.signal)
|
||||||
.then(({ check, stale }) => {
|
.then(({ check, stale, tour: answered }) => {
|
||||||
if (!controller.signal.aborted) setLoaded({ key, check, stale, failed: false });
|
if (controller.signal.aborted) return;
|
||||||
|
const newer = answered?.paddle_check?.revision;
|
||||||
|
if (answered && newer && newer !== revision) {
|
||||||
|
setLoaded({
|
||||||
|
placeId,
|
||||||
|
key: readKey(placeId, newer, offline),
|
||||||
|
check,
|
||||||
|
stale: check?.revision !== newer,
|
||||||
|
failed: false,
|
||||||
|
});
|
||||||
|
takeNewer(answered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoaded({ placeId, key, check, stale, failed: false });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!controller.signal.aborted) setLoaded({ key, check: null, stale: false, failed: true });
|
if (!controller.signal.aborted) setLoaded({ placeId, key, check: null, stale: false, failed: true });
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [active, key, placeId, revision, tripId]);
|
}, [active, key, offline, placeId, revision, tripId]);
|
||||||
const current = loaded?.key === key ? loaded : null;
|
const current = loaded?.key === key ? loaded : null;
|
||||||
return { current, offline, key, setLoaded };
|
// While the same tour reads again, what it read before stays in view, locked, so a panel open on it stays open.
|
||||||
|
const previous = !current && loaded?.placeId === placeId && loaded.check ? loaded : null;
|
||||||
|
return { current, previous, offline, key, setLoaded };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a portage for an obstacle starts on a planned tour: the control point whose leg passes it. */
|
/** Where a portage for an obstacle starts on a planned tour: the control point whose leg passes it. */
|
||||||
@@ -194,14 +227,16 @@ function useChecklistNavigation(
|
|||||||
export function usePaddleChecklist(options: PaddleChecklistOptions) {
|
export function usePaddleChecklist(options: PaddleChecklistOptions) {
|
||||||
const { tripId, planner, tours, canEdit, active, trackPaddlePlan } = options;
|
const { tripId, planner, tours, canEdit, active, trackPaddlePlan } = options;
|
||||||
const tour = useMemo(() => openPaddleTour(planner.mode, tours), [planner.mode, tours]);
|
const tour = useMemo(() => openPaddleTour(planner.mode, tours), [planner.mode, tours]);
|
||||||
const read = useChecklistRead(tripId, tour, active);
|
const read = useChecklistRead(tripId, tour, active, options.onSaved);
|
||||||
const writes = useChecklistWrites(options, tour, read);
|
const writes = useChecklistWrites(options, tour, read);
|
||||||
const check = writes.optimistic ?? read.current?.check ?? null;
|
const check = writes.optimistic ?? read.current?.check ?? read.previous?.check ?? null;
|
||||||
const navigation = useChecklistNavigation(planner, check, trackPaddlePlan);
|
const navigation = useChecklistNavigation(planner, check, trackPaddlePlan);
|
||||||
const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty;
|
const dirty = planner.hasUnsavedChanges || !!trackPaddlePlan?.dirty;
|
||||||
const status = checklistStatus({
|
const status = checklistStatus({
|
||||||
active,
|
active,
|
||||||
newPaddleDraft: planner.mode.type === 'new-draft' && planner.isPaddle,
|
// A new paddle draft, or a saved tour turned into one and not saved yet: no server check exists for either.
|
||||||
|
newPaddleDraft:
|
||||||
|
planner.isPaddle && (planner.mode.type === 'new-draft' || (planner.mode.type === 'edit-saved' && !tour)),
|
||||||
tour,
|
tour,
|
||||||
current: read.current,
|
current: read.current,
|
||||||
offline: read.offline,
|
offline: read.offline,
|
||||||
@@ -216,6 +251,8 @@ export function usePaddleChecklist(options: PaddleChecklistOptions) {
|
|||||||
stale: !!read.current?.stale,
|
stale: !!read.current?.stale,
|
||||||
dirty,
|
dirty,
|
||||||
canDecide: canEdit && status === 'ready' && !dirty && saved && writes.savingKey === null,
|
canDecide: canEdit && status === 'ready' && !dirty && saved && writes.savingKey === null,
|
||||||
|
/** Planning a portage edits the tour, so only someone who may edit it is offered it. */
|
||||||
|
canPlanPortage: canEdit,
|
||||||
savingKey: writes.savingKey,
|
savingKey: writes.savingKey,
|
||||||
...navigation,
|
...navigation,
|
||||||
...writes.actions,
|
...writes.actions,
|
||||||
@@ -235,30 +272,38 @@ function useChecklistWrites(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const userId = useAuthStore((state) => state.user?.id ?? null);
|
const userId = useAuthStore((state) => state.user?.id ?? null);
|
||||||
const [pending, setPending] = useState<{ key: string; check: TourObstacleCheck } | null>(null);
|
const [pending, setPending] = useState<{ placeId: number; key: string; check: TourObstacleCheck } | null>(null);
|
||||||
const base = read.current?.check ?? null;
|
const base = read.current?.check ?? null;
|
||||||
const placeId = tour?.place_id ?? null;
|
const placeId = tour?.place_id ?? null;
|
||||||
const { setLoaded, offline } = read;
|
const { setLoaded, offline } = read;
|
||||||
|
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
async (key: string, op: ObstacleReviewOp) => {
|
async (key: string, op: ObstacleReviewOp) => {
|
||||||
if (!base || placeId === null) return;
|
if (!base || placeId === null) return false;
|
||||||
setPending({
|
setPending({
|
||||||
|
placeId,
|
||||||
key,
|
key,
|
||||||
check: applyObstacleOps(base, [op], { now: new Date().toISOString(), userId, revision: 'pending' }),
|
check: applyObstacleOps(base, [op], { now: new Date().toISOString(), userId, revision: 'pending' }),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await tourRepo.saveObstacleReviews(tripId, placeId, { ops: [op] }, userId);
|
const result = await tourRepo.saveObstacleReviews(tripId, placeId, { ops: [op] }, userId);
|
||||||
const revision = result.tour.paddle_check?.revision ?? null;
|
const revision = result.tour.paddle_check?.revision ?? null;
|
||||||
setLoaded({
|
const check = result.obstacle_check ?? base;
|
||||||
key: `${placeId}:${revision}:${offline}`,
|
const next: Loaded = {
|
||||||
check: result.obstacle_check ?? base,
|
placeId,
|
||||||
stale: false,
|
key: readKey(placeId, revision, offline),
|
||||||
|
check,
|
||||||
|
// A checklist still judged on an older route than the bench stays marked as such.
|
||||||
|
stale: revision !== null && check.revision !== revision,
|
||||||
failed: false,
|
failed: false,
|
||||||
});
|
};
|
||||||
|
// Another tour opened meanwhile keeps what it read; this one's answer reaches the list and the cache.
|
||||||
|
setLoaded((current) => (current && !current.key.startsWith(`${placeId}:`) ? current : next));
|
||||||
onSaved(result.tour);
|
onSaved(result.tour);
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('tours.paddle.marks.failed'));
|
toast.error(t('tours.paddle.marks.failed'));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
}
|
}
|
||||||
@@ -285,5 +330,7 @@ function useChecklistWrites(
|
|||||||
}),
|
}),
|
||||||
[save]
|
[save]
|
||||||
);
|
);
|
||||||
return { optimistic: pending?.check ?? null, savingKey: pending?.key ?? null, actions };
|
// A save still running for a tour left meanwhile shows nothing on the one open now.
|
||||||
|
const mine = pending && pending.placeId === placeId ? pending : null;
|
||||||
|
return { optimistic: mine?.check ?? null, savingKey: mine?.key ?? null, actions };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-W4TIP-001 to FE-W4TIP-014
|
// FE-W4TIP-001 to FE-W4TIP-015
|
||||||
import { createRef, useRef } from 'react'
|
import { createRef, useRef } from 'react'
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
|
||||||
@@ -181,4 +181,16 @@ describe('Tooltip — inside a component that owns the ref', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('button')).toHaveAttribute('data-clicked', '1')
|
expect(screen.getByRole('button')).toHaveAttribute('data-clicked', '1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-W4TIP-015: a label of several lines breaks at them and stays within the window, a single line does not wrap', () => {
|
||||||
|
const { unmount } = render(<Tooltip label={'3 open\nNo safety or legal clearance.'}><button>x</button></Tooltip>)
|
||||||
|
hover(screen.getByRole('button'))
|
||||||
|
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'pre-line' })
|
||||||
|
expect(screen.getByRole('tooltip').style.maxWidth).toContain('100vw')
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
render(<Tooltip label="Delete"><button>y</button></Tooltip>)
|
||||||
|
hover(screen.getByRole('button'))
|
||||||
|
expect(screen.getByRole('tooltip')).toHaveStyle({ whiteSpace: 'nowrap' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
padding: '5px 10px',
|
padding: '5px 10px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
whiteSpace: 'nowrap',
|
// A label of several lines (the paddle check bench) breaks at its lines and wraps within the window.
|
||||||
|
whiteSpace: label.includes('\n') ? 'pre-line' : 'nowrap',
|
||||||
|
maxWidth: label.includes('\n') ? 'min(22rem, calc(100vw - 12px))' : undefined,
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "var(--font-system)",
|
||||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||||
|
|||||||
@@ -308,7 +308,8 @@ describe('TripPlannerPage — shell', () => {
|
|||||||
expect(await screen.findByText('Plan a tour')).toBeInTheDocument()
|
expect(await screen.findByText('Plan a tour')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Plan new tour' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Plan new tour' })).toBeInTheDocument()
|
||||||
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
|
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('option', { name: /Editable ridge tour/i })).not.toHaveAttribute('aria-selected', 'true')
|
// The tours list fills in after the rail: wait for its row instead of reading it at once.
|
||||||
|
expect(await screen.findByRole('option', { name: /Editable ridge tour/i })).not.toHaveAttribute('aria-selected', 'true')
|
||||||
expect(plannerActions.openTour).not.toHaveBeenCalled()
|
expect(plannerActions.openTour).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-REPO-TOUR-030 to FE-REPO-TOUR-038
|
// FE-REPO-TOUR-030 to FE-REPO-TOUR-040
|
||||||
// A paddle tour's checklist through the offline core (PAD-06, PAD-09): read
|
// A paddle tour's checklist through the offline core (PAD-06, PAD-09): read
|
||||||
// from the cache while its revision matches the bench, decisions saved online
|
// from the cache while its revision matches the bench, decisions saved online
|
||||||
// and cached, saved offline under a local revision and queued, and a route
|
// and cached, saved offline under a local revision and queued, and a route
|
||||||
@@ -92,14 +92,14 @@ describe('tourRepo.obstacleCheck', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(await tourRepo.obstacleCheck(3, 40, 'rev-2')).toEqual({ check: fresh, stale: false });
|
expect(await tourRepo.obstacleCheck(3, 40, 'rev-2')).toMatchObject({ check: fresh, stale: false });
|
||||||
expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(fresh);
|
expect((await offlineDb.tours.get(40))?.obstacle_check).toEqual(fresh);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-REPO-TOUR-032: offline, an older cached checklist stands in and reads as stale', async () => {
|
it('FE-REPO-TOUR-032: offline, an older cached checklist stands in and reads as stale', async () => {
|
||||||
setOnline(false);
|
setOnline(false);
|
||||||
await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() });
|
await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() });
|
||||||
expect(await tourRepo.obstacleCheck(3, 40, 'local:abc')).toEqual({ check: obstacleCheck(), stale: true });
|
expect(await tourRepo.obstacleCheck(3, 40, 'local:abc')).toMatchObject({ check: obstacleCheck(), stale: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,3 +204,34 @@ describe('a route written offline and the list cache', () => {
|
|||||||
expect(tours[0]).not.toHaveProperty('obstacle_check');
|
expect(tours[0]).not.toHaveProperty('obstacle_check');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('after review', () => {
|
||||||
|
it('FE-REPO-TOUR-039: a decision queued on a checklist already stale keeps it stale against the bench', async () => {
|
||||||
|
setOnline(false);
|
||||||
|
vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => undefined);
|
||||||
|
await offlineDb.tours.put({
|
||||||
|
...paddleTour({ paddle_check: paddleBench({ revision: 'local:route' }) }),
|
||||||
|
trip_id: 3,
|
||||||
|
waypoints,
|
||||||
|
obstacle_check: obstacleCheck(),
|
||||||
|
});
|
||||||
|
const result = await tourRepo.saveObstacleReviews(3, 40, decide, 4);
|
||||||
|
expect(result.tour.paddle_check?.revision).toBe('local:route');
|
||||||
|
expect(result.obstacle_check?.revision).not.toBe('local:route');
|
||||||
|
expect((await tourRepo.obstacleCheck(3, 40, 'local:route')).stale).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-REPO-TOUR-040: online, a bench of a write made offline waits for the queue, then reads the server and hands its tour on', async () => {
|
||||||
|
await offlineDb.tours.put({ ...paddleTour(), trip_id: 3, waypoints, obstacle_check: obstacleCheck() });
|
||||||
|
const flush = vi.spyOn(mutationQueue, 'flush').mockResolvedValue();
|
||||||
|
const server_ = paddleTour({ paddle_check: paddleBench({ revision: 'rev-5' }) });
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/3/tours/40', () =>
|
||||||
|
HttpResponse.json({ tour: server_, waypoints, obstacle_check: obstacleCheck({ revision: 'rev-5' }) })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const read = await tourRepo.obstacleCheck(3, 40, 'local:abc');
|
||||||
|
expect(flush).toHaveBeenCalled();
|
||||||
|
expect(read).toMatchObject({ check: { revision: 'rev-5' }, tour: { paddle_check: { revision: 'rev-5' } } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -268,18 +268,22 @@ export const tourRepo = {
|
|||||||
* PAD-06: a paddle tour's checklist for the bench the list shows. The
|
* PAD-06: a paddle tour's checklist for the bench the list shows. The
|
||||||
* cached one serves while its revision matches; otherwise the detail is read
|
* cached one serves while its revision matches; otherwise the detail is read
|
||||||
* (the cache standing in offline), and a checklist of another revision than
|
* (the cache standing in offline), and a checklist of another revision than
|
||||||
* the bench's reads as stale.
|
* the bench's reads as stale. A bench of a write made here offline waits for
|
||||||
|
* the queue to replay once online, and the tour read with the detail comes
|
||||||
|
* back with it, so the list can take in the server's bench.
|
||||||
*/
|
*/
|
||||||
async obstacleCheck(
|
async obstacleCheck(
|
||||||
tripId: number | string,
|
tripId: number | string,
|
||||||
placeId: number,
|
placeId: number,
|
||||||
revision: string | null,
|
revision: string | null,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ check: TourObstacleCheck | null; stale: boolean }> {
|
): Promise<{ check: TourObstacleCheck | null; stale: boolean; tour?: TourListItem }> {
|
||||||
|
if (revision?.startsWith('local:') && !isEffectivelyOffline()) await mutationQueue.flush()
|
||||||
const cached = await offlineDb.tours.get(placeId)
|
const cached = await offlineDb.tours.get(placeId)
|
||||||
if (revision !== null && cached?.obstacle_check?.revision === revision) return { check: cached.obstacle_check, stale: false }
|
if (revision !== null && cached?.obstacle_check?.revision === revision) return { check: cached.obstacle_check, stale: false }
|
||||||
const check = (await tourRepo.detail(tripId, placeId, signal)).obstacle_check ?? null
|
const detail = await tourRepo.detail(tripId, placeId, signal)
|
||||||
return { check, stale: check !== null && revision !== null && check.revision !== revision }
|
const check = detail.obstacle_check ?? null
|
||||||
|
return { check, stale: check !== null && revision !== null && check.revision !== revision, tour: detail.tour }
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,9 +304,14 @@ export const tourRepo = {
|
|||||||
if (!existing?.obstacle_check) throw new Error('Checklist not available offline')
|
if (!existing?.obstacle_check) throw new Error('Checklist not available offline')
|
||||||
const id = generateUUID()
|
const id = generateUUID()
|
||||||
const revision = `local:${id}`
|
const revision = `local:${id}`
|
||||||
|
// A checklist still judged on an older route than the bench keeps that gap, so it stays marked stale.
|
||||||
|
const routeStale = !!existing.paddle_check && existing.paddle_check.revision !== existing.obstacle_check.revision
|
||||||
const check = applyObstacleOps(existing.obstacle_check, body.ops, { now: new Date().toISOString(), userId, revision })
|
const check = applyObstacleOps(existing.obstacle_check, body.ops, { now: new Date().toISOString(), userId, revision })
|
||||||
const bench = existing.paddle_check
|
const bench = existing.paddle_check
|
||||||
? { ...paddleCheckSummaryOf(check.entries, check.own, existing.paddle_check), revision }
|
? {
|
||||||
|
...paddleCheckSummaryOf(check.entries, check.own, existing.paddle_check),
|
||||||
|
revision: routeStale ? existing.paddle_check.revision : revision,
|
||||||
|
}
|
||||||
: existing.paddle_check
|
: existing.paddle_check
|
||||||
const tour: CachedTour = { ...existing, obstacle_check: check, paddle_check: bench }
|
const tour: CachedTour = { ...existing, obstacle_check: check, paddle_check: bench }
|
||||||
await offlineDb.tours.put(tour)
|
await offlineDb.tours.put(tour)
|
||||||
|
|||||||
Reference in New Issue
Block a user