mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collections): share the save to list picker logic between desktop and phone
useSaveToCollection holds the store driven picker that SaveToCollectionModal and MSaveToCollectionSheet each carried: loading the lists and the membership for a new target, toggling the place in or out of a list, the per list status, marking it visited everywhere and opening the collections page. Both views keep their own markup. The two copies did the same thing, so no option was needed. The load effect now lists the membership query beside the target instead of silencing the exhaustive deps rule; the query is derived from the target alone, so it runs exactly as before.
This commit is contained in:
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus, X } from 'lucide-react'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import MIconBtn from '../../mobile/components/MIconBtn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { STATUS_META, nextStatus } from '../../pages/collections/collectionsModel'
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
|
||||
import { VISITED_EVERYWHERE_BUSY, useSaveToCollection } from './useSaveToCollection'
|
||||
|
||||
/**
|
||||
* Mobile counterpart of SaveToCollectionModal — the same store-driven list
|
||||
@@ -18,135 +12,8 @@ import type { Collection, CollectionMembership, CollectionStatus } from '@trek/s
|
||||
* detail sheet. Rendered instead of the desktop modal on phones (see App.tsx).
|
||||
*/
|
||||
export default function MSaveToCollectionSheet() {
|
||||
const target = useSaveToCollectionStore(s => s.target)
|
||||
const close = useSaveToCollectionStore(s => s.close)
|
||||
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
|
||||
const { target, close, lists, loading, busyId, savedByCollection, unvisited, handleStatus, handleVisitedEverywhere, handleToggle, openCollections } = useSaveToCollection()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [membership, setMembership] = useState<CollectionMembership | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
|
||||
const membershipQuery = useMemo(() => {
|
||||
if (!target) return null
|
||||
return {
|
||||
google_place_id: target.google_place_id ?? undefined,
|
||||
google_ftid: target.google_ftid ?? undefined,
|
||||
name: target.name,
|
||||
lat: target.lat ?? undefined,
|
||||
lng: target.lng ?? undefined,
|
||||
}
|
||||
}, [target])
|
||||
|
||||
const refreshMembership = useCallback(async () => {
|
||||
if (!membershipQuery) return
|
||||
try {
|
||||
setMembership(await collectionsApi.membership(membershipQuery))
|
||||
} catch {
|
||||
setMembership({ saved: false, lists: [] })
|
||||
}
|
||||
}, [membershipQuery])
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target.
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setMembership(null)
|
||||
void Promise.all([
|
||||
collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })),
|
||||
membershipQuery
|
||||
? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] }))
|
||||
: Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] }),
|
||||
])
|
||||
.then(([listRes, m]) => {
|
||||
if (cancelled) return
|
||||
setLists(listRes.collections)
|
||||
setMembership(m)
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
|
||||
|
||||
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
|
||||
if (busyId != null) return
|
||||
setBusyId(entry.collection_id)
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next)
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return
|
||||
setBusyId(-1)
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
|
||||
toast.success(t('collections.markedVisited', { count: updated }))
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null || !target) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
await collectionsApi.deletePlace(savedPlaceId)
|
||||
toast.success(t('collections.removedFromList', { name: list.name }))
|
||||
} else {
|
||||
await collectionsApi.savePlace({
|
||||
collection_id: list.id,
|
||||
source_trip_id: target.source_trip_id ?? null,
|
||||
source_place_id: target.source_place_id ?? null,
|
||||
name: target.name,
|
||||
description: target.description ?? null,
|
||||
lat: target.lat ?? null,
|
||||
lng: target.lng ?? null,
|
||||
address: target.address ?? null,
|
||||
category_id: target.category_id ?? null,
|
||||
price: target.price ?? null,
|
||||
currency: target.currency ?? null,
|
||||
notes: target.notes ?? null,
|
||||
image_url: target.image_url ?? null,
|
||||
google_place_id: target.google_place_id ?? null,
|
||||
google_ftid: target.google_ftid ?? null,
|
||||
osm_id: target.osm_id ?? null,
|
||||
website: target.website ?? null,
|
||||
phone: target.phone ?? null,
|
||||
force: true,
|
||||
})
|
||||
toast.success(t('collections.addedToList', { name: list.name }))
|
||||
}
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={!!target} onClose={close} variant="card" material="glass" ariaLabel={t('collections.pickList')}>
|
||||
@@ -181,7 +48,7 @@ export default function MSaveToCollectionSheet() {
|
||||
<p className="mb-3 font-geist text-[0.75rem] text-m-faint">{t('collections.noListsYet')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
onClick={openCollections}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-m-act px-4 py-[9px] text-[0.75rem] font-semibold text-m-actfg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} /> {t('collections.newList')}
|
||||
@@ -262,7 +129,7 @@ export default function MSaveToCollectionSheet() {
|
||||
disabled={busyId != null}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-[10px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-60"
|
||||
>
|
||||
{busyId === -1
|
||||
{busyId === VISITED_EVERYWHERE_BUSY
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: <CheckCircle2 size={14} strokeWidth={2.2} />}
|
||||
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
|
||||
@@ -274,7 +141,7 @@ export default function MSaveToCollectionSheet() {
|
||||
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
onClick={openCollections}
|
||||
className="text-[0.78125rem] font-semibold text-[color:var(--m-act)]"
|
||||
>
|
||||
{t('collections.viewInCollection')}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import React, { useId } from 'react'
|
||||
import { Bookmark, BookmarkCheck, Check, CheckCircle2, Loader2, Plus } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared'
|
||||
import { VISITED_EVERYWHERE_BUSY, useSaveToCollection } from './useSaveToCollection'
|
||||
|
||||
/**
|
||||
* Globally-mounted list picker for the "Save to Collection" entry points
|
||||
@@ -19,137 +14,12 @@ import type { Collection, CollectionMembership, CollectionStatus } from '@trek/s
|
||||
* so the inspector bookmark indicator stays in sync. One mount, no prop drilling.
|
||||
*/
|
||||
export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
const target = useSaveToCollectionStore(s => s.target)
|
||||
const close = useSaveToCollectionStore(s => s.close)
|
||||
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
|
||||
const { target, close, lists, loading, busyId, savedByCollection, unvisited, handleStatus, handleVisitedEverywhere, handleToggle, openCollections } = useSaveToCollection()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
const labelId = useId()
|
||||
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [membership, setMembership] = useState<CollectionMembership | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
|
||||
const membershipQuery = useMemo(() => {
|
||||
if (!target) return null
|
||||
return {
|
||||
google_place_id: target.google_place_id ?? undefined,
|
||||
google_ftid: target.google_ftid ?? undefined,
|
||||
name: target.name,
|
||||
lat: target.lat ?? undefined,
|
||||
lng: target.lng ?? undefined,
|
||||
}
|
||||
}, [target])
|
||||
|
||||
const refreshMembership = useCallback(async () => {
|
||||
if (!membershipQuery) return
|
||||
try {
|
||||
const m = await collectionsApi.membership(membershipQuery)
|
||||
setMembership(m)
|
||||
} catch {
|
||||
setMembership({ saved: false, lists: [] })
|
||||
}
|
||||
}, [membershipQuery])
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target.
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setMembership(null)
|
||||
void Promise.all([collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })), membershipQuery ? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] })) : Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] })])
|
||||
.then(([listRes, m]) => {
|
||||
if (cancelled) return
|
||||
setLists(listRes.collections)
|
||||
setMembership(m)
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const savedByCollection = new Map<number, CollectionMembership['lists'][number]>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l)
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter(l => l.can_edit && l.status !== 'visited')
|
||||
|
||||
const handleStatus = async (entry: CollectionMembership['lists'][number], next: CollectionStatus) => {
|
||||
if (busyId != null) return
|
||||
setBusyId(entry.collection_id)
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next)
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return
|
||||
setBusyId(-1)
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(unvisited.map(l => l.place_id), 'visited')
|
||||
toast.success(t('collections.markedVisited', { count: updated }))
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
await collectionsApi.deletePlace(savedPlaceId)
|
||||
toast.success(t('collections.removedFromList', { name: list.name }))
|
||||
} else {
|
||||
await collectionsApi.savePlace({
|
||||
collection_id: list.id,
|
||||
source_trip_id: target.source_trip_id ?? null,
|
||||
source_place_id: target.source_place_id ?? null,
|
||||
name: target.name,
|
||||
description: target.description ?? null,
|
||||
lat: target.lat ?? null,
|
||||
lng: target.lng ?? null,
|
||||
address: target.address ?? null,
|
||||
category_id: target.category_id ?? null,
|
||||
price: target.price ?? null,
|
||||
currency: target.currency ?? null,
|
||||
notes: target.notes ?? null,
|
||||
image_url: target.image_url ?? null,
|
||||
google_place_id: target.google_place_id ?? null,
|
||||
google_ftid: target.google_ftid ?? null,
|
||||
osm_id: target.osm_id ?? null,
|
||||
website: target.website ?? null,
|
||||
phone: target.phone ?? null,
|
||||
force: true,
|
||||
})
|
||||
toast.success(t('collections.addedToList', { name: list.name }))
|
||||
}
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openCollections = () => { close(); navigate('/collections') }
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={close}
|
||||
@@ -167,7 +37,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
pills={unvisited.length > 0 ? (
|
||||
<button type="button" onClick={handleVisitedEverywhere} disabled={busyId != null}
|
||||
className={`${PILL} hover:opacity-80 disabled:opacity-60`}>
|
||||
{busyId === -1 ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} strokeWidth={2.2} />}
|
||||
{busyId === VISITED_EVERYWHERE_BUSY ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle2 size={13} strokeWidth={2.2} />}
|
||||
{unvisited.length > 1 ? t('collections.markVisitedAll') : t('collections.markVisited')}
|
||||
</button>
|
||||
) : undefined}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// FE-COMP-SAVETOCOLHOOK-001 to FE-COMP-SAVETOCOLHOOK-014: the store-driven save-to-list
|
||||
// picker logic behind both the desktop dialog and the phone sheet.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { Collection, CollectionListResponse, CollectionMembership } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { type SaveToCollectionTarget, useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import { VISITED_EVERYWHERE_BUSY, useSaveToCollection } from './useSaveToCollection';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router', () => ({ useNavigate: () => mockNavigate }));
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k),
|
||||
}),
|
||||
}));
|
||||
|
||||
function list(over: Partial<Collection>): Collection {
|
||||
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
|
||||
}
|
||||
|
||||
const FAVORITES = list({ id: 1, name: 'Favorites' });
|
||||
const WISHLIST = list({ id: 2, name: 'Wishlist' });
|
||||
|
||||
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
|
||||
|
||||
const TARGET: SaveToCollectionTarget = {
|
||||
name: 'Colosseum',
|
||||
source_trip_id: 5,
|
||||
source_place_id: 42,
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
};
|
||||
|
||||
const SAVED_IN_FAVORITES: CollectionMembership = {
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true }],
|
||||
};
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
function openFor(target: SaveToCollectionTarget = TARGET) {
|
||||
act(() => useSaveToCollectionStore.setState({ target, version: 0 }));
|
||||
}
|
||||
|
||||
async function loaded() {
|
||||
const hook = renderHook(() => useSaveToCollection());
|
||||
openFor();
|
||||
await waitFor(() => expect(hook.result.current.lists).toHaveLength(2));
|
||||
return hook;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
mockNavigate.mockClear();
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 });
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: false, lists: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 });
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('useSaveToCollection', () => {
|
||||
it('FE-COMP-SAVETOCOLHOOK-001: requests nothing while no target is set', () => {
|
||||
const { result } = renderHook(() => useSaveToCollection());
|
||||
expect(result.current.target).toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(collectionsApi.list).not.toHaveBeenCalled();
|
||||
expect(collectionsApi.membership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-002: a new target loads the lists and the membership by maps identity', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(SAVED_IN_FAVORITES);
|
||||
const { result } = renderHook(() => useSaveToCollection());
|
||||
openFor();
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.lists.map((l) => l.id)).toEqual([1, 2]);
|
||||
expect(collectionsApi.membership).toHaveBeenCalledWith({
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
name: 'Colosseum',
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
});
|
||||
expect(result.current.savedByCollection.get(1)?.place_id).toBe(900);
|
||||
expect(result.current.savedByCollection.has(2)).toBe(false);
|
||||
expect(result.current.unvisited.map((l) => l.place_id)).toEqual([900]);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-003: failing requests fall back to no lists and saved nowhere', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
|
||||
vi.spyOn(collectionsApi, 'membership').mockRejectedValue(new Error('offline'));
|
||||
const { result } = renderHook(() => useSaveToCollection());
|
||||
openFor();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.lists).toEqual([]);
|
||||
expect(result.current.savedByCollection.size).toBe(0);
|
||||
expect(result.current.unvisited).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-004: unvisited leaves out visited and read-only lists', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [
|
||||
{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'visited', can_edit: true },
|
||||
{ collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: false },
|
||||
{ collection_id: 3, name: 'Team', place_id: 902, status: 'want', can_edit: true },
|
||||
],
|
||||
});
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(result.current.savedByCollection.size).toBe(3));
|
||||
expect(result.current.unvisited.map((l) => l.place_id)).toEqual([902]);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-005: toggling an unsaved list saves the whole target with nulls, then refreshes and bumps', async () => {
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
|
||||
const { result } = await loaded();
|
||||
const membershipCalls = vi.mocked(collectionsApi.membership).mock.calls.length;
|
||||
|
||||
await act(() => result.current.handleToggle(WISHLIST));
|
||||
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
collection_id: 2,
|
||||
source_trip_id: 5,
|
||||
source_place_id: 42,
|
||||
name: 'Colosseum',
|
||||
description: null,
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
address: null,
|
||||
category_id: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
notes: null,
|
||||
image_url: null,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
osm_id: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
force: true,
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith('collections.addedToList:{"name":"Wishlist"}', 'success', undefined);
|
||||
expect(vi.mocked(collectionsApi.membership).mock.calls.length).toBe(membershipCalls + 1);
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(1);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-006: toggling a saved list deletes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(SAVED_IN_FAVORITES);
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace');
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(result.current.savedByCollection.has(1)).toBe(true));
|
||||
|
||||
await act(() => result.current.handleToggle(FAVORITES));
|
||||
|
||||
expect(del).toHaveBeenCalledWith(900);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(addToast).toHaveBeenCalledWith('collections.removedFromList:{"name":"Favorites"}', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-007: a failing toggle toasts the error and leaves the version alone', async () => {
|
||||
vi.spyOn(collectionsApi, 'savePlace').mockRejectedValue(new Error('boom'));
|
||||
const { result } = await loaded();
|
||||
|
||||
await act(() => result.current.handleToggle(WISHLIST));
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(0);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-008: while one change runs, every other change is dropped', async () => {
|
||||
let resolve!: (v: unknown) => void;
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise((r) => (resolve = r)) as never);
|
||||
const status = vi.spyOn(collectionsApi, 'setStatus');
|
||||
const { result } = await loaded();
|
||||
|
||||
let first!: Promise<void>;
|
||||
act(() => {
|
||||
first = result.current.handleToggle(WISHLIST);
|
||||
});
|
||||
expect(result.current.busyId).toBe(2);
|
||||
await act(() => result.current.handleToggle(FAVORITES));
|
||||
await act(() => result.current.handleStatus(SAVED_IN_FAVORITES.lists[0], 'visited'));
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
|
||||
resolve({});
|
||||
await act(() => first);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-009: a status change sets it per list, then refreshes and bumps', async () => {
|
||||
const status = vi.spyOn(collectionsApi, 'setStatus').mockResolvedValue({} as never);
|
||||
const { result } = await loaded();
|
||||
|
||||
await act(() => result.current.handleStatus(SAVED_IN_FAVORITES.lists[0], 'visited'));
|
||||
|
||||
expect(status).toHaveBeenCalledWith(900, 'visited');
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-010: a failing status change toasts the error', async () => {
|
||||
vi.spyOn(collectionsApi, 'setStatus').mockRejectedValue(new Error('boom'));
|
||||
const { result } = await loaded();
|
||||
|
||||
await act(() => result.current.handleStatus(SAVED_IN_FAVORITES.lists[0], 'visited'));
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-011: visited everywhere marks every unvisited list in one call', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [
|
||||
{ collection_id: 1, name: 'Favorites', place_id: 900, status: 'want', can_edit: true },
|
||||
{ collection_id: 2, name: 'Wishlist', place_id: 901, status: 'idea', can_edit: true },
|
||||
],
|
||||
});
|
||||
let resolve!: (v: { updated: number }) => void;
|
||||
const many = vi.spyOn(collectionsApi, 'setStatusMany').mockReturnValue(new Promise((r) => (resolve = r)) as never);
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(result.current.unvisited).toHaveLength(2));
|
||||
|
||||
let run!: Promise<void>;
|
||||
act(() => {
|
||||
run = result.current.handleVisitedEverywhere();
|
||||
});
|
||||
expect(result.current.busyId).toBe(VISITED_EVERYWHERE_BUSY);
|
||||
resolve({ updated: 2 });
|
||||
await act(() => run);
|
||||
|
||||
expect(many).toHaveBeenCalledWith([900, 901], 'visited');
|
||||
expect(addToast).toHaveBeenCalledWith('collections.markedVisited:{"count":2}', 'success', undefined);
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-012: visited everywhere does nothing without an unvisited list, and toasts a failure', async () => {
|
||||
const many = vi.spyOn(collectionsApi, 'setStatusMany').mockRejectedValue(new Error('boom'));
|
||||
const { result } = await loaded();
|
||||
|
||||
await act(() => result.current.handleVisitedEverywhere());
|
||||
expect(many).not.toHaveBeenCalled();
|
||||
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(SAVED_IN_FAVORITES);
|
||||
openFor({ ...TARGET, name: 'Pantheon' });
|
||||
await waitFor(() => expect(result.current.unvisited).toHaveLength(1));
|
||||
await act(() => result.current.handleVisitedEverywhere());
|
||||
expect(many).toHaveBeenCalledWith([900], 'visited');
|
||||
expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-013: opening the collections page closes the picker and navigates', async () => {
|
||||
const { result } = await loaded();
|
||||
|
||||
act(() => result.current.openCollections());
|
||||
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOLHOOK-014: a load landing after the target changed is dropped', async () => {
|
||||
let resolveFirst!: (v: CollectionListResponse) => void;
|
||||
vi.spyOn(collectionsApi, 'list')
|
||||
.mockReturnValueOnce(new Promise((r) => (resolveFirst = r)))
|
||||
.mockResolvedValue(listResponse([WISHLIST]));
|
||||
const { result } = renderHook(() => useSaveToCollection());
|
||||
openFor();
|
||||
openFor({ ...TARGET, name: 'Pantheon' });
|
||||
await waitFor(() => expect(result.current.lists.map((l) => l.id)).toEqual([2]));
|
||||
|
||||
resolveFirst(listResponse([FAVORITES, WISHLIST]));
|
||||
await act(async () => {});
|
||||
expect(result.current.lists.map((l) => l.id)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Collection, CollectionMembership, CollectionStatus } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
type MembershipEntry = CollectionMembership['lists'][number];
|
||||
|
||||
/** busyId while the "visited everywhere" bulk action runs. */
|
||||
export const VISITED_EVERYWHERE_BUSY = -1;
|
||||
|
||||
/**
|
||||
* The store-driven "Save to Collection" picker: the one logic path behind the desktop
|
||||
* dialog and the phone sheet, which render their own markup over it. Every new target
|
||||
* loads the user's lists and the place's membership; a list toggles the place in or out,
|
||||
* a status changes per list, and "visited everywhere" marks every editable list at once.
|
||||
* Each change refreshes the membership and bumps the store version so the inspector
|
||||
* bookmark indicator stays in sync.
|
||||
*/
|
||||
export function useSaveToCollection() {
|
||||
const target = useSaveToCollectionStore((s) => s.target);
|
||||
const close = useSaveToCollectionStore((s) => s.close);
|
||||
const bumpVersion = useSaveToCollectionStore((s) => s.bumpVersion);
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [lists, setLists] = useState<Collection[]>([]);
|
||||
const [membership, setMembership] = useState<CollectionMembership | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
const membershipQuery = useMemo(() => {
|
||||
if (!target) return null;
|
||||
return {
|
||||
google_place_id: target.google_place_id ?? undefined,
|
||||
google_ftid: target.google_ftid ?? undefined,
|
||||
name: target.name,
|
||||
lat: target.lat ?? undefined,
|
||||
lng: target.lng ?? undefined,
|
||||
};
|
||||
}, [target]);
|
||||
|
||||
const refreshMembership = useCallback(async () => {
|
||||
if (!membershipQuery) return;
|
||||
try {
|
||||
setMembership(await collectionsApi.membership(membershipQuery));
|
||||
} catch {
|
||||
setMembership({ saved: false, lists: [] });
|
||||
}
|
||||
}, [membershipQuery]);
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target. The query is
|
||||
// derived from the target alone, so both change together.
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setMembership(null);
|
||||
void Promise.all([
|
||||
collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })),
|
||||
membershipQuery
|
||||
? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as MembershipEntry[] }))
|
||||
: Promise.resolve({ saved: false, lists: [] as MembershipEntry[] }),
|
||||
])
|
||||
.then(([listRes, m]) => {
|
||||
if (cancelled) return;
|
||||
setLists(listRes.collections);
|
||||
setMembership(m);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [target, membershipQuery]);
|
||||
|
||||
const savedByCollection = new Map<number, MembershipEntry>();
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l);
|
||||
|
||||
/** Lists holding this place that the viewer may edit and that are not visited yet. */
|
||||
const unvisited = (membership?.lists ?? []).filter((l) => l.can_edit && l.status !== 'visited');
|
||||
|
||||
const handleStatus = async (entry: MembershipEntry, next: CollectionStatus) => {
|
||||
if (busyId != null) return;
|
||||
setBusyId(entry.collection_id);
|
||||
try {
|
||||
await collectionsApi.setStatus(entry.place_id, next);
|
||||
await refreshMembership();
|
||||
bumpVersion();
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisitedEverywhere = async () => {
|
||||
if (busyId != null || unvisited.length === 0) return;
|
||||
setBusyId(VISITED_EVERYWHERE_BUSY);
|
||||
try {
|
||||
const { updated } = await collectionsApi.setStatusMany(
|
||||
unvisited.map((l) => l.place_id),
|
||||
'visited'
|
||||
);
|
||||
toast.success(t('collections.markedVisited', { count: updated }));
|
||||
await refreshMembership();
|
||||
bumpVersion();
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null || !target) return;
|
||||
const savedPlaceId = savedByCollection.get(list.id)?.place_id;
|
||||
setBusyId(list.id);
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
await collectionsApi.deletePlace(savedPlaceId);
|
||||
toast.success(t('collections.removedFromList', { name: list.name }));
|
||||
} else {
|
||||
await collectionsApi.savePlace({
|
||||
collection_id: list.id,
|
||||
source_trip_id: target.source_trip_id ?? null,
|
||||
source_place_id: target.source_place_id ?? null,
|
||||
name: target.name,
|
||||
description: target.description ?? null,
|
||||
lat: target.lat ?? null,
|
||||
lng: target.lng ?? null,
|
||||
address: target.address ?? null,
|
||||
category_id: target.category_id ?? null,
|
||||
price: target.price ?? null,
|
||||
currency: target.currency ?? null,
|
||||
notes: target.notes ?? null,
|
||||
image_url: target.image_url ?? null,
|
||||
google_place_id: target.google_place_id ?? null,
|
||||
google_ftid: target.google_ftid ?? null,
|
||||
osm_id: target.osm_id ?? null,
|
||||
website: target.website ?? null,
|
||||
phone: target.phone ?? null,
|
||||
force: true,
|
||||
});
|
||||
toast.success(t('collections.addedToList', { name: list.name }));
|
||||
}
|
||||
await refreshMembership();
|
||||
bumpVersion();
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openCollections = () => {
|
||||
close();
|
||||
navigate('/collections');
|
||||
};
|
||||
|
||||
return {
|
||||
target,
|
||||
close,
|
||||
lists,
|
||||
loading,
|
||||
busyId,
|
||||
savedByCollection,
|
||||
unvisited,
|
||||
handleStatus,
|
||||
handleVisitedEverywhere,
|
||||
handleToggle,
|
||||
openCollections,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user