fix(tours): open a tour's packing list on Packing, and ask before letting go of it while Packing is off

"Open packing list" switched to the Lists tab but left it on whichever
sub-tab was open last, so someone who had been in the to-dos landed there.
It now picks the packing side first, and a Lists tab already on screen
follows the choice; storage that is blocked no longer throws either.

With the Packing addon off, "Remove link" let go of the link on one click,
and the link cannot be made again until Packing is back on. It now opens
the tour's packing dialog with only the dimmed link and the Remove link and
Cancel buttons, so nothing changes without a second, deliberate click.
This commit is contained in:
Maurice
2026-10-20 14:02:20 +02:00
parent f833a05461
commit aacc7f2c8d
8 changed files with 218 additions and 92 deletions
@@ -14,7 +14,7 @@ import {
} from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { ChoiceList, ChoiceRow } from '../collection/choiceParts';
import { CategoryDot, CountBadge } from './packingLinkParts';
import { CategoryDot, CountBadge, DimmedLink } from './packingLinkParts';
import type { TourPackingLinkController } from './useTourPackingLink';
/** One thing to point at: its mark, its name and how many items it holds. */
@@ -44,16 +44,57 @@ function Choice({
);
}
/** Everything the tour can point at: the whole list first, then each category. */
function Choices({ packing }: { packing: TourPackingLinkController }) {
const { t } = useTranslation();
return (
<DialogSection label={t('tours.packing.choose')}>
<ChoiceList label={t('tours.packing.choose')} tall>
<Choice
selected={packing.choice === null}
onChoose={() => packing.choose(null)}
testId="tour-packing-choice-whole"
mark={
<span className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm">
<ListChecks size={13} strokeWidth={2.2} />
</span>
}
name={t('tours.packing.whole')}
count={packing.wholeCount}
/>
{packing.categories.map((category) => (
<Choice
key={category.name}
selected={packing.choice === category.name}
onChoose={() => packing.choose(category.name)}
testId={`tour-packing-choice-${category.name}`}
mark={
<span className="grid h-6 w-6 flex-none place-items-center">
<CategoryDot color={category.color} />
</span>
}
name={category.name}
count={category.count}
/>
))}
</ChoiceList>
</DialogSection>
);
}
/**
* P2D-XD-01: the dialog that points a tour at its trip's packing list, as a
* whole or at one of its categories, each with its colour and how many items
* it holds. It writes only through the button in its foot; letting go of a
* link is offered there too, and neither touches the list itself.
* link is offered there too, and neither touches the list itself. With Packing
* off there is nothing to choose and a link cannot be made again, so it shows
* only the dimmed link and asks before letting go of it.
*/
export function TourPackingLinkDialog({ packing, tourName }: { packing: TourPackingLinkController; tourName: string }) {
const { t } = useTranslation();
const labelId = useId();
if (!packing.open) return null;
const linkOnly = !packing.packingEnabled;
return (
<DialogShell
onClose={packing.close}
@@ -71,7 +112,7 @@ export function TourPackingLinkDialog({ packing, tourName }: { packing: TourPack
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={packing.close}
eyebrow={t('tours.packing.link')}
eyebrow={linkOnly ? t('tours.packing.unlink') : t('tours.packing.link')}
title={tourName}
sub={t('tours.packing.dialogSub')}
subWraps
@@ -97,55 +138,27 @@ export function TourPackingLinkDialog({ packing, tourName }: { packing: TourPack
<DialogButton onClick={packing.close} disabled={packing.saving}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="tour-packing-confirm"
disabled={!packing.canConfirm}
onClick={() => void packing.confirm()}
icon={
packing.saving ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<Link2 size={14} strokeWidth={2} />
)
}
>
{t('tours.packing.confirm')}
</DialogButton>
{!linkOnly && (
<DialogButton
variant="primary"
data-testid="tour-packing-confirm"
disabled={!packing.canConfirm}
onClick={() => void packing.confirm()}
icon={
packing.saving ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<Link2 size={14} strokeWidth={2} />
)
}
>
{t('tours.packing.confirm')}
</DialogButton>
)}
</DialogFooter>
}
>
<DialogSection label={t('tours.packing.choose')}>
<ChoiceList label={t('tours.packing.choose')} tall>
<Choice
selected={packing.choice === null}
onChoose={() => packing.choose(null)}
testId="tour-packing-choice-whole"
mark={
<span className="grid h-6 w-6 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm">
<ListChecks size={13} strokeWidth={2.2} />
</span>
}
name={t('tours.packing.whole')}
count={packing.wholeCount}
/>
{packing.categories.map((category) => (
<Choice
key={category.name}
selected={packing.choice === category.name}
onChoose={() => packing.choose(category.name)}
testId={`tour-packing-choice-${category.name}`}
mark={
<span className="grid h-6 w-6 flex-none place-items-center">
<CategoryDot color={category.color} />
</span>
}
name={category.name}
count={category.count}
/>
))}
</ChoiceList>
</DialogSection>
{linkOnly && packing.link ? <DimmedLink link={packing.link} /> : <Choices packing={packing} />}
</DialogShell>
);
}
@@ -1,14 +1,13 @@
import type { TourListItem, TourPackingLink } from '@trek/shared';
import { AlertTriangle, ArrowUpRight, Backpack, Check, Link2, PackageOpen, PowerOff, Unlink } from 'lucide-react';
import type { TourListItem } from '@trek/shared';
import { AlertTriangle, ArrowUpRight, Backpack, Check, Link2, PackageOpen, Unlink } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { PackingItem } from '../../../types';
import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, TourNotice } from '../tourParts';
import { CategoryDot } from './packingLinkParts';
import { CategoryDot, DimmedLink, linkName } from './packingLinkParts';
import { TourPackingLinkDialog } from './TourPackingLinkDialog';
import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink';
@@ -71,11 +70,6 @@ function ItemRow({ item }: { item: PackingItem }) {
);
}
/** The name of what the tour points at: a category by its own name, otherwise the whole list. */
function linkName(link: TourPackingLink, t: (key: string) => string): string {
return link.category ?? t('tours.packing.whole');
}
/** The linked list as it reads now: its items with how many are packed, or why there are none to show. */
function LinkedCard({ packing, onOpenPacking }: { packing: TourPackingLinkController; onOpenPacking?: () => void }) {
const { t } = useTranslation();
@@ -192,17 +186,7 @@ function SectionBody({
);
}
if (!packing.link) return <UnlinkedCard onLink={canEdit ? packing.show : undefined} />;
if (!packing.packingEnabled) {
// Dimmed, not hidden: the tour still points at the list, it just cannot be shown.
return (
<div className="opacity-70">
<TourNotice icon={<PowerOff size={13} strokeWidth={2} />}>
<span className="font-semibold text-content-secondary">{linkName(packing.link, t)}</span>
<span className="block">{t('tours.packing.addonOff')}</span>
</TourNotice>
</div>
);
}
if (!packing.packingEnabled) return <DimmedLink link={packing.link} />;
return <LinkedCard packing={packing} onOpenPacking={onOpenPacking} />;
}
@@ -239,14 +223,11 @@ export function TourPackingSection({
</SectionPill>
);
} else if (editable && packing.link) {
// With Packing off a link cannot be made again, so letting go asks first, in the tour's packing dialog.
action = (
<Tooltip label={t('tours.packing.unlinkHint')}>
<span className="inline-flex">
<SectionPill onClick={() => void packing.unlink()} icon={<Unlink size={11} strokeWidth={2} />}>
{t('tours.packing.unlink')}
</SectionPill>
</span>
</Tooltip>
<SectionPill onClick={packing.show} icon={<Unlink size={11} strokeWidth={2} />}>
{t('tours.packing.unlink')}
</SectionPill>
);
}
return (
@@ -1,6 +1,26 @@
import { Package } from 'lucide-react';
import type { TourPackingLink } from '@trek/shared';
import { Package, PowerOff } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { tintOf, WhiteBadge } from '../../Planner/planParts';
import { TourNotice } from '../tourParts';
/** The name of what the tour points at: a category by its own name, otherwise the whole list. */
export function linkName(link: TourPackingLink, t: (key: string) => string): string {
return link.category ?? t('tours.packing.whole');
}
/** A link while Packing is off: dimmed, not hidden, since the tour still points at the list it cannot show. */
export function DimmedLink({ link }: { link: TourPackingLink }) {
const { t } = useTranslation();
return (
<div className="opacity-70" data-testid="tour-packing-dimmed">
<TourNotice icon={<PowerOff size={13} strokeWidth={2} />}>
<span className="font-semibold text-content-secondary">{linkName(link, t)}</span>
<span className="block">{t('tours.packing.addonOff')}</span>
</TourNotice>
</div>
);
}
/** A packing category's own colour, as the packing list gives it, with a soft halo of the same colour. */
export function CategoryDot({ color }: { color: string }) {
@@ -12,7 +12,7 @@ import type { PackingItem } from '../../../types';
import { TourPackingSection } from './TourPackingSection';
import { useTourPackingLink } from './useTourPackingLink';
// FE-TOUR-PACKING-001 to FE-TOUR-PACKING-010
// FE-TOUR-PACKING-001 to FE-TOUR-PACKING-011
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -201,10 +201,29 @@ describe('TourPackingSection', () => {
section();
expect(await screen.findByText('Packing lists are switched off, so the list cannot be shown.')).toBeInTheDocument();
fireEvent.click(screen.getByText('Remove link'));
// One click only asks: the dialog shows the dimmed link, nothing to choose and no way to link again.
expect(unlink).not.toHaveBeenCalled();
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByTestId('tour-packing-dimmed')).toBeInTheDocument();
expect(within(dialog).queryByTestId('tour-packing-choice-whole')).not.toBeInTheDocument();
expect(within(dialog).queryByTestId('tour-packing-confirm')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByTestId('tour-packing-unlink'));
await waitFor(() => expect(unlink).toHaveBeenCalledWith(5, 11));
await waitFor(() => expect(screen.queryByTestId('tour-packing-section')).not.toBeInTheDocument());
});
it('FE-TOUR-PACKING-011: with Packing off, cancelling the question keeps the link', async () => {
setPacking(false);
vi.mocked(tourPackingLinkRepo.read).mockResolvedValue(GEAR);
const unlink = vi.spyOn(tourPackingLinkRepo, 'unlink').mockResolvedValue(null);
section();
fireEvent.click(await screen.findByText('Remove link'));
fireEvent.click(within(await screen.findByRole('dialog')).getByText('Cancel'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(unlink).not.toHaveBeenCalled();
expect(screen.getByTestId('tour-packing-dimmed')).toBeInTheDocument();
});
it('FE-TOUR-PACKING-010: a failed read is said, with a retry', async () => {
vi.mocked(tourPackingLinkRepo.read).mockRejectedValueOnce(new Error('500'));
section();