mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere
This commit is contained in:
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { buildBudgetItem, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { budgetApi } from '../../api/client';
|
||||
import { addListener } from '../../api/websocket';
|
||||
import { clearAll, offlineDb } from '../../db/offlineDb';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -350,3 +351,24 @@ describe('useCostsLedger: offline (#2601)', () => {
|
||||
await waitFor(() => expect(settlementMock().mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCostsLedger: changes written elsewhere', () => {
|
||||
it('FE-BCAST-017: a burst of payment, split and guest events reads the balances once more', async () => {
|
||||
// Only this test's listeners: the mock keeps the calls of earlier tests otherwise.
|
||||
vi.mocked(addListener).mockClear();
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.settlement).toEqual(SETTLEMENT));
|
||||
const before = settlementMock().mock.calls.length;
|
||||
act(() => {
|
||||
for (const [listener] of vi.mocked(addListener).mock.calls) {
|
||||
listener({ type: 'budget:settlement-created', settlement: { id: 31 } });
|
||||
listener({ type: 'budget:member-paid-updated', itemId: 4, userId: 2, paid: 1 });
|
||||
listener({ type: 'member:updated', member: { id: 2, username: 'Bo' } });
|
||||
listener({ type: 'budget:created', item: { id: 99 } });
|
||||
}
|
||||
});
|
||||
await waitFor(() => expect(settlementMock()).toHaveBeenCalledTimes(before + 1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(settlementMock()).toHaveBeenCalledTimes(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { budgetRepo, type SettlementWriteResult } from '../../repo/budgetRepo';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { TripStoreState } from '../../store/tripStore';
|
||||
import { useServerPing } from '../../sync/useServerPing';
|
||||
import type { BudgetItem, Trip } from '../../types';
|
||||
import { downloadBlob } from '../../utils/fileDownload';
|
||||
import { localToday } from '../Planner/today';
|
||||
@@ -26,6 +27,18 @@ import {
|
||||
import { knownPersonNames } from './CostsPanel.helpers';
|
||||
import { useFreezeMissingRates } from './useFreezeMissingRates';
|
||||
|
||||
/** The events after which the balances are read again from the server. */
|
||||
const SETTLEMENT_RELOAD_EVENTS = [
|
||||
'budget:settlement-created',
|
||||
'budget:settlement-updated',
|
||||
'budget:settlement-deleted',
|
||||
'budget:updated',
|
||||
'budget:members-updated',
|
||||
'budget:member-paid-updated',
|
||||
'member:removed',
|
||||
'member:updated',
|
||||
] as const;
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
interface Toaster {
|
||||
error: (message: string) => void;
|
||||
@@ -91,15 +104,26 @@ export function useCostsLedger({
|
||||
|
||||
// The browser's own figure for the display currency goes along, for the server to
|
||||
// answer in it when it cannot fetch a quote itself.
|
||||
const readSettlement = useCallback(
|
||||
() =>
|
||||
budgetRepo
|
||||
.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
|
||||
.then((s) => {
|
||||
setSettlement(s);
|
||||
setSettlementError(false);
|
||||
})
|
||||
.catch(() => setSettlementError(true)),
|
||||
[tripId, base, tripCurrency, displayPerTrip]
|
||||
);
|
||||
const loadSettlement = useCallback(() => {
|
||||
budgetRepo
|
||||
.settlement(tripId, base, base !== tripCurrency ? displayPerTrip : null)
|
||||
.then((s) => {
|
||||
setSettlement(s);
|
||||
setSettlementError(false);
|
||||
})
|
||||
.catch(() => setSettlementError(true));
|
||||
}, [tripId, base, tripCurrency, displayPerTrip]);
|
||||
void readSettlement();
|
||||
}, [readSettlement]);
|
||||
|
||||
// A payment, a split, a paid mark or a renamed or removed guest written
|
||||
// elsewhere (a fellow traveller, the assistant) moves the balances without
|
||||
// changing the number of expenses, so those events read the settlement again,
|
||||
// one read per burst however many changes one assistant step applies.
|
||||
useServerPing(SETTLEMENT_RELOAD_EVENTS, true, readSettlement);
|
||||
|
||||
// The items reload when the trip changes; the settlement reloads on a trip or
|
||||
// currency change and when the number of expenses changes. Further refreshes are
|
||||
|
||||
@@ -5,11 +5,25 @@ import { useNavigate } from 'react-router';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import { useServerPing } from '../../sync/useServerPing';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
type MembershipEntry = CollectionMembership['lists'][number];
|
||||
|
||||
/**
|
||||
* The collection events that can change whether a place is saved: a place saved or
|
||||
* removed in another tab or through MCP, an invite to a list accepted, a list
|
||||
* deleted, left or taken away.
|
||||
*/
|
||||
const MEMBERSHIP_EVENTS = [
|
||||
'collections:updated',
|
||||
'collections:accepted',
|
||||
'collections:deleted',
|
||||
'collections:left',
|
||||
'collections:removed',
|
||||
] as const;
|
||||
|
||||
/** busyId while the "visited everywhere" bulk action runs. */
|
||||
export const VISITED_EVERYWHERE_BUSY = -1;
|
||||
|
||||
@@ -54,6 +68,19 @@ export function useSaveToCollection() {
|
||||
}
|
||||
}, [membershipQuery]);
|
||||
|
||||
// Saved elsewhere: the open picker reads its membership again, and the version
|
||||
// bump lets the inspector bookmark re-check, once per burst of events. Collection
|
||||
// events go to the user, not to a trip room, so opening a trip is no reason to re-check.
|
||||
useServerPing(
|
||||
MEMBERSHIP_EVENTS,
|
||||
true,
|
||||
async () => {
|
||||
bumpVersion();
|
||||
await refreshMembership();
|
||||
},
|
||||
{ rejoin: false }
|
||||
);
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target. The query is
|
||||
// derived from the target alone, so both change together.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useServerPing } from '../../sync/useServerPing'
|
||||
|
||||
/**
|
||||
* Re-read the bag weights when the server says they moved (#2191).
|
||||
*
|
||||
* The ping is content-free by design: totals are summed across every member,
|
||||
* including people whose items this client may not see, so the event cannot
|
||||
* carry the new numbers without leaking what produced them. `useServerPing`
|
||||
* carries the coalescing, the in-flight guard and the reconnect handling.
|
||||
* The events after which the bags are read again. The totals ping is content-free
|
||||
* by design (#2191): totals are summed across every member, including people whose
|
||||
* items this client may not see, so the event cannot carry the new numbers without
|
||||
* leaking what produced them. A bag created, renamed, deleted or given new members
|
||||
* elsewhere (a fellow traveller, the assistant) is read back the same way, so the
|
||||
* bag list has one source instead of a second copy patched from the payloads.
|
||||
*/
|
||||
const BAG_RELOAD_EVENTS = [
|
||||
'packing:bag-totals',
|
||||
'packing:bag-created',
|
||||
'packing:bag-updated',
|
||||
'packing:bag-deleted',
|
||||
'packing:bag-members-updated',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Re-read the bags when the server says they moved. `useServerPing` carries the
|
||||
* coalescing, the in-flight guard and the reconnect handling, so an assistant step
|
||||
* that touches several bags is still one read.
|
||||
*/
|
||||
export function useBagTotalsPing(enabled: boolean, reload: () => Promise<void>): void {
|
||||
useServerPing('packing:bag-totals', enabled, reload)
|
||||
useServerPing(BAG_RELOAD_EVENTS, enabled, reload)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { packingApi } from '../../api/client';
|
||||
import { useServerPing } from '../../sync/useServerPing';
|
||||
import { offlineOr } from '../../utils/needsConnection';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
@@ -23,14 +24,24 @@ export interface CategoryAssignee {
|
||||
export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: number; t: Translate; toast: Toaster }) {
|
||||
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({});
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
packingApi
|
||||
.getCategoryAssignees(tripId)
|
||||
.then((data) => setCategoryAssignees(data.assignees || {}))
|
||||
.catch(() => {
|
||||
// A failed read leaves the last answer standing; there is nothing to undo.
|
||||
}),
|
||||
[tripId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
packingApi
|
||||
.getCategoryAssignees(tripId)
|
||||
.then((data) => setCategoryAssignees(data.assignees || {}))
|
||||
.catch(() => {
|
||||
// A failed read leaves every category unassigned; there is nothing to undo.
|
||||
});
|
||||
}, [tripId]);
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
// Assignees set elsewhere (a fellow traveller, the assistant) are read back,
|
||||
// one read per burst, rather than patched in from the event.
|
||||
useServerPing('packing:assignees', true, reload);
|
||||
|
||||
const setAssignees = async (category: string, userIds: number[]) => {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { addListener } from '../../api/websocket'
|
||||
import { usePackingList, type PackingListPanelProps } from './usePackingListPanel'
|
||||
import type { PackingItem } from '../../types'
|
||||
|
||||
@@ -84,6 +85,31 @@ describe('usePackingList — members & assignees', () => {
|
||||
expect(result.current.tripMembers).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-BCAST-024: a traveller added or renamed elsewhere reaches the pickers, one read per burst', async () => {
|
||||
vi.mocked(addListener).mockClear()
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.get('/api/trips/:id/members', () => {
|
||||
calls++
|
||||
return HttpResponse.json({
|
||||
owner: { id: 1, username: 'owner', avatar_url: null },
|
||||
members: calls > 1 ? [{ id: 2, username: 'Ana', avatar_url: null, is_guest: 1 }] : [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
const { result } = renderPanel()
|
||||
await waitFor(() => expect(result.current.tripMembers).toHaveLength(1))
|
||||
|
||||
act(() => {
|
||||
for (const [listener] of vi.mocked(addListener).mock.calls) {
|
||||
listener({ type: 'member:added', member: { id: 2 } })
|
||||
listener({ type: 'member:updated', member: { id: 2, username: 'Ana' } })
|
||||
}
|
||||
})
|
||||
await waitFor(() => expect(result.current.tripMembers.map((m) => m.username)).toEqual(['owner', 'Ana']))
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-W5HOOK-004: an assignee response without a map falls back to an empty record', async () => {
|
||||
server.use(http.get('/api/trips/:id/packing/category-assignees', () => HttpResponse.json({})))
|
||||
const { result } = renderPanel()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useServerPing } from '../../sync/useServerPing'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
@@ -19,6 +20,9 @@ export type { CategoryAssignee } from './usePackingCategoryAssignees'
|
||||
|
||||
const PACKING_SORT_KEY = 'trek:packing-sort'
|
||||
|
||||
/** The roster events that change who the pickers offer. */
|
||||
const MEMBER_EVENTS = ['member:added', 'member:updated', 'member:removed'] as const
|
||||
|
||||
export type PackingSort = 'manual' | 'name'
|
||||
|
||||
export interface TripMember {
|
||||
@@ -78,14 +82,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
// Trip members & category assignees
|
||||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||
|
||||
const readMembers = useCallback(() => tripsApi.getMembers(tripId).then(data => {
|
||||
const all: TripMember[] = []
|
||||
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false })
|
||||
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(() => {}), [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi.getMembers(tripId).then(data => {
|
||||
const all: TripMember[] = []
|
||||
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false })
|
||||
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(() => {})
|
||||
}, [tripId])
|
||||
void readMembers()
|
||||
}, [readMembers])
|
||||
// A traveller or guest added, renamed or removed elsewhere: the pickers read the
|
||||
// roster again, once per burst (the phone list reads the planner's roster).
|
||||
useServerPing(MEMBER_EVENTS, true, readMembers)
|
||||
|
||||
const { categoryAssignees, setAssignees: handleSetAssignees } = usePackingCategoryAssignees({ tripId, t, toast })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user