mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
f75e9e9af8
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.
The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.
Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.
The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
import { create } from 'zustand'
|
|
import { collectionsApi } from '../api/collections'
|
|
import type {
|
|
Collection,
|
|
CollectionPlace,
|
|
CollectionMember,
|
|
CollectionStatus,
|
|
CollectionRole,
|
|
CollectionListResponse,
|
|
CollectionCreateRequest,
|
|
CollectionUpdateRequest,
|
|
CollectionPlaceUpdateRequest,
|
|
CollectionLabel,
|
|
CollectionLabelUpdateRequest,
|
|
} from '@trek/shared'
|
|
|
|
/** A pending invitation the current user has received (derived server-side). */
|
|
export type IncomingCollectionInvite = CollectionListResponse['incomingInvites'][number]
|
|
|
|
/** Sentinel id for the client-side "All saved" union pseudo-list. */
|
|
export const ALL_SAVED = 'all' as const
|
|
export type ActiveCollectionId = number | typeof ALL_SAVED | null
|
|
|
|
export type CollectionView = 'list' | 'map'
|
|
export type StatusFilter = CollectionStatus | 'all'
|
|
/** Display order for a list's places: the saved order, or alphabetical by name. */
|
|
export type CollectionSortMode = 'default' | 'name_asc'
|
|
|
|
interface CollectionState {
|
|
collections: Collection[]
|
|
activeId: ActiveCollectionId
|
|
places: CollectionPlace[]
|
|
members: CollectionMember[]
|
|
labels: CollectionLabel[]
|
|
incomingInvites: IncomingCollectionInvite[]
|
|
view: CollectionView
|
|
statusFilter: StatusFilter
|
|
categoryFilter: number | 'all'
|
|
ratingFilter: number | 'all'
|
|
labelFilter: number[]
|
|
sortMode: CollectionSortMode
|
|
search: string
|
|
selectedPlaceId: number | null
|
|
selectMode: boolean
|
|
selectedIds: number[]
|
|
loading: boolean
|
|
placesLoading: boolean
|
|
|
|
loadAll: () => Promise<void>
|
|
loadCollection: (id: number) => Promise<void>
|
|
setActive: (id: ActiveCollectionId) => Promise<void>
|
|
refreshActive: () => Promise<void>
|
|
|
|
createCollection: (payload: CollectionCreateRequest) => Promise<Collection | null>
|
|
updateCollection: (id: number, updates: CollectionUpdateRequest) => Promise<void>
|
|
uploadCover: (id: number, file: File) => Promise<void>
|
|
deleteCollection: (id: number) => Promise<void>
|
|
reorderCollections: (orderedIds: number[]) => Promise<void>
|
|
|
|
setStatus: (placeId: number, status: CollectionStatus) => Promise<void>
|
|
updatePlace: (placeId: number, body: CollectionPlaceUpdateRequest) => Promise<void>
|
|
uploadPlaceImage: (placeId: number, file: File) => Promise<void>
|
|
ratePlace: (placeId: number, rating: number | null) => Promise<void>
|
|
deletePlace: (placeId: number) => Promise<void>
|
|
deleteMany: (ids: number[]) => Promise<void>
|
|
copyToTrip: (tripId: number, placeIds: number[], force?: boolean) => Promise<{ copied: number; skipped: { id: number; name: string }[] }>
|
|
moveToList: (placeIds: number[], targetId: number) => Promise<void>
|
|
duplicateToList: (placeIds: number[], targetId: number) => Promise<void>
|
|
|
|
createLabel: (name: string, color?: string) => Promise<void>
|
|
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest) => Promise<void>
|
|
deleteLabel: (labelId: number) => Promise<void>
|
|
assignLabels: (labelIds: number[], placeIds: number[], remove?: boolean) => Promise<void>
|
|
|
|
invite: (collectionId: number, userId: number, role?: CollectionRole) => Promise<void>
|
|
setMemberRole: (collectionId: number, userId: number, role: CollectionRole) => Promise<void>
|
|
acceptInvite: (collectionId: number) => Promise<void>
|
|
declineInvite: (collectionId: number) => Promise<void>
|
|
cancelInvite: (collectionId: number, userId: number) => Promise<void>
|
|
removeMember: (collectionId: number, userId: number) => Promise<void>
|
|
leave: (collectionId: number) => Promise<void>
|
|
|
|
setView: (view: CollectionView) => void
|
|
setStatusFilter: (filter: StatusFilter) => void
|
|
setCategoryFilter: (filter: number | 'all') => void
|
|
setRatingFilter: (filter: number | 'all') => void
|
|
setLabelFilter: (labelIds: number[]) => void
|
|
setSortMode: (mode: CollectionSortMode) => void
|
|
setSearch: (search: string) => void
|
|
setSelectedPlaceId: (id: number | null) => void
|
|
setSelectMode: (on: boolean) => void
|
|
toggleSelect: (id: number) => void
|
|
setSelectedIds: (ids: number[]) => void
|
|
clearSelection: () => void
|
|
}
|
|
|
|
/**
|
|
* Load whatever `id` points at (a list, the "All saved" union, or nothing) into
|
|
* the store. Split out of setActive() because switching lists also has to drop
|
|
* the per-list UI state, while re-reading the list that is already open must not
|
|
* (#1921: every add from the still-open add-place dialog runs a refresh).
|
|
*/
|
|
async function loadActive(
|
|
set: (partial: Partial<CollectionState>) => void,
|
|
get: () => CollectionState,
|
|
id: ActiveCollectionId,
|
|
): Promise<void> {
|
|
if (id === null) {
|
|
set({ activeId: null, places: [], members: [], labels: [] })
|
|
return
|
|
}
|
|
if (id === ALL_SAVED) {
|
|
set({ activeId: ALL_SAVED, members: [], labels: [], placesLoading: true })
|
|
try {
|
|
// Client-side union of every list the user owns or co-owns (no server change).
|
|
// On first load the lists may not be fetched yet (loadAll still in flight),
|
|
// which would union nothing — make sure they're loaded first.
|
|
let lists = get().collections
|
|
if (lists.length === 0) { await get().loadAll(); lists = get().collections }
|
|
const results = await Promise.all(lists.map(l => collectionsApi.get(l.id).catch(() => null)))
|
|
const seen = new Set<number>()
|
|
const merged: CollectionPlace[] = []
|
|
for (const res of results) {
|
|
if (!res) continue
|
|
for (const p of res.places) {
|
|
if (seen.has(p.id)) continue
|
|
seen.add(p.id)
|
|
merged.push(p)
|
|
}
|
|
}
|
|
set({ places: merged })
|
|
} finally {
|
|
set({ placesLoading: false })
|
|
}
|
|
return
|
|
}
|
|
await get().loadCollection(id)
|
|
}
|
|
|
|
export const useCollectionStore = create<CollectionState>((set, get) => ({
|
|
collections: [],
|
|
activeId: null,
|
|
places: [],
|
|
members: [],
|
|
labels: [],
|
|
incomingInvites: [],
|
|
view: 'list',
|
|
statusFilter: 'all',
|
|
categoryFilter: 'all',
|
|
ratingFilter: 'all',
|
|
labelFilter: [],
|
|
sortMode: 'default',
|
|
search: '',
|
|
selectedPlaceId: null,
|
|
selectMode: false,
|
|
selectedIds: [],
|
|
loading: false,
|
|
placesLoading: false,
|
|
|
|
loadAll: async () => {
|
|
set({ loading: true })
|
|
try {
|
|
const data = await collectionsApi.list()
|
|
set({ collections: data.collections, incomingInvites: data.incomingInvites })
|
|
} finally {
|
|
set({ loading: false })
|
|
}
|
|
},
|
|
|
|
loadCollection: async (id: number) => {
|
|
set({ placesLoading: true })
|
|
try {
|
|
const data = await collectionsApi.get(id)
|
|
set({
|
|
activeId: id,
|
|
places: data.places,
|
|
members: data.collection.members ?? [],
|
|
labels: data.collection.labels ?? [],
|
|
})
|
|
} catch {
|
|
// The list may have been left / removed / deleted out from under us (a WS
|
|
// event or the URL sync can re-request an id we just lost access to). Clear
|
|
// it instead of leaving an uncaught 403/404 rejection; the route sync then
|
|
// bounces to /collections.
|
|
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
|
|
} finally {
|
|
set({ placesLoading: false })
|
|
}
|
|
},
|
|
|
|
setActive: async (id: ActiveCollectionId) => {
|
|
// Labels are per-collection, so their filter can't carry across lists.
|
|
set({ selectMode: false, selectedIds: [], selectedPlaceId: null, labelFilter: [] })
|
|
await loadActive(set, get, id)
|
|
},
|
|
|
|
refreshActive: async () => {
|
|
const { activeId } = get()
|
|
if (activeId === null) return
|
|
// Same list, so select mode, the selection and the label filter stay put.
|
|
await loadActive(set, get, activeId)
|
|
// Only what the reload no longer contains can't stay selected.
|
|
const ids = new Set(get().places.map(p => p.id))
|
|
const { selectedIds, selectedPlaceId } = get()
|
|
const kept = selectedIds.filter(id => ids.has(id))
|
|
const keptPlaceId = selectedPlaceId != null && ids.has(selectedPlaceId) ? selectedPlaceId : null
|
|
if (kept.length !== selectedIds.length || keptPlaceId !== selectedPlaceId) {
|
|
set({ selectedIds: kept, selectedPlaceId: keptPlaceId })
|
|
}
|
|
},
|
|
|
|
createCollection: async (payload) => {
|
|
// POST answers with the collection itself. Reading `.collection` off it
|
|
// returned undefined every time, so the caller never got the new id: a cover
|
|
// picked while creating a list was dropped and the list never opened.
|
|
const created = await collectionsApi.create(payload)
|
|
await get().loadAll()
|
|
return created ?? null
|
|
},
|
|
|
|
updateCollection: async (id, updates) => {
|
|
await collectionsApi.update(id, updates)
|
|
await get().loadAll()
|
|
if (get().activeId === id) await get().loadCollection(id)
|
|
},
|
|
|
|
uploadCover: async (id: number, file: File) => {
|
|
const fd = new FormData()
|
|
fd.append('cover', file)
|
|
await collectionsApi.uploadCover(id, fd)
|
|
await get().loadAll()
|
|
if (get().activeId === id) await get().loadCollection(id)
|
|
},
|
|
|
|
deleteCollection: async (id: number) => {
|
|
await collectionsApi.remove(id)
|
|
// Labels are per-list, so they have to go with the list they belonged to.
|
|
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [], labelFilter: [] })
|
|
await get().loadAll()
|
|
},
|
|
|
|
reorderCollections: async (orderedIds: number[]) => {
|
|
// optimistic
|
|
const byId = new Map(get().collections.map(c => [c.id, c]))
|
|
const reordered = orderedIds.map(id => byId.get(id)).filter(Boolean) as Collection[]
|
|
set({ collections: reordered })
|
|
try {
|
|
await collectionsApi.reorder(orderedIds)
|
|
} finally {
|
|
await get().loadAll()
|
|
}
|
|
},
|
|
|
|
setStatus: async (placeId: number, status: CollectionStatus) => {
|
|
// optimistic
|
|
set({ places: get().places.map(p => (p.id === placeId ? { ...p, status } : p)) })
|
|
try {
|
|
await collectionsApi.setStatus(placeId, status)
|
|
} catch {
|
|
await get().refreshActive()
|
|
}
|
|
},
|
|
|
|
updatePlace: async (placeId, body) => {
|
|
// The endpoint returns the updated place directly (not wrapped in { place }).
|
|
const updated = await collectionsApi.updatePlace(placeId, body)
|
|
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
|
|
},
|
|
|
|
uploadPlaceImage: async (placeId: number, file: File) => {
|
|
const fd = new FormData()
|
|
fd.append('image', file)
|
|
const updated = await collectionsApi.uploadPlaceImage(placeId, fd)
|
|
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
|
|
},
|
|
|
|
ratePlace: async (placeId: number, rating: number | null) => {
|
|
const updated = await collectionsApi.ratePlace(placeId, rating)
|
|
if (updated) set({ places: get().places.map(p => (p.id === placeId ? updated : p)) })
|
|
},
|
|
|
|
deletePlace: async (placeId: number) => {
|
|
// optimistic; put the row back if the server refuses, then let the caller toast
|
|
const prev = get().places
|
|
set({ places: prev.filter(p => p.id !== placeId) })
|
|
try {
|
|
await collectionsApi.deletePlace(placeId)
|
|
} catch (err) {
|
|
set({ places: prev })
|
|
throw err
|
|
}
|
|
await get().loadAll()
|
|
},
|
|
|
|
deleteMany: async (ids: number[]) => {
|
|
const idSet = new Set(ids)
|
|
const prev = get().places
|
|
const prevSelected = get().selectedIds
|
|
const prevSelectMode = get().selectMode
|
|
set({ places: prev.filter(p => !idSet.has(p.id)), selectedIds: [], selectMode: false })
|
|
try {
|
|
await collectionsApi.deleteMany(ids)
|
|
} catch (err) {
|
|
set({ places: prev, selectedIds: prevSelected, selectMode: prevSelectMode })
|
|
throw err
|
|
}
|
|
await get().loadAll()
|
|
},
|
|
|
|
copyToTrip: async (tripId: number, placeIds: number[], force?: boolean) => {
|
|
const res = await collectionsApi.copyToTrip({ trip_id: tripId, place_ids: placeIds, force })
|
|
return res
|
|
},
|
|
|
|
// Move the selected places into another list (re-point collection_id). They
|
|
// leave the current list, so drop them locally + refresh.
|
|
moveToList: async (placeIds: number[], targetId: number) => {
|
|
// The fan-out can fail halfway through, so drop only the places that really
|
|
// moved and refresh either way — otherwise the open list keeps showing rows
|
|
// that already belong to the target.
|
|
const moved: number[] = []
|
|
let failure: unknown = null
|
|
for (const id of placeIds) {
|
|
try {
|
|
await collectionsApi.updatePlace(id, { collection_id: targetId })
|
|
moved.push(id)
|
|
} catch (err) {
|
|
failure = err
|
|
break
|
|
}
|
|
}
|
|
const idSet = new Set(moved)
|
|
set({ places: get().places.filter(p => !idSet.has(p.id)), selectedIds: [], selectMode: false })
|
|
await get().loadAll()
|
|
const active = get().activeId
|
|
if (typeof active === 'number') await get().loadCollection(active)
|
|
if (failure) throw failure
|
|
},
|
|
|
|
// Duplicate the selected places into another list (re-save each place's data).
|
|
duplicateToList: async (placeIds: number[], targetId: number) => {
|
|
const byId = new Map(get().places.map(p => [p.id, p]))
|
|
for (const id of placeIds) {
|
|
const p = byId.get(id)
|
|
if (!p) continue
|
|
await collectionsApi.savePlace({
|
|
collection_id: targetId,
|
|
name: p.name,
|
|
description: p.description ?? null,
|
|
lat: p.lat ?? null,
|
|
lng: p.lng ?? null,
|
|
address: p.address ?? null,
|
|
category_id: p.category_id ?? null,
|
|
price: p.price ?? null,
|
|
currency: p.currency ?? null,
|
|
notes: p.notes ?? null,
|
|
image_url: p.image_url ?? null,
|
|
google_place_id: p.google_place_id ?? null,
|
|
google_ftid: p.google_ftid ?? null,
|
|
osm_id: p.osm_id ?? null,
|
|
website: p.website ?? null,
|
|
phone: p.phone ?? null,
|
|
status: p.status,
|
|
links: p.links ?? [],
|
|
force: true,
|
|
})
|
|
}
|
|
set({ selectedIds: [], selectMode: false })
|
|
await get().loadAll()
|
|
},
|
|
|
|
createLabel: async (name: string, color?: string) => {
|
|
const active = get().activeId
|
|
if (typeof active !== 'number') return
|
|
await collectionsApi.createLabel(active, name, color)
|
|
await get().loadCollection(active)
|
|
},
|
|
|
|
updateLabel: async (labelId: number, body: CollectionLabelUpdateRequest) => {
|
|
// optimistic recolor/rename
|
|
const prevLabels = get().labels
|
|
set({ labels: prevLabels.map(l => (l.id === labelId ? { ...l, ...body } : l)) })
|
|
try {
|
|
await collectionsApi.updateLabel(labelId, body)
|
|
} catch (err) {
|
|
set({ labels: prevLabels })
|
|
throw err
|
|
}
|
|
const active = get().activeId
|
|
if (typeof active === 'number') await get().loadCollection(active)
|
|
},
|
|
|
|
deleteLabel: async (labelId: number) => {
|
|
// optimistic: drop the label + its assignments + any active filter on it
|
|
const prev = { labels: get().labels, labelFilter: get().labelFilter, places: get().places }
|
|
set({
|
|
labels: prev.labels.filter(l => l.id !== labelId),
|
|
labelFilter: prev.labelFilter.filter(id => id !== labelId),
|
|
places: prev.places.map(p => ({ ...p, label_ids: (p.label_ids ?? []).filter(id => id !== labelId) })),
|
|
})
|
|
try {
|
|
await collectionsApi.deleteLabel(labelId)
|
|
} catch (err) {
|
|
set(prev)
|
|
throw err
|
|
}
|
|
const active = get().activeId
|
|
if (typeof active === 'number') await get().loadCollection(active)
|
|
},
|
|
|
|
assignLabels: async (labelIds: number[], placeIds: number[], remove = false) => {
|
|
const idSet = new Set(placeIds)
|
|
// optimistic per-place label_ids update
|
|
const prevPlaces = get().places
|
|
set({
|
|
places: prevPlaces.map(p => {
|
|
if (!idSet.has(p.id)) return p
|
|
const current = new Set(p.label_ids ?? [])
|
|
if (remove) labelIds.forEach(id => current.delete(id))
|
|
else labelIds.forEach(id => current.add(id))
|
|
return { ...p, label_ids: [...current] }
|
|
}),
|
|
})
|
|
try {
|
|
if (remove) await collectionsApi.unassignLabels(labelIds, placeIds)
|
|
else await collectionsApi.assignLabels(labelIds, placeIds)
|
|
} catch (err) {
|
|
set({ places: prevPlaces })
|
|
throw err
|
|
}
|
|
const active = get().activeId
|
|
if (typeof active === 'number') await get().loadCollection(active)
|
|
},
|
|
|
|
invite: async (collectionId: number, userId: number, role?: CollectionRole) => {
|
|
await collectionsApi.invite(collectionId, userId, role)
|
|
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
|
},
|
|
|
|
setMemberRole: async (collectionId: number, userId: number, role: CollectionRole) => {
|
|
await collectionsApi.setMemberRole(collectionId, userId, role)
|
|
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
|
},
|
|
|
|
acceptInvite: async (collectionId: number) => {
|
|
await collectionsApi.acceptInvite(collectionId)
|
|
await get().loadAll()
|
|
},
|
|
|
|
declineInvite: async (collectionId: number) => {
|
|
await collectionsApi.declineInvite(collectionId)
|
|
await get().loadAll()
|
|
},
|
|
|
|
cancelInvite: async (collectionId: number, userId: number) => {
|
|
await collectionsApi.cancelInvite(collectionId, userId)
|
|
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
|
},
|
|
|
|
removeMember: async (collectionId: number, userId: number) => {
|
|
await collectionsApi.removeMember(collectionId, userId)
|
|
if (get().activeId === collectionId) await get().loadCollection(collectionId)
|
|
},
|
|
|
|
leave: async (collectionId: number) => {
|
|
await collectionsApi.leave(collectionId)
|
|
// Labels are per-list, so they have to go with the list we just left.
|
|
if (get().activeId === collectionId) set({ activeId: null, places: [], members: [], labels: [], labelFilter: [] })
|
|
await get().loadAll()
|
|
},
|
|
|
|
setView: (view: CollectionView) => set({ view }),
|
|
setStatusFilter: (filter: StatusFilter) => set({ statusFilter: filter }),
|
|
setCategoryFilter: (filter: number | 'all') => set({ categoryFilter: filter }),
|
|
setRatingFilter: (filter: number | 'all') => set({ ratingFilter: filter }),
|
|
setLabelFilter: (labelIds: number[]) => set({ labelFilter: labelIds }),
|
|
setSortMode: (mode: CollectionSortMode) => set({ sortMode: mode }),
|
|
setSearch: (search: string) => set({ search }),
|
|
setSelectedPlaceId: (id: number | null) => set({ selectedPlaceId: id }),
|
|
setSelectMode: (on: boolean) => set({ selectMode: on, selectedIds: on ? get().selectedIds : [] }),
|
|
toggleSelect: (id: number) => {
|
|
const selected = get().selectedIds
|
|
set({ selectedIds: selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id] })
|
|
},
|
|
setSelectedIds: (ids: number[]) => set({ selectedIds: ids }),
|
|
clearSelection: () => set({ selectedIds: [], selectMode: false }),
|
|
}))
|