mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(packing): share the packing list logic between desktop and phone
usePackingItemActions, usePackingTemplates and usePackingImport take the item, template and bulk import logic out of usePackingListPanel and MPackingListTab, and the phone listsModel keeps only its priority tokens. When adding to a category the desktop looks for the placeholder in the store and the phone in its rendered items. The desktop toasts on an empty import, the phone runs one import at a time. The effects run in the same order as before.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import {
|
||||
formatWeight,
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
packingStatusFiltered,
|
||||
packingViewItems,
|
||||
} from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||
|
||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||
const stove = buildPackingItem({ id: 3, name: 'Stove', category: 'Camp' });
|
||||
const loose = buildPackingItem({ id: 4, name: 'Map', category: null });
|
||||
|
||||
describe('packingListModel', () => {
|
||||
it('FE-PACK-MODEL-001: the common view holds the group pool, the personal one the private items', () => {
|
||||
const items = [shared, mine, stove, loose];
|
||||
expect(packingViewItems(items, 'common').map((i) => i.id)).toEqual([1, 3, 4]);
|
||||
expect(packingViewItems(items, 'personal').map((i) => i.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-002: categories come in first-appearance order, the uncategorised under the default name', () => {
|
||||
expect(packingCategoryOrder([shared, loose, stove], 'Other')).toEqual(['Camp', 'Other']);
|
||||
expect(packingCategoryOrder([], 'Other')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-003: the status filter keeps open or done items, and all of them otherwise', () => {
|
||||
const items = [shared, stove];
|
||||
expect(packingStatusFiltered(items, 'open').map((i) => i.id)).toEqual([3]);
|
||||
expect(packingStatusFiltered(items, 'done').map((i) => i.id)).toEqual([1]);
|
||||
expect(packingStatusFiltered(items, 'all')).toBe(items);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-004: groups follow the first item of each category, after the status filter', () => {
|
||||
const groups = groupPackingItems([loose, shared, stove], 'all', 'Other');
|
||||
expect(groups.map((g) => [g.category, g.items.map((i) => i.id)])).toEqual([
|
||||
['Other', [4]],
|
||||
['Camp', [1, 3]],
|
||||
]);
|
||||
expect(groupPackingItems([loose, shared, stove], 'done', 'Other').map((g) => g.category)).toEqual(['Camp']);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-005: progress counts checked items and rounds the share', () => {
|
||||
expect(packingProgress([shared, stove, loose])).toEqual({ checked: 1, total: 3, pct: 33 });
|
||||
expect(packingProgress([])).toEqual({ checked: 0, total: 0, pct: 0 });
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-006: weights read in grams below a kilo and in kilos from there', () => {
|
||||
expect(formatWeight(232.4)).toBe('232 g');
|
||||
expect(formatWeight(1000)).toBe('1.0 kg');
|
||||
expect(formatWeight(1250)).toBe('1.3 kg');
|
||||
expect(packingItemWeight({ weight_grams: 200, quantity: 3 })).toBe(600);
|
||||
expect(packingItemWeight({ weight_grams: null, quantity: 2 })).toBe(0);
|
||||
expect(packingItemWeight({ weight_grams: 150, quantity: 0 })).toBe(150);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-007: only the last real item of a named category is reset instead of deleted', () => {
|
||||
const only = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes' });
|
||||
expect(isLastCustomItemInCategory(only, [only, shared])).toBe(true);
|
||||
expect(isLastCustomItemInCategory(stove, [shared, stove])).toBe(false);
|
||||
expect(isLastCustomItemInCategory(loose, [loose])).toBe(false);
|
||||
const placeholder = buildPackingItem({ id: 6, name: PACKING_PLACEHOLDER_NAME, category: 'Empty' });
|
||||
expect(isLastCustomItemInCategory(placeholder, [placeholder])).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-008: the placeholder row is recognised by its name', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PackingItem } from '../../types';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
/**
|
||||
* Pure view-model helpers for the packing list, shared by the desktop panel and
|
||||
* the phone tab. No React, no side effects: grouping, filtering and counting
|
||||
* only, so switching a filter on either surface produces the same buckets.
|
||||
*/
|
||||
|
||||
export type PackingView = 'common' | 'personal';
|
||||
export type PackingStatusFilter = 'all' | 'open' | 'done';
|
||||
|
||||
/** Three-tier sharing split (#858): Common = group pool, Personal = mine + shared-to-me. */
|
||||
export function packingViewItems(items: PackingItem[], view: PackingView): PackingItem[] {
|
||||
return items.filter((i) => (view === 'common' ? !i.is_private : !!i.is_private));
|
||||
}
|
||||
|
||||
/** Category display order = first-appearance order within the active view (stable colours). */
|
||||
export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory: string): string[] {
|
||||
const seen: string[] = [];
|
||||
for (const item of viewItems) {
|
||||
const cat = item.category || defaultCategory;
|
||||
if (!seen.includes(cat)) seen.push(cat);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter((i) => !i.checked);
|
||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||
return items;
|
||||
}
|
||||
|
||||
export interface PackingCategoryGroup {
|
||||
category: string;
|
||||
items: PackingItem[];
|
||||
}
|
||||
|
||||
/** Groups the view+status filtered items by category, in first-encounter order. */
|
||||
export function groupPackingItems(
|
||||
viewItems: PackingItem[],
|
||||
status: PackingStatusFilter,
|
||||
defaultCategory: string
|
||||
): PackingCategoryGroup[] {
|
||||
const filtered = packingStatusFiltered(viewItems, status);
|
||||
const order: string[] = [];
|
||||
const byCategory = new Map<string, PackingItem[]>();
|
||||
for (const item of filtered) {
|
||||
const cat = item.category || defaultCategory;
|
||||
let bucket = byCategory.get(cat);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
byCategory.set(cat, bucket);
|
||||
order.push(cat);
|
||||
}
|
||||
bucket.push(item);
|
||||
}
|
||||
return order.map((category) => ({ category, items: byCategory.get(category) as PackingItem[] }));
|
||||
}
|
||||
|
||||
export interface PackingProgress {
|
||||
checked: number;
|
||||
total: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
export function packingProgress(items: PackingItem[]): PackingProgress {
|
||||
const checked = items.filter((i) => i.checked).length;
|
||||
const total = items.length;
|
||||
return { checked, total, pct: total > 0 ? Math.round((checked / total) * 100) : 0 };
|
||||
}
|
||||
|
||||
/** "232 g" under 1000g, "1.2 kg" at/above, the phone's and the printout's weight label. */
|
||||
export function formatWeight(grams: number): string {
|
||||
return grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${Math.round(grams)} g`;
|
||||
}
|
||||
|
||||
/** Weight an item contributes to a bag/total: unit weight times quantity. */
|
||||
export function packingItemWeight(item: Pick<PackingItem, 'weight_grams' | 'quantity'>): number {
|
||||
return (item.weight_grams || 0) * (item.quantity || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when deleting `item` would empty its (custom) category: the row is then
|
||||
* reset to the `...` placeholder instead of removed, so the category keeps its
|
||||
* position and colour (#1289).
|
||||
*/
|
||||
export function isLastCustomItemInCategory(item: PackingItem, allItems: PackingItem[]): boolean {
|
||||
return (
|
||||
!!item.category &&
|
||||
item.name !== PACKING_PLACEHOLDER_NAME &&
|
||||
!allItems.some((i) => i.id !== item.id && i.category === item.category)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPackingPlaceholder(item: Pick<PackingItem, 'name'>): boolean {
|
||||
return item.name === PACKING_PLACEHOLDER_NAME;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { formatWeight, packingItemWeight } from '../../mobile/screens/trip/tabs/listsModel'
|
||||
import { formatWeight, packingItemWeight } from './packingListModel'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
import { groupByCategory, type PackingBagInfo, type PackingBagLookup } from './packingExport'
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||
import type { PackingBag } from '../../types';
|
||||
import { BAG_COLORS } from './packingListPanel.constants';
|
||||
import { useBagTotalsPing } from './useBagTotalsPing';
|
||||
import { usePackingBags } from './usePackingBags';
|
||||
|
||||
vi.mock('./useBagTotalsPing', () => ({ useBagTotalsPing: vi.fn() }));
|
||||
|
||||
// FE-PACK-BAGS-001 to FE-PACK-BAGS-010
|
||||
|
||||
const t = (key: string) => key;
|
||||
const bag = (over: Partial<PackingBag>): PackingBag => ({
|
||||
id: 1,
|
||||
trip_id: 7,
|
||||
name: 'Backpack',
|
||||
color: BAG_COLORS[0],
|
||||
sort_order: 0,
|
||||
...over,
|
||||
});
|
||||
const BACKPACK = bag({ id: 1 });
|
||||
const DUFFEL = bag({ id: 2, name: 'Duffel', color: BAG_COLORS[1] });
|
||||
|
||||
function setup(bagTrackingEnabled = true) {
|
||||
const toast = { error: vi.fn() };
|
||||
const view = renderHook(
|
||||
(props: { enabled: boolean }) => usePackingBags({ tripId: 7, bagTrackingEnabled: props.enabled, t, toast }),
|
||||
{
|
||||
initialProps: { enabled: bagTrackingEnabled },
|
||||
}
|
||||
);
|
||||
return { ...view, toast };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetNetworkMode();
|
||||
vi.mocked(useBagTotalsPing).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setForcedOffline(false);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('usePackingBags', () => {
|
||||
it('FE-PACK-BAGS-001: loads the bags and the weight in no bag while bag tracking is on', async () => {
|
||||
const list = vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK], unassigned_weight_grams: 450 });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.bags).toEqual([BACKPACK]));
|
||||
expect(list).toHaveBeenCalledWith(7);
|
||||
expect(result.current.unassignedWeightGrams).toBe(450);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-002: reads nothing while bag tracking is off, and starts once it is on', async () => {
|
||||
const list = vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK] });
|
||||
const { result, rerender } = setup(false);
|
||||
await act(async () => {});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
expect(result.current.bags).toEqual([]);
|
||||
expect(result.current.unassignedWeightGrams).toBeNull();
|
||||
rerender({ enabled: true });
|
||||
await waitFor(() => expect(result.current.bags).toEqual([BACKPACK]));
|
||||
// A response without the server sum leaves it unknown.
|
||||
expect(result.current.unassignedWeightGrams).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-003: a failed read keeps what was there and says nothing', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockRejectedValue(new Error('offline'));
|
||||
const { result, toast } = setup();
|
||||
await act(async () => {});
|
||||
expect(result.current.bags).toEqual([]);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-004: re-reads the totals on the room ping, and the server totals count only online', async () => {
|
||||
const list = vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [] });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
|
||||
expect(useBagTotalsPing).toHaveBeenLastCalledWith(true, result.current.reloadBags);
|
||||
expect(result.current.serverWeightsFresh).toBe(true);
|
||||
act(() => setForcedOffline(true));
|
||||
expect(result.current.serverWeightsFresh).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-005: a new bag takes the next colour and joins the list', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK] });
|
||||
const create = vi.spyOn(packingApi, 'createBag').mockResolvedValue({ bag: DUFFEL });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(1));
|
||||
let created: PackingBag | undefined;
|
||||
await act(async () => {
|
||||
created = await result.current.createBag('Duffel');
|
||||
});
|
||||
expect(create).toHaveBeenCalledWith(7, { name: 'Duffel', color: BAG_COLORS[1] });
|
||||
expect(created).toEqual(DUFFEL);
|
||||
expect(result.current.bags).toEqual([BACKPACK, DUFFEL]);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-006: a failed new bag is reported and comes back empty', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [] });
|
||||
vi.spyOn(packingApi, 'createBag').mockRejectedValue(new Error('x'));
|
||||
const { result, toast } = setup();
|
||||
let created: PackingBag | undefined = BACKPACK;
|
||||
await act(async () => {
|
||||
created = await result.current.createBag('Duffel');
|
||||
});
|
||||
expect(created).toBeUndefined();
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.saveError');
|
||||
expect(result.current.bags).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-007: an edit merges what the server returns into that bag only', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK, DUFFEL] });
|
||||
const update = vi
|
||||
.spyOn(packingApi, 'updateBag')
|
||||
.mockResolvedValue({ bag: { ...BACKPACK, name: 'Daypack', weight_limit_grams: 7000 } });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(2));
|
||||
await act(async () => {
|
||||
await result.current.updateBag(1, { name: 'Daypack', weight_limit_grams: 7000 });
|
||||
});
|
||||
expect(update).toHaveBeenCalledWith(7, 1, { name: 'Daypack', weight_limit_grams: 7000 });
|
||||
expect(result.current.bags).toEqual([{ ...BACKPACK, name: 'Daypack', weight_limit_grams: 7000 }, DUFFEL]);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-008: deleting and setting members change the list, and each failure has its message', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK, DUFFEL] });
|
||||
vi.spyOn(packingApi, 'setBagMembers').mockResolvedValue({ members: [{ user_id: 3, username: 'ada' }] });
|
||||
vi.spyOn(packingApi, 'deleteBag').mockResolvedValue({ ok: true });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(2));
|
||||
await act(async () => {
|
||||
await result.current.setBagMembers(2, [3]);
|
||||
});
|
||||
expect(result.current.bags[1].members).toEqual([{ user_id: 3, username: 'ada' }]);
|
||||
await act(async () => {
|
||||
await result.current.deleteBag(1);
|
||||
});
|
||||
expect(result.current.bags.map((b) => b.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-009: failed edits, deletes and member changes leave the bags and say why', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK] });
|
||||
vi.spyOn(packingApi, 'updateBag').mockRejectedValue(new Error('x'));
|
||||
vi.spyOn(packingApi, 'deleteBag').mockRejectedValue(new Error('x'));
|
||||
vi.spyOn(packingApi, 'setBagMembers').mockRejectedValue(new Error('x'));
|
||||
const { result, toast } = setup();
|
||||
await waitFor(() => expect(result.current.bags).toHaveLength(1));
|
||||
await act(async () => {
|
||||
await result.current.updateBag(1, { name: 'Daypack' });
|
||||
await result.current.deleteBag(1);
|
||||
await result.current.setBagMembers(1, [3]);
|
||||
});
|
||||
expect(toast.error.mock.calls.map((c) => c[0])).toEqual([
|
||||
'common.error',
|
||||
'packing.toast.deleteError',
|
||||
'common.error',
|
||||
]);
|
||||
expect(result.current.bags).toEqual([BACKPACK]);
|
||||
});
|
||||
|
||||
it('FE-PACK-BAGS-010: tryCreateBag tells a write that went through from a failed one', async () => {
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [] });
|
||||
const create = vi.spyOn(packingApi, 'createBag').mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('x'));
|
||||
const { result, toast } = setup();
|
||||
let first: unknown = 'unset';
|
||||
let second: unknown = 'unset';
|
||||
await act(async () => {
|
||||
first = await result.current.tryCreateBag('Duffel');
|
||||
});
|
||||
expect(first).toEqual({ bag: undefined });
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
second = await result.current.tryCreateBag('Tote');
|
||||
});
|
||||
expect(second).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.saveError');
|
||||
expect(create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PackingUpdateBagRequest } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import type { PackingBag } from '../../types';
|
||||
import { BAG_COLORS } from './packingListPanel.constants';
|
||||
import { useBagTotalsPing } from './useBagTotalsPing';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
interface Toaster {
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's bags behind both packing lists: the bags with their server-summed
|
||||
* weights, kept fresh by the room's totals ping, and the bag writes. The desktop
|
||||
* panel and the phone tab draw their own bag sidebar, modal or sheet over it.
|
||||
*/
|
||||
export function usePackingBags({
|
||||
tripId,
|
||||
bagTrackingEnabled,
|
||||
t,
|
||||
toast,
|
||||
}: {
|
||||
tripId: number;
|
||||
bagTrackingEnabled: boolean;
|
||||
t: Translate;
|
||||
toast: Toaster;
|
||||
}) {
|
||||
const [bags, setBags] = useState<PackingBag[]>([]);
|
||||
/** Server-summed weight of everything in no bag (#2191); null until the first load. */
|
||||
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null);
|
||||
|
||||
const reloadBags = useCallback(async () => {
|
||||
if (!bagTrackingEnabled) return;
|
||||
try {
|
||||
const r = await packingApi.listBags(tripId);
|
||||
setBags(r.bags || []);
|
||||
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null);
|
||||
} catch {
|
||||
// Offline or a failed read: the surfaces fall back to the local sum
|
||||
// (see `serverWeightsFresh` below), so there is nothing to roll back.
|
||||
}
|
||||
}, [tripId, bagTrackingEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadBags();
|
||||
}, [reloadBags]);
|
||||
|
||||
// Bag weights are summed server-side across every member (#2191), so an item
|
||||
// this viewer may not even see still moves them. The item events cannot carry
|
||||
// that (a private item is delivered only to its owner, which is the very rule
|
||||
// that made the totals wrong), so the server pings the room content-free and
|
||||
// we re-read the numbers.
|
||||
useBagTotalsPing(bagTrackingEnabled, reloadBags);
|
||||
|
||||
// Bags are not part of the offline cache (no repo, no Dexie table), so while
|
||||
// offline the server totals are frozen at the last online read and cannot see
|
||||
// the optimistic item writes the mutation queue is holding. A stale absolute
|
||||
// number measured against an airline limit is worse than an honest partial
|
||||
// one, so offline the surfaces sum what they can see instead (#2191).
|
||||
const { offline } = useNetworkMode();
|
||||
const serverWeightsFresh = !offline;
|
||||
|
||||
/**
|
||||
* Creates a bag in the next colour. Resolves to the server's answer once the
|
||||
* write went through, even when that answer carries no bag, or to null once
|
||||
* the failure is reported.
|
||||
*/
|
||||
const tryCreateBag = async (name: string): Promise<{ bag: PackingBag } | null> => {
|
||||
try {
|
||||
const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] });
|
||||
setBags((prev) => [...prev, data.bag]);
|
||||
return { bag: data.bag };
|
||||
} catch {
|
||||
toast.error(t('packing.toast.saveError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Creates a bag in the next colour; the new bag, or undefined once the failure is reported. */
|
||||
const createBag = async (name: string): Promise<PackingBag | undefined> => (await tryCreateBag(name))?.bag;
|
||||
|
||||
const updateBag = async (bagId: number, data: PackingUpdateBagRequest) => {
|
||||
try {
|
||||
const result = await packingApi.updateBag(tripId, bagId, data);
|
||||
setBags((prev) => prev.map((b) => (b.id === bagId ? { ...b, ...result.bag } : b)));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBag = async (bagId: number) => {
|
||||
try {
|
||||
await packingApi.deleteBag(tripId, bagId);
|
||||
setBags((prev) => prev.filter((b) => b.id !== bagId));
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
const setBagMembers = async (bagId: number, userIds: number[]) => {
|
||||
try {
|
||||
const result = await packingApi.setBagMembers(tripId, bagId, userIds);
|
||||
setBags((prev) => prev.map((b) => (b.id === bagId ? { ...b, members: result.members } : b)));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
bags,
|
||||
unassignedWeightGrams,
|
||||
serverWeightsFresh,
|
||||
reloadBags,
|
||||
tryCreateBag,
|
||||
createBag,
|
||||
updateBag,
|
||||
deleteBag,
|
||||
setBagMembers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees';
|
||||
|
||||
// FE-PACK-ASSIGN-001 to FE-PACK-ASSIGN-004
|
||||
|
||||
const t = (key: string) => key;
|
||||
const ANNA = { user_id: 3, username: 'anna' };
|
||||
const BEN = { user_id: 4, username: 'ben' };
|
||||
|
||||
function setup() {
|
||||
const toast = { error: vi.fn() };
|
||||
return { ...renderHook(() => usePackingCategoryAssignees({ tripId: 7, t, toast })), toast };
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('usePackingCategoryAssignees', () => {
|
||||
it('FE-PACK-ASSIGN-001: loads who looks after each category of the trip', async () => {
|
||||
const get = vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: { Camp: [ANNA] } });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.categoryAssignees).toEqual({ Camp: [ANNA] }));
|
||||
expect(get).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('FE-PACK-ASSIGN-002: a failed or empty load leaves every category unassigned, without a message', async () => {
|
||||
vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({});
|
||||
const empty = setup();
|
||||
await act(async () => {});
|
||||
expect(empty.result.current.categoryAssignees).toEqual({});
|
||||
|
||||
vi.spyOn(packingApi, 'getCategoryAssignees').mockRejectedValue(new Error('offline'));
|
||||
const failed = setup();
|
||||
await act(async () => {});
|
||||
expect(failed.result.current.categoryAssignees).toEqual({});
|
||||
expect(failed.toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PACK-ASSIGN-003: setting a category keeps the others and takes what the server answers', async () => {
|
||||
vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: { Camp: [ANNA], Food: [BEN] } });
|
||||
const put = vi.spyOn(packingApi, 'setCategoryAssignees').mockResolvedValue({ assignees: [ANNA, BEN] });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.categoryAssignees.Food).toEqual([BEN]));
|
||||
await act(async () => {
|
||||
await result.current.setAssignees('Camp', [3, 4]);
|
||||
});
|
||||
expect(put).toHaveBeenCalledWith(7, 'Camp', [3, 4]);
|
||||
expect(result.current.categoryAssignees).toEqual({ Camp: [ANNA, BEN], Food: [BEN] });
|
||||
|
||||
put.mockResolvedValueOnce({});
|
||||
await act(async () => {
|
||||
await result.current.setAssignees('Food', []);
|
||||
});
|
||||
expect(result.current.categoryAssignees.Food).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PACK-ASSIGN-004: a failed change is reported and changes nothing', async () => {
|
||||
vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: { Camp: [ANNA] } });
|
||||
vi.spyOn(packingApi, 'setCategoryAssignees').mockRejectedValue(new Error('x'));
|
||||
const { result, toast } = setup();
|
||||
await waitFor(() => expect(result.current.categoryAssignees.Camp).toEqual([ANNA]));
|
||||
await act(async () => {
|
||||
await result.current.setAssignees('Camp', []);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.saveError');
|
||||
expect(result.current.categoryAssignees).toEqual({ Camp: [ANNA] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
interface Toaster {
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
/** Who looks after a packing category, as the server lists them. */
|
||||
export interface CategoryAssignee {
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
is_guest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who looks after each packing category, for both packing lists: loaded per
|
||||
* trip, and replaced category by category. A failed load leaves nobody assigned.
|
||||
*/
|
||||
export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: number; t: Translate; toast: Toaster }) {
|
||||
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
packingApi
|
||||
.getCategoryAssignees(tripId)
|
||||
.then((data) => setCategoryAssignees(data.assignees || {}))
|
||||
.catch(() => {
|
||||
// A failed read leaves every category unassigned; there is nothing to undo.
|
||||
});
|
||||
}, [tripId]);
|
||||
|
||||
const setAssignees = async (category: string, userIds: number[]) => {
|
||||
try {
|
||||
const data = await packingApi.setCategoryAssignees(tripId, category, userIds);
|
||||
setCategoryAssignees((prev) => ({ ...prev, [category]: data.assignees || [] }));
|
||||
} catch {
|
||||
toast.error(t('packing.toast.saveError'));
|
||||
}
|
||||
};
|
||||
|
||||
return { categoryAssignees, setAssignees };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { packingApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingViewItems, type PackingView } from '../../mobile/screens/trip/tabs/listsModel'
|
||||
import { packingViewItems, type PackingView } from './packingListModel'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { packingApi } from '../../api/client';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { parseImportLines } from './packingListPanel.helpers';
|
||||
import { usePackingImport } from './usePackingImport';
|
||||
|
||||
// FE-PACK-IMPORT-001 to FE-PACK-IMPORT-008
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
const EXISTING = buildPackingItem({ id: 1, name: 'Tent' });
|
||||
const LIST = 'Gear, Rope\nClothes, Socks';
|
||||
|
||||
function setup(options: { reportEmpty?: boolean; oneAtATime?: boolean } = {}) {
|
||||
const toast = { success: vi.fn(), error: vi.fn() };
|
||||
const onImported = vi.fn();
|
||||
const rendered = renderHook(() => usePackingImport({ tripId: 7, t, toast, onImported, ...options }));
|
||||
return { ...rendered, toast, onImported };
|
||||
}
|
||||
|
||||
const fileEvent = (target: { files: File[] | null; value: string }) =>
|
||||
({ target }) as unknown as ChangeEvent<HTMLInputElement>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { packingItems: [EXISTING] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('usePackingImport', () => {
|
||||
it('FE-PACK-IMPORT-001: parses the text as it is typed', () => {
|
||||
const { result } = setup();
|
||||
expect(result.current.parsed).toEqual([]);
|
||||
act(() => result.current.setText(LIST));
|
||||
expect(result.current.parsed).toEqual(parseImportLines(LIST));
|
||||
expect(result.current.parsed).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-002: a picked file fills the text box and frees the picker for the same file', async () => {
|
||||
const { result } = setup();
|
||||
const target = { files: [new File(['Gear, Rope'], 'list.csv', { type: 'text/plain' })], value: 'list.csv' };
|
||||
act(() => result.current.readFile(fileEvent(target)));
|
||||
await waitFor(() => expect(result.current.text).toBe('Gear, Rope'));
|
||||
expect(target.value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-003: no file picked, or a result that is not text, leaves the box alone', async () => {
|
||||
class BinaryReader {
|
||||
result: unknown = new ArrayBuffer(4);
|
||||
onload: (() => void) | null = null;
|
||||
readAsText() {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
const { result } = setup();
|
||||
const none = { files: null, value: 'x' };
|
||||
act(() => result.current.readFile(fileEvent(none)));
|
||||
expect(none.value).toBe('x');
|
||||
vi.stubGlobal('FileReader', BinaryReader);
|
||||
act(() => result.current.readFile(fileEvent({ files: [new File(['x'], 'list.csv')], value: 'list.csv' })));
|
||||
expect(result.current.text).toBe('');
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-004: an import appends the items to the store, reports the count, clears and closes', async () => {
|
||||
const added = buildPackingItem({ id: 2, name: 'Rope', category: 'Gear' });
|
||||
const bulk = vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: [added], count: 2 });
|
||||
const { result, toast, onImported } = setup({ oneAtATime: true });
|
||||
act(() => result.current.setText(LIST));
|
||||
await act(async () => {
|
||||
await result.current.runImport();
|
||||
});
|
||||
expect(bulk).toHaveBeenCalledWith(7, parseImportLines(LIST));
|
||||
expect(useTripStore.getState().packingItems).toEqual([EXISTING, added]);
|
||||
expect(toast.success).toHaveBeenCalledWith('packing.importSuccess:2');
|
||||
expect(result.current.text).toBe('');
|
||||
expect(onImported).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.importing).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-005: a failed import is reported, keeps the text and stays open', async () => {
|
||||
vi.spyOn(packingApi, 'bulkImport').mockRejectedValue(new Error('x'));
|
||||
const { result, toast, onImported } = setup({ reportEmpty: true });
|
||||
act(() => result.current.setText(LIST));
|
||||
await act(async () => {
|
||||
await result.current.runImport();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.importError');
|
||||
expect(result.current.text).toBe(LIST);
|
||||
expect(onImported).not.toHaveBeenCalled();
|
||||
expect(useTripStore.getState().packingItems).toEqual([EXISTING]);
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-006: nothing to import: the desktop says so, the phone stays quiet, neither sends', async () => {
|
||||
const bulk = vi.spyOn(packingApi, 'bulkImport');
|
||||
const desktop = setup({ reportEmpty: true });
|
||||
act(() => desktop.result.current.setText(','));
|
||||
await act(async () => {
|
||||
await desktop.result.current.runImport();
|
||||
});
|
||||
expect(desktop.toast.error).toHaveBeenCalledWith('packing.importEmpty');
|
||||
|
||||
const phone = setup({ oneAtATime: true });
|
||||
act(() => phone.result.current.setText(','));
|
||||
await act(async () => {
|
||||
await phone.result.current.runImport();
|
||||
});
|
||||
expect(phone.toast.error).not.toHaveBeenCalled();
|
||||
expect(bulk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-007: one import at a time on the phone: a second tap while it runs is ignored', async () => {
|
||||
let finish: () => void = () => {};
|
||||
const bulk = vi.spyOn(packingApi, 'bulkImport').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve({ items: [], count: 2 });
|
||||
})
|
||||
);
|
||||
const { result } = setup({ oneAtATime: true });
|
||||
act(() => result.current.setText(LIST));
|
||||
let first: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
first = result.current.runImport();
|
||||
});
|
||||
expect(result.current.importing).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.runImport();
|
||||
});
|
||||
expect(bulk).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
finish();
|
||||
await first;
|
||||
});
|
||||
expect(result.current.importing).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-IMPORT-008: the desktop sends a second import while one runs, as it always has', async () => {
|
||||
const bulk = vi.spyOn(packingApi, 'bulkImport').mockImplementation(() => new Promise(() => {}));
|
||||
const { result } = setup({ reportEmpty: true });
|
||||
act(() => result.current.setText(LIST));
|
||||
act(() => {
|
||||
void result.current.runImport();
|
||||
});
|
||||
act(() => {
|
||||
void result.current.runImport();
|
||||
});
|
||||
expect(bulk).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.importing).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo, useState, type ChangeEvent } from 'react';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { parseImportLines } from './packingListPanel.helpers';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
interface Toaster {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bulk packing import behind the desktop dialog and the phone sheet: the
|
||||
* pasted or loaded text, one item per line, and sending it in one request whose
|
||||
* items land straight in the trip store. `onImported` closes the surface.
|
||||
*
|
||||
* The desktop dialog says so when no line parses (`reportEmpty`); the phone sheet
|
||||
* keeps its button disabled then, and lets one import run at a time (`oneAtATime`).
|
||||
*/
|
||||
export function usePackingImport({
|
||||
tripId,
|
||||
t,
|
||||
toast,
|
||||
onImported,
|
||||
reportEmpty = false,
|
||||
oneAtATime = false,
|
||||
}: {
|
||||
tripId: number;
|
||||
t: Translate;
|
||||
toast: Toaster;
|
||||
onImported: () => void;
|
||||
reportEmpty?: boolean;
|
||||
oneAtATime?: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const parsed = useMemo(() => parseImportLines(text), [text]);
|
||||
|
||||
/** Loads a picked CSV, text or Markdown file into the text box. */
|
||||
const readFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') setText(reader.result);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
const lines = parseImportLines(text);
|
||||
if (lines.length === 0) {
|
||||
if (reportEmpty) toast.error(t('packing.importEmpty'));
|
||||
return;
|
||||
}
|
||||
if (oneAtATime) {
|
||||
if (importing) return;
|
||||
setImporting(true);
|
||||
}
|
||||
try {
|
||||
const result = await packingApi.bulkImport(tripId, lines);
|
||||
useTripStore.setState((s) => ({ packingItems: [...s.packingItems, ...(result.items || [])] }));
|
||||
toast.success(t('packing.importSuccess', { count: result.count }));
|
||||
setText('');
|
||||
onImported();
|
||||
} catch {
|
||||
toast.error(t('packing.importError'));
|
||||
} finally {
|
||||
if (oneAtATime) setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { text, setText, parsed, importing, readFile, runImport };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useTripStore, type TripStoreState } from '../../store/tripStore';
|
||||
import type { PackingItem } from '../../types';
|
||||
import type { PackingView } from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
import { usePackingItemActions } from './usePackingItemActions';
|
||||
|
||||
// FE-PACK-ITEMACT-001 to FE-PACK-ITEMACT-012
|
||||
|
||||
const t = (key: string) => key;
|
||||
const ZWSP = '';
|
||||
|
||||
function actions() {
|
||||
return {
|
||||
addPackingItem: vi.fn<TripStoreState['addPackingItem']>(async () => buildPackingItem()),
|
||||
updatePackingItem: vi.fn<TripStoreState['updatePackingItem']>(async () => buildPackingItem()),
|
||||
deletePackingItem: vi.fn<TripStoreState['deletePackingItem']>(async () => undefined),
|
||||
togglePackingItem: vi.fn<TripStoreState['togglePackingItem']>(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function setup({
|
||||
items = [] as PackingItem[],
|
||||
view = 'common' as PackingView,
|
||||
categories = [] as string[],
|
||||
placeholderFrom = 'items' as 'store' | 'items',
|
||||
currentUserId = 9 as number | null | undefined,
|
||||
} = {}) {
|
||||
const acts = actions();
|
||||
const toast = { error: vi.fn() };
|
||||
const rendered = renderHook(() =>
|
||||
usePackingItemActions({
|
||||
tripId: 7,
|
||||
items,
|
||||
view,
|
||||
currentUserId,
|
||||
categories,
|
||||
defaultCategory: 'Other',
|
||||
actions: acts,
|
||||
t,
|
||||
toast,
|
||||
placeholderFrom,
|
||||
})
|
||||
);
|
||||
return { ...rendered, acts, toast };
|
||||
}
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
describe('usePackingItemActions', () => {
|
||||
it('FE-PACK-ITEMACT-001: an item added to a category with a placeholder takes over that row', async () => {
|
||||
const placeholder = buildPackingItem({ id: 4, name: PACKING_PLACEHOLDER_NAME, category: 'Camp' });
|
||||
const { result, acts } = setup({ items: [placeholder] });
|
||||
await act(async () => {
|
||||
await result.current.addItemToCategory('Camp', 'Tent');
|
||||
});
|
||||
expect(acts.updatePackingItem).toHaveBeenCalledWith(7, 4, { name: 'Tent' });
|
||||
expect(acts.addPackingItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-002: the desktop looks the placeholder up in the store, the phone in the items it shows', async () => {
|
||||
const placeholder = buildPackingItem({ id: 4, name: PACKING_PLACEHOLDER_NAME, category: 'Camp' });
|
||||
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||
|
||||
const desktop = setup({ items: [], placeholderFrom: 'store' });
|
||||
await act(async () => {
|
||||
await desktop.result.current.addItemToCategory('Camp', 'Tent');
|
||||
});
|
||||
expect(desktop.acts.updatePackingItem).toHaveBeenCalledWith(7, 4, { name: 'Tent' });
|
||||
|
||||
const phone = setup({ items: [], placeholderFrom: 'items' });
|
||||
await act(async () => {
|
||||
await phone.result.current.addItemToCategory('Camp', 'Tent');
|
||||
});
|
||||
expect(phone.acts.updatePackingItem).not.toHaveBeenCalled();
|
||||
expect(phone.acts.addPackingItem).toHaveBeenCalledWith(7, { name: 'Tent', category: 'Camp', visibility: 'common' });
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-003: in my list a new item is shared the way the category already is (#2241)', async () => {
|
||||
const own = buildPackingItem({
|
||||
id: 5,
|
||||
name: 'Socks',
|
||||
category: 'Clothes',
|
||||
is_private: 1,
|
||||
owner_id: 9,
|
||||
recipients: [{ user_id: 3, username: 'ada' }],
|
||||
});
|
||||
const { result, acts } = setup({ items: [own], view: 'personal' });
|
||||
await act(async () => {
|
||||
await result.current.addItemToCategory('Clothes', 'Shirt');
|
||||
});
|
||||
expect(acts.addPackingItem).toHaveBeenCalledWith(7, {
|
||||
name: 'Shirt',
|
||||
category: 'Clothes',
|
||||
visibility: 'shared',
|
||||
recipient_ids: [3],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-004: a failed add is reported', async () => {
|
||||
const { result, acts, toast } = setup();
|
||||
acts.addPackingItem.mockRejectedValueOnce(new Error('x'));
|
||||
await act(async () => {
|
||||
await result.current.addItemToCategory('Camp', 'Tent');
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.addError');
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-005: the last real item of a category turns back into its placeholder, unchecked first', async () => {
|
||||
const last = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes', checked: 1 });
|
||||
const { result, acts } = setup({ items: [last] });
|
||||
await act(async () => {
|
||||
await result.current.deleteItem(last);
|
||||
});
|
||||
expect(acts.togglePackingItem).toHaveBeenCalledWith(7, 5, false);
|
||||
expect(acts.updatePackingItem).toHaveBeenCalledWith(7, 5, {
|
||||
name: PACKING_PLACEHOLDER_NAME,
|
||||
weight_grams: null,
|
||||
bag_id: null,
|
||||
quantity: 1,
|
||||
});
|
||||
expect(acts.deletePackingItem).not.toHaveBeenCalled();
|
||||
expect(acts.togglePackingItem.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
acts.updatePackingItem.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-006: any other item is deleted, and a failure is reported', async () => {
|
||||
const rope = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes' });
|
||||
const knot = buildPackingItem({ id: 6, name: 'Knot', category: 'Ropes' });
|
||||
const { result, acts, toast } = setup({ items: [rope, knot] });
|
||||
await act(async () => {
|
||||
await result.current.deleteItem(rope);
|
||||
});
|
||||
expect(acts.deletePackingItem).toHaveBeenCalledWith(7, 5);
|
||||
expect(acts.togglePackingItem).not.toHaveBeenCalled();
|
||||
acts.deletePackingItem.mockRejectedValueOnce(new Error('x'));
|
||||
await act(async () => {
|
||||
await result.current.deleteItem(rope);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError');
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-007: renaming moves every item of the category, the uncategorised under the default name', async () => {
|
||||
const a = buildPackingItem({ id: 1, category: 'Camp' });
|
||||
const b = buildPackingItem({ id: 2, category: null });
|
||||
const c = buildPackingItem({ id: 3, category: 'Camp' });
|
||||
const { result, acts } = setup({ items: [a, b, c] });
|
||||
await act(async () => {
|
||||
await result.current.renameCategory('Camp', 'Outdoor');
|
||||
});
|
||||
expect(acts.updatePackingItem.mock.calls).toEqual([
|
||||
[7, 1, { category: 'Outdoor' }],
|
||||
[7, 3, { category: 'Outdoor' }],
|
||||
]);
|
||||
acts.updatePackingItem.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.renameCategory('Other', 'Misc');
|
||||
});
|
||||
expect(acts.updatePackingItem.mock.calls).toEqual([[7, 2, { category: 'Misc' }]]);
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-008: a failed rename stops: the desktop gets the error, the phone a message', async () => {
|
||||
const a = buildPackingItem({ id: 1, category: 'Camp' });
|
||||
const c = buildPackingItem({ id: 3, category: 'Camp' });
|
||||
const { result, acts, toast } = setup({ items: [a, c] });
|
||||
acts.updatePackingItem.mockRejectedValueOnce(new Error('x'));
|
||||
await expect(result.current.renameCategory('Camp', 'Outdoor')).rejects.toThrow('x');
|
||||
expect(acts.updatePackingItem).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
|
||||
acts.updatePackingItem.mockClear();
|
||||
acts.updatePackingItem.mockRejectedValueOnce(new Error('x'));
|
||||
await act(async () => {
|
||||
await result.current.renameCategoryReporting('Camp', 'Outdoor');
|
||||
});
|
||||
expect(acts.updatePackingItem).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.renameError');
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-009: emptying a category tries every item and reports a failure once', async () => {
|
||||
const items = [1, 2, 3].map((id) => buildPackingItem({ id, category: 'Camp' }));
|
||||
const { result, acts, toast } = setup({ items });
|
||||
acts.deletePackingItem.mockRejectedValueOnce(new Error('x')).mockRejectedValueOnce(new Error('y'));
|
||||
await act(async () => {
|
||||
await result.current.deleteCategoryItems(items);
|
||||
});
|
||||
expect(acts.deletePackingItem.mock.calls.map((c) => c[1])).toEqual([1, 2, 3]);
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError');
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-010: clearing removes every checked item of the trip, in both views', async () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, checked: 1 }),
|
||||
buildPackingItem({ id: 2, checked: 0 }),
|
||||
buildPackingItem({ id: 3, checked: 1, is_private: 1 }),
|
||||
];
|
||||
const { result, acts, toast } = setup({ items });
|
||||
await act(async () => {
|
||||
await result.current.clearChecked();
|
||||
});
|
||||
expect(acts.deletePackingItem.mock.calls.map((c) => c[1])).toEqual([1, 3]);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
acts.deletePackingItem.mockRejectedValueOnce(new Error('x'));
|
||||
await act(async () => {
|
||||
await result.current.clearChecked();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError');
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-011: a new category needs a name, and a taken one gets an invisible suffix', async () => {
|
||||
const { result, acts } = setup({ categories: ['Camp', `Camp${ZWSP}`], view: 'personal' });
|
||||
await act(async () => {
|
||||
await result.current.addNewCategory();
|
||||
});
|
||||
expect(acts.addPackingItem).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
result.current.setAddingCategory(true);
|
||||
result.current.setNewCategoryName(' Camp ');
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.addNewCategory();
|
||||
});
|
||||
expect(acts.addPackingItem).toHaveBeenCalledWith(7, {
|
||||
name: PACKING_PLACEHOLDER_NAME,
|
||||
category: `Camp${ZWSP}${ZWSP}`,
|
||||
visibility: 'personal',
|
||||
});
|
||||
expect(result.current.newCategoryName).toBe('');
|
||||
expect(result.current.addingCategory).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-ITEMACT-012: a failed new category is reported and keeps the field open', async () => {
|
||||
const { result, acts, toast } = setup();
|
||||
acts.addPackingItem.mockRejectedValueOnce(new Error('x'));
|
||||
act(() => {
|
||||
result.current.setAddingCategory(true);
|
||||
result.current.setNewCategoryName('Camp');
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.addNewCategory();
|
||||
});
|
||||
expect(acts.addPackingItem).toHaveBeenCalledWith(7, {
|
||||
name: PACKING_PLACEHOLDER_NAME,
|
||||
category: 'Camp',
|
||||
visibility: 'common',
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.toast.addError');
|
||||
expect(result.current.newCategoryName).toBe('Camp');
|
||||
expect(result.current.addingCategory).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTripStore, type TripStoreState } from '../../store/tripStore';
|
||||
import type { PackingItem } from '../../types';
|
||||
import { isLastCustomItemInCategory, isPackingPlaceholder, type PackingView } from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
import { newItemSharing } from './packingListPanel.helpers';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
interface Toaster {
|
||||
error: (message: string) => void;
|
||||
}
|
||||
type PackingActions = Pick<
|
||||
TripStoreState,
|
||||
'addPackingItem' | 'updatePackingItem' | 'deletePackingItem' | 'togglePackingItem'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The item and category writes behind both packing lists: adding an item to a
|
||||
* category, deleting one, renaming, emptying and adding categories, and removing
|
||||
* every checked item. Each surface asks for confirmation its own way first.
|
||||
*
|
||||
* `placeholderFrom` says where a category's `...` placeholder is looked up when
|
||||
* an item is added: the desktop reads the store as it is at that moment, the
|
||||
* phone the items it rendered.
|
||||
*/
|
||||
export function usePackingItemActions({
|
||||
tripId,
|
||||
items,
|
||||
view,
|
||||
currentUserId,
|
||||
categories,
|
||||
defaultCategory,
|
||||
actions,
|
||||
t,
|
||||
toast,
|
||||
placeholderFrom,
|
||||
}: {
|
||||
tripId: number;
|
||||
items: PackingItem[];
|
||||
view: PackingView;
|
||||
currentUserId: number | null | undefined;
|
||||
/** The categories of the active view, in display order. */
|
||||
categories: string[];
|
||||
defaultCategory: string;
|
||||
actions: PackingActions;
|
||||
t: Translate;
|
||||
toast: Toaster;
|
||||
placeholderFrom: 'store' | 'items';
|
||||
}) {
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = actions;
|
||||
const [addingCategory, setAddingCategory] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
const addItemToCategory = async (category: string, name: string) => {
|
||||
try {
|
||||
// Reuse the '...' placeholder slot when the category already has one, so a
|
||||
// freshly-emptied category keeps its position (and therefore its colour)
|
||||
// instead of the new item being appended to the end of the list.
|
||||
const pool = placeholderFrom === 'store' ? useTripStore.getState().packingItems : items;
|
||||
const placeholder = pool.find((i) => i.category === category && isPackingPlaceholder(i));
|
||||
if (placeholder) {
|
||||
await updatePackingItem(tripId, placeholder.id, { name });
|
||||
} else {
|
||||
// New items inherit the active view's tier, and in "my list" the sharing the
|
||||
// category's own items agree on (#2241).
|
||||
await addPackingItem(tripId, {
|
||||
name,
|
||||
category,
|
||||
...newItemSharing(items, category, view, currentUserId),
|
||||
} as Parameters<typeof addPackingItem>[1]);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.addError'));
|
||||
}
|
||||
};
|
||||
|
||||
// Deleting an item from a row. When it is the last item of a user-created
|
||||
// category, turn that row back into the '...' placeholder in place rather than
|
||||
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
||||
// so the category neither disappears nor jumps to the end. The default
|
||||
// (uncategorized) group and the placeholder row itself are deleted normally:
|
||||
// removing the placeholder is how an empty category is dismissed.
|
||||
const deleteItem = async (item: PackingItem) => {
|
||||
try {
|
||||
if (isLastCustomItemInCategory(item, items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false);
|
||||
await updatePackingItem(tripId, item.id, {
|
||||
name: PACKING_PLACEHOLDER_NAME,
|
||||
weight_grams: null,
|
||||
bag_id: null,
|
||||
quantity: 1,
|
||||
});
|
||||
} else {
|
||||
await deletePackingItem(tripId, item.id);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
/** Moves every item of a category to the new name, one write each; a failure stops it and is thrown. */
|
||||
const renameCategory = async (oldName: string, newName: string) => {
|
||||
const toUpdate = items.filter((i) => (i.category || defaultCategory) === oldName);
|
||||
for (const item of toUpdate) {
|
||||
await updatePackingItem(tripId, item.id, { category: newName });
|
||||
}
|
||||
};
|
||||
|
||||
/** The same, with a failure reported here rather than thrown to the caller. */
|
||||
const renameCategoryReporting = async (oldName: string, newName: string) => {
|
||||
try {
|
||||
await renameCategory(oldName, newName);
|
||||
} catch {
|
||||
toast.error(t('packing.toast.renameError'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategoryItems = async (catItems: PackingItem[]) => {
|
||||
let failed = false;
|
||||
for (const item of catItems) {
|
||||
try {
|
||||
await deletePackingItem(tripId, item.id);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed) toast.error(t('packing.toast.deleteError'));
|
||||
};
|
||||
|
||||
/** Removes every checked item of the trip, not only those of the active view. */
|
||||
const clearChecked = async () => {
|
||||
let failed = false;
|
||||
for (const item of items.filter((i) => i.checked)) {
|
||||
try {
|
||||
await deletePackingItem(tripId, item.id);
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed) toast.error(t('packing.toast.deleteError'));
|
||||
};
|
||||
|
||||
const addNewCategory = async () => {
|
||||
if (!newCategoryName.trim()) return;
|
||||
let catName = newCategoryName.trim();
|
||||
// Allow duplicate display names: append invisible zero-width spaces to make them unique internally.
|
||||
while (categories.includes(catName)) {
|
||||
catName += '';
|
||||
}
|
||||
try {
|
||||
await addPackingItem(tripId, {
|
||||
name: PACKING_PLACEHOLDER_NAME,
|
||||
category: catName,
|
||||
visibility: view === 'personal' ? 'personal' : 'common',
|
||||
} as Parameters<typeof addPackingItem>[1]);
|
||||
setNewCategoryName('');
|
||||
setAddingCategory(false);
|
||||
} catch {
|
||||
toast.error(t('packing.toast.addError'));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
addingCategory,
|
||||
setAddingCategory,
|
||||
newCategoryName,
|
||||
setNewCategoryName,
|
||||
addItemToCategory,
|
||||
deleteItem,
|
||||
renameCategory,
|
||||
renameCategoryReporting,
|
||||
deleteCategoryItems,
|
||||
clearChecked,
|
||||
addNewCategory,
|
||||
};
|
||||
}
|
||||
@@ -495,6 +495,20 @@ describe('usePackingList — bags', () => {
|
||||
expect(result.current.showAddBag).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-059: a create that answers without a bag still clears the form', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/bags', () => HttpResponse.json({})))
|
||||
const { result } = renderPanel()
|
||||
await settled()
|
||||
|
||||
act(() => { result.current.setNewBagName('Duffel'); result.current.setShowAddBag(true) })
|
||||
await act(async () => { await result.current.handleCreateBag() })
|
||||
|
||||
expect(result.current.bags).toEqual([undefined])
|
||||
expect(result.current.newBagName).toBe('')
|
||||
expect(result.current.showAddBag).toBe(false)
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-031: a failing bag create surfaces a save error', async () => {
|
||||
server.use(http.post('/api/trips/1/packing/bags', () => new HttpResponse(null, { status: 500 })))
|
||||
const { result } = renderPanel()
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi } from '../../api/client'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { newItemSharing, parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { usePackingBags } from './usePackingBags'
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||
import { usePackingImport } from './usePackingImport'
|
||||
import { usePackingItemActions } from './usePackingItemActions'
|
||||
import { usePackingTemplates } from './usePackingTemplates'
|
||||
|
||||
export type { CategoryAssignee } from './usePackingCategoryAssignees'
|
||||
|
||||
const PACKING_SORT_KEY = 'trek:packing-sort'
|
||||
|
||||
@@ -25,13 +29,6 @@ export interface TripMember {
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
export interface CategoryAssignee {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
export interface PackingListPanelProps {
|
||||
tripId: number
|
||||
items: PackingItem[]
|
||||
@@ -68,8 +65,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
const [ownView, setOwnView] = useState<'common' | 'personal'>('common')
|
||||
const view = viewProp ?? ownView
|
||||
const setView = onViewChange ?? setOwnView
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems,
|
||||
setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = useTripStore()
|
||||
const can = useCanDo()
|
||||
@@ -82,7 +77,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
// Trip members & category assignees
|
||||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi.getMembers(tripId).then(data => {
|
||||
@@ -91,136 +85,39 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest })))
|
||||
setTripMembers(all)
|
||||
}).catch(() => {})
|
||||
packingApi.getCategoryAssignees(tripId).then(data => {
|
||||
setCategoryAssignees(data.assignees || {})
|
||||
}).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
const handleSetAssignees = async (category: string, userIds: number[]) => {
|
||||
try {
|
||||
const data = await packingApi.setCategoryAssignees(tripId, category, userIds)
|
||||
setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] }))
|
||||
} catch {
|
||||
toast.error(t('packing.toast.saveError'))
|
||||
}
|
||||
}
|
||||
const { categoryAssignees, setAssignees: handleSetAssignees } = usePackingCategoryAssignees({ tripId, t, toast })
|
||||
|
||||
// Split by the active view (#858): Common = group pool (is_private 0), Personal =
|
||||
// my own + shared-to-me (is_private 1, already filtered to me by the server).
|
||||
const viewItems = useMemo(
|
||||
() => items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)),
|
||||
[items, view],
|
||||
)
|
||||
const viewItems = useMemo(() => packingViewItems(items, view), [items, view])
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const seen: string[] = []
|
||||
for (const item of viewItems) {
|
||||
const cat = item.category || t('packing.defaultCategory')
|
||||
if (!seen.includes(cat)) seen.push(cat)
|
||||
}
|
||||
return seen
|
||||
}, [viewItems, t])
|
||||
const allCategories = useMemo(() => packingCategoryOrder(viewItems, t('packing.defaultCategory')), [viewItems, t])
|
||||
|
||||
const gruppiert = useMemo(() => {
|
||||
const filtered = viewItems.filter(i => {
|
||||
if (filter === 'offen') return !i.checked
|
||||
if (filter === 'erledigt') return i.checked
|
||||
return true
|
||||
})
|
||||
const status = filter === 'offen' ? 'open' : filter === 'erledigt' ? 'done' : 'all'
|
||||
const groups: Record<string, PackingItem[]> = {}
|
||||
for (const item of filtered) {
|
||||
const kat = item.category || t('packing.defaultCategory')
|
||||
if (!groups[kat]) groups[kat] = []
|
||||
groups[kat].push(item)
|
||||
}
|
||||
if (sort === 'name') {
|
||||
for (const kat of Object.keys(groups)) groups[kat] = sortItemsByName(groups[kat], locale)
|
||||
for (const group of groupPackingItems(viewItems, status, t('packing.defaultCategory'))) {
|
||||
groups[group.category] = sort === 'name' ? sortItemsByName(group.items, locale) : group.items
|
||||
}
|
||||
return groups
|
||||
}, [viewItems, filter, sort, locale, t])
|
||||
|
||||
const abgehakt = viewItems.filter(i => i.checked).length
|
||||
const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||
try {
|
||||
// Reuse the '...' placeholder slot when the category already has one, so a
|
||||
// freshly-emptied category keeps its position (and therefore its colour)
|
||||
// instead of the new item being appended to the end of the list.
|
||||
const placeholder = useTripStore.getState().packingItems.find(
|
||||
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
|
||||
)
|
||||
if (placeholder) {
|
||||
await updatePackingItem(tripId, placeholder.id, { name })
|
||||
} else {
|
||||
// New items inherit the active view's tier, and in "my list" the sharing the
|
||||
// category's own items agree on (#2241).
|
||||
await addPackingItem(tripId, { name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters<typeof addPackingItem>[1])
|
||||
}
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
// Deleting an item from a row. When it is the last item of a user-created
|
||||
// category, turn that row back into the '...' placeholder in place rather than
|
||||
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
||||
// so the category neither disappears nor jumps to the end. The default
|
||||
// (uncategorized) group and the placeholder row itself are deleted normally —
|
||||
// removing the placeholder is how an empty category is dismissed.
|
||||
const handleDeleteItem = async (item: PackingItem) => {
|
||||
const category = item.category
|
||||
const isLastInCategory = !!category
|
||||
&& item.name !== PACKING_PLACEHOLDER_NAME
|
||||
&& !items.some(i => i.id !== item.id && i.category === category)
|
||||
try {
|
||||
if (isLastInCategory) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
await updatePackingItem(tripId, item.id, {
|
||||
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
||||
})
|
||||
} else {
|
||||
await deletePackingItem(tripId, item.id)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddNewCategory = async () => {
|
||||
if (!newCatName.trim()) return
|
||||
let catName = newCatName.trim()
|
||||
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
|
||||
while (allCategories.includes(catName)) {
|
||||
catName += ''
|
||||
}
|
||||
try {
|
||||
await addPackingItem(tripId, { name: '...', category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
|
||||
setNewCatName('')
|
||||
setAddingCategory(false)
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
|
||||
for (const item of toUpdate) {
|
||||
await updatePackingItem(tripId, item.id, { category: newName })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (catItems: PackingItem[]) => {
|
||||
let failed = false
|
||||
for (const item of catItems) {
|
||||
try { await deletePackingItem(tripId, item.id) } catch { failed = true }
|
||||
}
|
||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||
renameCategory: handleRenameCategory, deleteCategoryItems: handleDeleteCategory, clearChecked,
|
||||
} = usePackingItemActions({
|
||||
tripId, items, view, currentUserId, categories: allCategories, defaultCategory: t('packing.defaultCategory'),
|
||||
actions: { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem }, t, toast, placeholderFrom: 'store',
|
||||
})
|
||||
|
||||
const handleClearChecked = async () => {
|
||||
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
|
||||
let failed = false
|
||||
for (const item of items.filter(i => i.checked)) {
|
||||
try { await deletePackingItem(tripId, item.id) } catch { failed = true }
|
||||
}
|
||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||
await clearChecked()
|
||||
}
|
||||
|
||||
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
|
||||
@@ -229,9 +126,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const loadAddons = useAddonStore(s => s.loadAddons)
|
||||
const [bags, setBags] = useState<PackingBag[]>([])
|
||||
/** Server-summed weight of everything in no bag (#2191); null until the first load. */
|
||||
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null)
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
const [showAddBag, setShowAddBag] = useState(false)
|
||||
const [showBagModal, setShowBagModal] = useState(false)
|
||||
@@ -240,81 +134,22 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
if (!addonsLoaded) loadAddons()
|
||||
}, [addonsLoaded, loadAddons])
|
||||
|
||||
const reloadBags = useCallback(async () => {
|
||||
if (!bagTrackingEnabled) return
|
||||
try {
|
||||
const r = await packingApi.listBags(tripId)
|
||||
setBags(r.bags || [])
|
||||
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null)
|
||||
} catch {
|
||||
// Offline or a failed read: the surfaces fall back to the local sum
|
||||
// (see `serverWeightsFresh` below), so there is nothing to roll back.
|
||||
}
|
||||
}, [tripId, bagTrackingEnabled])
|
||||
|
||||
useEffect(() => { void reloadBags() }, [reloadBags])
|
||||
|
||||
// Bag weights are summed server-side across every member (#2191), so an item
|
||||
// this viewer may not even see still moves them. The item events cannot carry
|
||||
// that — a private item is delivered only to its owner, which is the very rule
|
||||
// that made the totals wrong — so the server pings the room content-free and
|
||||
// we re-read the numbers.
|
||||
useBagTotalsPing(bagTrackingEnabled, reloadBags)
|
||||
|
||||
// Bags are not part of the offline cache (no repo, no Dexie table), so while
|
||||
// offline the server totals are frozen at the last online read and cannot see
|
||||
// the optimistic item writes the mutation queue is holding. A stale absolute
|
||||
// number measured against an airline limit is worse than an honest partial
|
||||
// one, so offline the surfaces sum what they can see instead (#2191).
|
||||
const { offline } = useNetworkMode()
|
||||
const serverWeightsFresh = !offline
|
||||
const {
|
||||
bags, unassignedWeightGrams, serverWeightsFresh, tryCreateBag, createBag: handleCreateBagByName,
|
||||
deleteBag: handleDeleteBag, updateBag: handleUpdateBag, setBagMembers: handleSetBagMembers,
|
||||
} = usePackingBags({ tripId, bagTrackingEnabled, t, toast })
|
||||
|
||||
const handleCreateBag = async () => {
|
||||
if (!newBagName.trim()) return
|
||||
try {
|
||||
const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] })
|
||||
setBags(prev => [...prev, data.bag])
|
||||
if (await tryCreateBag(newBagName.trim())) {
|
||||
setNewBagName(''); setShowAddBag(false)
|
||||
} catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
|
||||
const handleCreateBagByName = async (name: string): Promise<PackingBag | undefined> => {
|
||||
try {
|
||||
const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] })
|
||||
setBags(prev => [...prev, data.bag])
|
||||
return data.bag
|
||||
} catch { toast.error(t('packing.toast.saveError')); return undefined }
|
||||
}
|
||||
|
||||
const handleDeleteBag = async (bagId: number) => {
|
||||
try {
|
||||
await packingApi.deleteBag(tripId, bagId)
|
||||
setBags(prev => prev.filter(b => b.id !== bagId))
|
||||
} catch { toast.error(t('packing.toast.deleteError')) }
|
||||
}
|
||||
|
||||
const handleUpdateBag = async (bagId: number, data: Record<string, any>) => {
|
||||
try {
|
||||
const result = await packingApi.updateBag(tripId, bagId, data)
|
||||
setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const handleSetBagMembers = async (bagId: number, userIds: number[]) => {
|
||||
try {
|
||||
const result = await packingApi.setBagMembers(tripId, bagId, userIds)
|
||||
setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
}
|
||||
|
||||
// Templates
|
||||
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
|
||||
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
|
||||
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
||||
const [saveTemplateName, setSaveTemplateName] = useState('')
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importText, setImportText] = useState('')
|
||||
const lastHandledImportSignal = useRef(openImportSignal)
|
||||
const lastHandledAddCategorySignal = useRef(addCategorySignal)
|
||||
const lastHandledSaveSignal = useRef(saveTemplateSignal)
|
||||
@@ -331,7 +166,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
setAddingCategory(true)
|
||||
}
|
||||
lastHandledAddCategorySignal.current = addCategorySignal
|
||||
}, [addCategorySignal])
|
||||
}, [addCategorySignal, setAddingCategory])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
|
||||
@@ -342,9 +177,14 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
const {
|
||||
templates: availableTemplates, applyingTemplate, applyTemplate: handleApplyTemplate,
|
||||
saveTemplateName, setSaveTemplateName, saveAsTemplate: handleSaveAsTemplate,
|
||||
} = usePackingTemplates({
|
||||
tripId, view, t, toast,
|
||||
onApplied: () => setShowTemplateDropdown(false),
|
||||
onSaved: () => setShowSaveTemplate(false),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTemplateDropdown) return
|
||||
@@ -355,53 +195,9 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showTemplateDropdown])
|
||||
|
||||
const handleApplyTemplate = async (templateId: number) => {
|
||||
setApplyingTemplate(true)
|
||||
try {
|
||||
const data = await packingApi.applyTemplate(tripId, templateId, view)
|
||||
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
|
||||
toast.success(t('packing.templateApplied', { count: data.count }))
|
||||
setShowTemplateDropdown(false)
|
||||
} catch {
|
||||
toast.error(t('packing.templateError'))
|
||||
} finally {
|
||||
setApplyingTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAsTemplate = async () => {
|
||||
if (!saveTemplateName.trim()) return
|
||||
try {
|
||||
await packingApi.saveAsTemplate(tripId, saveTemplateName.trim())
|
||||
toast.success(t('packing.templateSaved'))
|
||||
setShowSaveTemplate(false)
|
||||
setSaveTemplateName('')
|
||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkImport = async () => {
|
||||
const parsed = parseImportLines(importText)
|
||||
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
|
||||
try {
|
||||
const result = await packingApi.bulkImport(tripId, parsed)
|
||||
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
|
||||
toast.success(t('packing.importSuccess', { count: result.count }))
|
||||
setImportText('')
|
||||
setShowImportModal(false)
|
||||
} catch { toast.error(t('packing.importError')) }
|
||||
}
|
||||
|
||||
const handleCsvFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
|
||||
reader.readAsText(file)
|
||||
}
|
||||
const {
|
||||
text: importText, setText: setImportText, readFile: handleCsvFile, runImport: handleBulkImport,
|
||||
} = usePackingImport({ tripId, t, toast, onImported: () => setShowImportModal(false), reportEmpty: true })
|
||||
|
||||
const font = { fontFamily: "var(--font-system)" }
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { packingApi } from '../../api/client';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { PackingView } from './packingListModel';
|
||||
import { usePackingTemplates } from './usePackingTemplates';
|
||||
|
||||
// FE-PACK-TEMPL-001 to FE-PACK-TEMPL-007
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
const BEACH = { id: 4, name: 'Beach', item_count: 12 };
|
||||
const SKI = { id: 5, name: 'Ski', item_count: 30 };
|
||||
const EXISTING = buildPackingItem({ id: 1, name: 'Tent' });
|
||||
|
||||
function setup(view: PackingView = 'common') {
|
||||
const toast = { success: vi.fn(), error: vi.fn() };
|
||||
const onApplied = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
const rendered = renderHook(() => usePackingTemplates({ tripId: 7, view, t, toast, onApplied, onSaved }));
|
||||
return { ...rendered, toast, onApplied, onSaved };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useTripStore, { packingItems: [EXISTING] });
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('usePackingTemplates', () => {
|
||||
it('FE-PACK-TEMPL-001: lists the templates on hand for the trip, and nothing when that fails', async () => {
|
||||
const list = vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] });
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.templates).toEqual([BEACH]));
|
||||
expect(list).toHaveBeenCalledWith(7);
|
||||
|
||||
list.mockRejectedValue(new Error('offline'));
|
||||
const failed = setup();
|
||||
await act(async () => {});
|
||||
expect(failed.result.current.templates).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PACK-TEMPL-002: applying lands the items in the list being looked at and reports how many', async () => {
|
||||
vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] });
|
||||
const added = buildPackingItem({ id: 2, name: 'Towel', is_private: 1 });
|
||||
let finish: () => void = () => {};
|
||||
const apply = vi.spyOn(packingApi, 'applyTemplate').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve({ items: [added], count: 1 });
|
||||
})
|
||||
);
|
||||
const { result, toast, onApplied } = setup('personal');
|
||||
let done: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
done = result.current.applyTemplate(4);
|
||||
});
|
||||
expect(result.current.applyingTemplate).toBe(true);
|
||||
await act(async () => {
|
||||
finish();
|
||||
await done;
|
||||
});
|
||||
expect(apply).toHaveBeenCalledWith(7, 4, 'personal');
|
||||
expect(useTripStore.getState().packingItems).toEqual([EXISTING, added]);
|
||||
expect(toast.success).toHaveBeenCalledWith('packing.templateApplied:1');
|
||||
expect(onApplied).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.applyingTemplate).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-TEMPL-003: a template without items still reports success and keeps the list', async () => {
|
||||
vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [] });
|
||||
vi.spyOn(packingApi, 'applyTemplate').mockResolvedValue({ count: 0 });
|
||||
const { result, toast } = setup();
|
||||
await act(async () => {
|
||||
await result.current.applyTemplate(4);
|
||||
});
|
||||
expect(useTripStore.getState().packingItems).toEqual([EXISTING]);
|
||||
expect(toast.success).toHaveBeenCalledWith('packing.templateApplied:0');
|
||||
});
|
||||
|
||||
it('FE-PACK-TEMPL-004: a failed apply is reported and leaves the surface open', async () => {
|
||||
vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [] });
|
||||
vi.spyOn(packingApi, 'applyTemplate').mockRejectedValue(new Error('x'));
|
||||
const { result, toast, onApplied } = setup();
|
||||
await act(async () => {
|
||||
await result.current.applyTemplate(4);
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('packing.templateError');
|
||||
expect(onApplied).not.toHaveBeenCalled();
|
||||
expect(result.current.applyingTemplate).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PACK-TEMPL-005: saving needs a name, then saves it trimmed, clears it and reloads the list', async () => {
|
||||
const list = vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] });
|
||||
const save = vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true });
|
||||
const { result, toast, onSaved } = setup();
|
||||
await waitFor(() => expect(result.current.templates).toEqual([BEACH]));
|
||||
|
||||
act(() => result.current.setSaveTemplateName(' '));
|
||||
await act(async () => {
|
||||
await result.current.saveAsTemplate();
|
||||
});
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
list.mockResolvedValue({ templates: [BEACH, SKI] });
|
||||
act(() => result.current.setSaveTemplateName(' Ski '));
|
||||
await act(async () => {
|
||||
await result.current.saveAsTemplate();
|
||||
});
|
||||
expect(save).toHaveBeenCalledWith(7, 'Ski');
|
||||
expect(toast.success).toHaveBeenCalledWith('packing.templateSaved');
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.saveTemplateName).toBe('');
|
||||
await waitFor(() => expect(result.current.templates).toEqual([BEACH, SKI]));
|
||||
});
|
||||
|
||||
it('FE-PACK-TEMPL-006: a failed save is reported and keeps the name typed', async () => {
|
||||
vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [] });
|
||||
vi.spyOn(packingApi, 'saveAsTemplate').mockRejectedValue(new Error('x'));
|
||||
const { result, toast, onSaved } = setup();
|
||||
act(() => result.current.setSaveTemplateName('Ski'));
|
||||
await act(async () => {
|
||||
await result.current.saveAsTemplate();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(result.current.saveTemplateName).toBe('Ski');
|
||||
});
|
||||
|
||||
it('FE-PACK-TEMPL-007: a list that fails to reload after a save keeps the templates it had', async () => {
|
||||
const list = vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] });
|
||||
vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true });
|
||||
const { result, toast } = setup();
|
||||
await waitFor(() => expect(result.current.templates).toEqual([BEACH]));
|
||||
list.mockRejectedValue(new Error('offline'));
|
||||
act(() => result.current.setSaveTemplateName('Ski'));
|
||||
await act(async () => {
|
||||
await result.current.saveAsTemplate();
|
||||
});
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.templates).toEqual([BEACH]);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { PackingView } from './packingListModel';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
interface Toaster {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface PackingTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
item_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packing templates for both packing lists: the trip's template list, applying
|
||||
* one into the list being looked at, and saving the trip's list as a new one.
|
||||
* Each surface closes its own menu, dropdown or field through `onApplied` and
|
||||
* `onSaved`, which run where the surface used to close it.
|
||||
*/
|
||||
export function usePackingTemplates({
|
||||
tripId,
|
||||
view,
|
||||
t,
|
||||
toast,
|
||||
onApplied,
|
||||
onSaved,
|
||||
}: {
|
||||
tripId: number;
|
||||
view: PackingView;
|
||||
t: Translate;
|
||||
toast: Toaster;
|
||||
onApplied: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [templates, setTemplates] = useState<PackingTemplate[]>([]);
|
||||
const [applyingTemplate, setApplyingTemplate] = useState(false);
|
||||
const [saveTemplateName, setSaveTemplateName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
packingApi
|
||||
.listTemplates(tripId)
|
||||
.then((d) => setTemplates(d.templates || []))
|
||||
.catch(() => {
|
||||
// Without the list there is simply nothing to apply.
|
||||
});
|
||||
}, [tripId]);
|
||||
|
||||
const applyTemplate = async (templateId: number) => {
|
||||
setApplyingTemplate(true);
|
||||
try {
|
||||
// Land the items in the list the user is looking at: without the
|
||||
// visibility the API defaults to 'common' and they vanish from My list.
|
||||
const data = await packingApi.applyTemplate(tripId, templateId, view);
|
||||
useTripStore.setState((s) => ({ packingItems: [...s.packingItems, ...(data.items || [])] }));
|
||||
toast.success(t('packing.templateApplied', { count: data.count }));
|
||||
onApplied();
|
||||
} catch {
|
||||
toast.error(t('packing.templateError'));
|
||||
} finally {
|
||||
setApplyingTemplate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAsTemplate = async () => {
|
||||
if (!saveTemplateName.trim()) return;
|
||||
try {
|
||||
await packingApi.saveAsTemplate(tripId, saveTemplateName.trim());
|
||||
toast.success(t('packing.templateSaved'));
|
||||
setSaveTemplateName('');
|
||||
onSaved();
|
||||
packingApi
|
||||
.listTemplates(tripId)
|
||||
.then((d) => setTemplates(d.templates || []))
|
||||
.catch(() => {
|
||||
// The template is saved; the list catches up on the next load.
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return { templates, applyingTemplate, applyTemplate, saveTemplateName, setSaveTemplateName, saveAsTemplate };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { FIELD_CLS, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import type { PackingBag, PackingItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { formatWeight } from './listsModel'
|
||||
import { formatWeight } from '../../../../components/Packing/packingListModel'
|
||||
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from '../../../../components/Packing/packingListPanel.helpers'
|
||||
|
||||
export interface MBagsSheetProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import type { PackingItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { isPackingPlaceholder } from './listsModel'
|
||||
import { isPackingPlaceholder } from '../../../../components/Packing/packingListModel'
|
||||
|
||||
export interface MPackItemSheetProps {
|
||||
planner: TripPlanner
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { packingApi } from '../../../../api/client'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { parseImportLines } from '../../../../components/Packing/packingListPanel.helpers'
|
||||
import { usePackingImport } from '../../../../components/Packing/usePackingImport'
|
||||
import { PACKING_IMPORT_ACCEPT } from '../../../../components/Packing/packingListPanel.constants'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
@@ -16,43 +14,15 @@ export interface MPackingImportSheetProps {
|
||||
|
||||
/**
|
||||
* Bulk packing import (spec 03 §4.2 action-menu "Import"): one item per line,
|
||||
* `Category, Name, Weight(g), Bag, checked` — same parser + endpoint as the
|
||||
* desktop bulk-import modal (`packingListPanel.helpers.parseImportLines` +
|
||||
* `packingApi.bulkImport`), appended straight into the trip store so both
|
||||
* surfaces stay consistent.
|
||||
* `Category, Name, Weight(g), Bag, checked`, run by the same `usePackingImport`
|
||||
* as the desktop bulk-import dialog, which appends the result straight into the
|
||||
* trip store so both surfaces stay consistent.
|
||||
*/
|
||||
export default function MPackingImportSheet({ planner, open, onClose }: MPackingImportSheetProps) {
|
||||
const { t, toast, tripId } = planner
|
||||
const [text, setText] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const parsed = parseImportLines(text)
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { if (typeof reader.result === 'string') setText(reader.result) }
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (parsed.length === 0 || importing) return
|
||||
setImporting(true)
|
||||
try {
|
||||
const result = await packingApi.bulkImport(tripId, parsed)
|
||||
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
|
||||
toast.success(t('packing.importSuccess', { count: result.count }))
|
||||
setText('')
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('packing.importError'))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
const { text, setText, parsed, importing, readFile: handleFile, runImport: handleImport } =
|
||||
usePackingImport({ tripId, t, toast, onImported: onClose, oneAtATime: true })
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={t('packing.importTitle')}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Briefcase, Check, CheckCheck, ChevronDown, ChevronUp, HandHelping, Minus,
|
||||
@@ -8,23 +8,21 @@ import {
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { packingApi } from '../../../../api/client'
|
||||
import { useNetworkMode } from '../../../../hooks/useNetworkMode'
|
||||
import { useBagTotalsPing } from '../../../../components/Packing/useBagTotalsPing'
|
||||
import type { PackingUpdateBagRequest } from '@trek/shared'
|
||||
import type { PackingBag, PackingItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { FIELD_CLS } from '../sheets/PlSheetChrome'
|
||||
import { TabScroller } from './tabChrome'
|
||||
import { katColor, newItemSharing, packedOf } from '../../../../components/Packing/packingListPanel.helpers'
|
||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from '../../../../components/Packing/packingListPanel.constants'
|
||||
import { katColor, packedOf } from '../../../../components/Packing/packingListPanel.helpers'
|
||||
import {
|
||||
formatWeight, groupPackingItems, isLastCustomItemInCategory, isPackingPlaceholder,
|
||||
formatWeight, groupPackingItems, isPackingPlaceholder,
|
||||
packingCategoryOrder, packingProgress, packingViewItems,
|
||||
type PackingCategoryGroup, type PackingStatusFilter, type PackingView,
|
||||
} from './listsModel'
|
||||
} from '../../../../components/Packing/packingListModel'
|
||||
import { usePackingBags } from '../../../../components/Packing/usePackingBags'
|
||||
import { usePackingTemplates } from '../../../../components/Packing/usePackingTemplates'
|
||||
import { usePackingCategoryAssignees, type CategoryAssignee } from '../../../../components/Packing/usePackingCategoryAssignees'
|
||||
import { usePackingItemActions } from '../../../../components/Packing/usePackingItemActions'
|
||||
import MBagsSheet from './MBagsSheet'
|
||||
import MPackItemSheet from './MPackItemSheet'
|
||||
import MPackingImportSheet from './MPackingImportSheet'
|
||||
@@ -32,8 +30,6 @@ import PackingPrintPreview from '../../../../components/Packing/PackingPrintPrev
|
||||
import { usePackingExport } from '../../../../components/Packing/usePackingExport'
|
||||
|
||||
type ActionView = 'menu' | 'apply' | 'save'
|
||||
interface CategoryAssignee { user_id: number; username: string }
|
||||
interface PackingTemplate { id: number; name: string; item_count: number }
|
||||
|
||||
/**
|
||||
* Packing sub-tab (spec 03 §4.1-4.4): progress card, action menu (remove
|
||||
@@ -59,50 +55,22 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [actionsOpen, setActionsOpen] = useState(false)
|
||||
const [actionView, setActionView] = useState<ActionView>('menu')
|
||||
const [saveTemplateName, setSaveTemplateName] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||
const [showBagsSheet, setShowBagsSheet] = useState(false)
|
||||
const [showImportSheet, setShowImportSheet] = useState(false)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [deleteCategoryTarget, setDeleteCategoryTarget] = useState<{ name: string; items: PackingItem[] } | null>(null)
|
||||
|
||||
const [bags, setBags] = useState<PackingBag[]>([])
|
||||
/** Server-summed weight of everything in no bag (#2191); null until first load. */
|
||||
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null)
|
||||
const [templates, setTemplates] = useState<PackingTemplate[]>([])
|
||||
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
|
||||
|
||||
const reloadBags = useCallback(async () => {
|
||||
if (!bagTrackingEnabled) return
|
||||
try {
|
||||
const r = await packingApi.listBags(tripId)
|
||||
setBags(r.bags || [])
|
||||
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null)
|
||||
} catch {
|
||||
// Offline or a failed read: the sheet falls back to the local sum.
|
||||
}
|
||||
}, [tripId, bagTrackingEnabled])
|
||||
|
||||
useEffect(() => { void reloadBags() }, [reloadBags])
|
||||
|
||||
// Bag weights are server-summed across every member (#2191), so an item this
|
||||
// viewer cannot see still moves them; the server pings the room content-free.
|
||||
useBagTotalsPing(bagTrackingEnabled, reloadBags)
|
||||
|
||||
// Bags have no offline cache, so their totals go stale the moment the device
|
||||
// does; offline the sheet sums what it can see instead (#2191).
|
||||
const { offline } = useNetworkMode()
|
||||
|
||||
useEffect(() => {
|
||||
packingApi.listTemplates(tripId).then(r => setTemplates(r.templates || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
packingApi.getCategoryAssignees(tripId).then(r => setCategoryAssignees(r.assignees || {})).catch(() => {})
|
||||
}, [tripId])
|
||||
const closeActions = () => {
|
||||
setActionsOpen(false)
|
||||
setActionView('menu')
|
||||
}
|
||||
const { bags, unassignedWeightGrams, serverWeightsFresh, createBag, updateBag, deleteBag, setBagMembers } =
|
||||
usePackingBags({ tripId, bagTrackingEnabled, t, toast })
|
||||
const { templates, applyTemplate, saveTemplateName, setSaveTemplateName, saveAsTemplate } =
|
||||
usePackingTemplates({ tripId, view, t, toast, onApplied: closeActions, onSaved: closeActions })
|
||||
const { categoryAssignees, setAssignees: setCategoryAssigneesFor } = usePackingCategoryAssignees({ tripId, t, toast })
|
||||
|
||||
const defaultCategory = t('packing.defaultCategory')
|
||||
const viewItems = useMemo(() => packingViewItems(items, view), [items, view])
|
||||
@@ -111,57 +79,16 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
|
||||
const progress = packingProgress(viewItems)
|
||||
const checkedCount = viewItems.filter(i => i.checked).length
|
||||
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName, addItemToCategory, deleteItem,
|
||||
renameCategoryReporting: renameCategory, deleteCategoryItems, clearChecked, addNewCategory,
|
||||
} = usePackingItemActions({
|
||||
tripId, items, view, currentUserId, categories: categoryOrder, defaultCategory,
|
||||
actions: tripActions, t, toast, placeholderFrom: 'items',
|
||||
})
|
||||
|
||||
const toggleCategory = (cat: string) => setCollapsed(c => ({ ...c, [cat]: !c[cat] }))
|
||||
|
||||
const addItemToCategory = async (category: string, name: string) => {
|
||||
try {
|
||||
const placeholder = items.find(i => i.category === category && isPackingPlaceholder(i))
|
||||
if (placeholder) {
|
||||
await tripActions.updatePackingItem(tripId, placeholder.id, { name })
|
||||
} else {
|
||||
await tripActions.addPackingItem(
|
||||
tripId,
|
||||
// In "my list", shared the way the category's own items all are (#2241).
|
||||
{ name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters<typeof tripActions.addPackingItem>[1],
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.addError'))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (item: PackingItem) => {
|
||||
try {
|
||||
if (isLastCustomItemInCategory(item, items)) {
|
||||
if (item.checked) await tripActions.togglePackingItem(tripId, item.id, false)
|
||||
await tripActions.updatePackingItem(tripId, item.id, {
|
||||
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
||||
})
|
||||
} else {
|
||||
await tripActions.deletePackingItem(tripId, item.id)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const renameCategory = async (oldName: string, newName: string) => {
|
||||
const toUpdate = items.filter(i => (i.category || defaultCategory) === oldName)
|
||||
try {
|
||||
for (const item of toUpdate) await tripActions.updatePackingItem(tripId, item.id, { category: newName })
|
||||
} catch {
|
||||
toast.error(t('packing.toast.renameError'))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCategoryItems = async (catItems: PackingItem[]) => {
|
||||
let failed = false
|
||||
for (const item of catItems) {
|
||||
try { await tripActions.deletePackingItem(tripId, item.id) } catch { failed = true }
|
||||
}
|
||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
|
||||
const checkAllInCategory = async (catItems: PackingItem[]) => {
|
||||
try {
|
||||
for (const item of catItems) if (!item.checked) await tripActions.togglePackingItem(tripId, item.id, true)
|
||||
@@ -177,103 +104,6 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
|
||||
}
|
||||
}
|
||||
|
||||
const addNewCategory = async () => {
|
||||
const trimmed = newCategoryName.trim()
|
||||
if (!trimmed) return
|
||||
let catName = trimmed
|
||||
while (categoryOrder.includes(catName)) catName += ''
|
||||
try {
|
||||
await tripActions.addPackingItem(
|
||||
tripId,
|
||||
{ name: PACKING_PLACEHOLDER_NAME, category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof tripActions.addPackingItem>[1],
|
||||
)
|
||||
setNewCategoryName('')
|
||||
setAddingCategory(false)
|
||||
} catch {
|
||||
toast.error(t('packing.toast.addError'))
|
||||
}
|
||||
}
|
||||
|
||||
const clearChecked = async () => {
|
||||
let failed = false
|
||||
for (const item of items.filter(i => i.checked)) {
|
||||
try { await tripActions.deletePackingItem(tripId, item.id) } catch { failed = true }
|
||||
}
|
||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
|
||||
const createBag = async (name: string): Promise<PackingBag | undefined> => {
|
||||
try {
|
||||
const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] })
|
||||
setBags(prev => [...prev, data.bag])
|
||||
return data.bag
|
||||
} catch {
|
||||
toast.error(t('packing.toast.saveError'))
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const updateBag = async (bagId: number, data: PackingUpdateBagRequest) => {
|
||||
try {
|
||||
const result = await packingApi.updateBag(tripId, bagId, data)
|
||||
setBags(prev => prev.map(b => (b.id === bagId ? { ...b, ...result.bag } : b)))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
const deleteBag = async (bagId: number) => {
|
||||
try {
|
||||
await packingApi.deleteBag(tripId, bagId)
|
||||
setBags(prev => prev.filter(b => b.id !== bagId))
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
const setBagMembers = async (bagId: number, userIds: number[]) => {
|
||||
try {
|
||||
const result = await packingApi.setBagMembers(tripId, bagId, userIds)
|
||||
setBags(prev => prev.map(b => (b.id === bagId ? { ...b, members: result.members } : b)))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const setCategoryAssigneesFor = async (category: string, userIds: number[]) => {
|
||||
try {
|
||||
const data = await packingApi.setCategoryAssignees(tripId, category, userIds)
|
||||
setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] }))
|
||||
} catch {
|
||||
toast.error(t('packing.toast.saveError'))
|
||||
}
|
||||
}
|
||||
|
||||
const applyTemplate = async (templateId: number) => {
|
||||
try {
|
||||
// Land the items in the list the user is looking at — without the
|
||||
// visibility the API defaults to 'common' and they vanish from My list.
|
||||
const data = await packingApi.applyTemplate(tripId, templateId, view)
|
||||
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
|
||||
toast.success(t('packing.templateApplied', { count: data.count }))
|
||||
setActionsOpen(false)
|
||||
setActionView('menu')
|
||||
} catch {
|
||||
toast.error(t('packing.templateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveAsTemplate = async () => {
|
||||
if (!saveTemplateName.trim()) return
|
||||
try {
|
||||
await packingApi.saveAsTemplate(tripId, saveTemplateName.trim())
|
||||
toast.success(t('packing.templateSaved'))
|
||||
setSaveTemplateName('')
|
||||
setActionsOpen(false)
|
||||
setActionView('menu')
|
||||
packingApi.listTemplates(tripId).then(r => setTemplates(r.templates || [])).catch(() => {})
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const filterPillCls = (active: boolean) =>
|
||||
`flex-1 whitespace-nowrap rounded-full px-2 py-[6px] text-center text-[0.71875rem] font-semibold ${
|
||||
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
|
||||
@@ -507,7 +337,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
|
||||
bags={bags}
|
||||
items={items}
|
||||
unassignedWeightGrams={unassignedWeightGrams}
|
||||
serverWeightsFresh={!offline}
|
||||
serverWeightsFresh={serverWeightsFresh}
|
||||
tripMembers={tripMembers}
|
||||
canEdit={canEdit}
|
||||
currentUserId={currentUserId}
|
||||
|
||||
@@ -1,110 +1,12 @@
|
||||
import type { PackingItem } from '../../../../types'
|
||||
import { PACKING_PLACEHOLDER_NAME } from '../../../../components/Packing/packingListPanel.constants'
|
||||
import { STATUS_COLOR } from './tabModel'
|
||||
|
||||
/**
|
||||
* Pure view-model helpers for the Listen tab (Packing + To-do), the real-data
|
||||
* counterpart to the demo's `PACK`/`TODOS` state (spec 03 §4). No React, no
|
||||
* side effects — grouping/filtering/sorting only, mirroring the desktop hooks
|
||||
* (`usePackingListPanel.ts`, `useTodoList.ts`) closely enough that switching a
|
||||
* filter here and there produces the same buckets.
|
||||
* The phone's priority tokens for the Listen tab's to-do list (spec 03 §4.7).
|
||||
* The grouping, filtering and counting behind both lists is shared with the
|
||||
* desktop panels: `components/Packing/packingListModel.ts` and
|
||||
* `components/Todo/todoListModel.ts`.
|
||||
*/
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type PackingView = 'common' | 'personal'
|
||||
export type PackingStatusFilter = 'all' | 'open' | 'done'
|
||||
|
||||
/** Three-tier sharing split (#858): Common = group pool, Personal = mine + shared-to-me. */
|
||||
export function packingViewItems(items: PackingItem[], view: PackingView): PackingItem[] {
|
||||
return items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private))
|
||||
}
|
||||
|
||||
/** Category display order = first-appearance order within the active view (stable colours). */
|
||||
export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory: string): string[] {
|
||||
const seen: string[] = []
|
||||
for (const item of viewItems) {
|
||||
const cat = item.category || defaultCategory
|
||||
if (!seen.includes(cat)) seen.push(cat)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter(i => !i.checked)
|
||||
if (status === 'done') return items.filter(i => !!i.checked)
|
||||
return items
|
||||
}
|
||||
|
||||
export interface PackingCategoryGroup {
|
||||
category: string
|
||||
items: PackingItem[]
|
||||
}
|
||||
|
||||
/** Groups the view+status filtered items by category, in first-encounter order. */
|
||||
export function groupPackingItems(
|
||||
viewItems: PackingItem[],
|
||||
status: PackingStatusFilter,
|
||||
defaultCategory: string,
|
||||
): PackingCategoryGroup[] {
|
||||
const filtered = packingStatusFiltered(viewItems, status)
|
||||
const order: string[] = []
|
||||
const byCategory = new Map<string, PackingItem[]>()
|
||||
for (const item of filtered) {
|
||||
const cat = item.category || defaultCategory
|
||||
let bucket = byCategory.get(cat)
|
||||
if (!bucket) {
|
||||
bucket = []
|
||||
byCategory.set(cat, bucket)
|
||||
order.push(cat)
|
||||
}
|
||||
bucket.push(item)
|
||||
}
|
||||
return order.map(category => ({ category, items: byCategory.get(category) as PackingItem[] }))
|
||||
}
|
||||
|
||||
export interface PackingProgress {
|
||||
checked: number
|
||||
total: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
export function packingProgress(items: PackingItem[]): PackingProgress {
|
||||
const checked = items.filter(i => i.checked).length
|
||||
const total = items.length
|
||||
return { checked, total, pct: total > 0 ? Math.round((checked / total) * 100) : 0 }
|
||||
}
|
||||
|
||||
/** "232 g" under 1000g, "1.2 kg" at/above — matches the demo's `gFmt` (spec 03 §4.4). */
|
||||
export function formatWeight(grams: number): string {
|
||||
return grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${Math.round(grams)} g`
|
||||
}
|
||||
|
||||
/** Weight an item contributes to a bag/total: unit weight times quantity. */
|
||||
export function packingItemWeight(item: Pick<PackingItem, 'weight_grams' | 'quantity'>): number {
|
||||
return (item.weight_grams || 0) * (item.quantity || 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when deleting `item` would empty its (custom) category — the row
|
||||
* should be reset to the `...` placeholder instead of removed so the
|
||||
* category keeps its position/colour (mirrors usePackingListPanel's
|
||||
* handleDeleteItem, #1289).
|
||||
*/
|
||||
export function isLastCustomItemInCategory(item: PackingItem, allItems: PackingItem[]): boolean {
|
||||
return (
|
||||
!!item.category &&
|
||||
item.name !== PACKING_PLACEHOLDER_NAME &&
|
||||
!allItems.some(i => i.id !== item.id && i.category === item.category)
|
||||
)
|
||||
}
|
||||
|
||||
export function isPackingPlaceholder(item: Pick<PackingItem, 'name'>): boolean {
|
||||
return item.name === PACKING_PLACEHOLDER_NAME
|
||||
}
|
||||
|
||||
// ── To-do ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** P1/P2/P3 colours (spec 03 §4.7) reuse the shared status-dot tokens so priority and status stay one palette. */
|
||||
export const PRIORITY_COLOR: Record<number, string> = {
|
||||
1: STATUS_COLOR.danger,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_LEVELS } from '../../../../src/mobile/screens/trip/tabs/listsModel';
|
||||
import {
|
||||
PRIORITY_COLOR,
|
||||
PRIORITY_LABEL,
|
||||
PRIORITY_LEVELS,
|
||||
formatWeight,
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
packingProgress,
|
||||
packingStatusFiltered,
|
||||
packingViewItems,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/listsModel';
|
||||
} from '../../../../src/components/Packing/packingListModel';
|
||||
import {
|
||||
filterTodoItems,
|
||||
isTodoOverdue,
|
||||
|
||||
Reference in New Issue
Block a user