+ {bodyInvites.length > 0 && (
+
+
+ {t('collections.invites.title')}
+ {bodyInvites.length}
+
+ {bodyInvites.map(inv => (
+
+ ))}
+
+ )}
{noLists ? (
diff --git a/client/src/pages/CollectionsPage.wiring.test.tsx b/client/src/pages/CollectionsPage.wiring.test.tsx
index 80a7a6579..3bd463e16 100644
--- a/client/src/pages/CollectionsPage.wiring.test.tsx
+++ b/client/src/pages/CollectionsPage.wiring.test.tsx
@@ -211,6 +211,8 @@ function makeHook(overrides: Hook = {}): Hook {
hasMappable: true,
members: [],
incomingInvites: [],
+ orderedInvites: [],
+ routeInvite: null,
counts: { all: 1, idea: 0, want: 1, visited: 0 },
view: 'list',
statusFilter: 'all',
diff --git a/client/src/pages/collections/useCollections.test.tsx b/client/src/pages/collections/useCollections.test.tsx
index 65087a00e..599a64742 100644
--- a/client/src/pages/collections/useCollections.test.tsx
+++ b/client/src/pages/collections/useCollections.test.tsx
@@ -1207,3 +1207,53 @@ describe('useCollections: GPX (#2301)', () => {
expect(answer).toEqual(read)
})
})
+
+// #2645: the invite notification links to /collections/:id, a list the invitee
+// cannot read until they accept. The page has to offer the invite there, and a
+// link to a list that is gone (declined, cancelled, deleted) must not leave the
+// page pointing at nothing.
+describe('useCollections — route that is an invite (#2645)', () => {
+ const invite = { collection_id: 14, name: 'Lisbon', from: { id: 4, username: 'Lena' } }
+
+ it('FE-PAGE-COLL-086: a route id that is a pending invite exposes it and does not open the list', async () => {
+ routeParams = { id: '14' }
+ store.incomingInvites = [invite]
+ const { result } = await setup()
+ expect(result.current.routeInvite).toEqual(invite)
+ expect(store.setActive).not.toHaveBeenCalledWith(14)
+ expect(navigate).not.toHaveBeenCalled()
+ })
+
+ it('FE-PAGE-COLL-087: the invite on the route is listed first', async () => {
+ routeParams = { id: '14' }
+ const other = { collection_id: 3, name: 'Rome', from: { id: 5, username: 'Tom' } }
+ store.incomingInvites = [other, invite]
+ const { result } = await setup()
+ expect(result.current.orderedInvites).toEqual([invite, other])
+ })
+
+ it('FE-PAGE-COLL-088: a route id that is neither a list nor an invite bounces to /collections once the lists are in', async () => {
+ routeParams = { id: '14' }
+ store.collections = [collection({ id: 2 })]
+ await setup()
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/collections', { replace: true }))
+ })
+
+ it('FE-PAGE-COLL-089: no bounce while the lists are still loading', async () => {
+ routeParams = { id: '14' }
+ let finish: () => void = () => {}
+ store.loadAll.mockImplementationOnce(() => new Promise(res => { finish = res }))
+ await setup()
+ expect(navigate).not.toHaveBeenCalled()
+ await act(async () => { finish() })
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/collections', { replace: true }))
+ })
+
+ it('FE-PAGE-COLL-090: a route id that is one of the lists stays put', async () => {
+ routeParams = { id: '14' }
+ store.collections = [collection({ id: 14 })]
+ await setup()
+ await act(async () => {})
+ expect(navigate).not.toHaveBeenCalled()
+ })
+})
diff --git a/client/src/pages/collections/useCollections.ts b/client/src/pages/collections/useCollections.ts
index 1857e0cd5..137b1a4b6 100644
--- a/client/src/pages/collections/useCollections.ts
+++ b/client/src/pages/collections/useCollections.ts
@@ -79,8 +79,11 @@ export function useCollections() {
// place from the detail panel, or the select-mode set for a bulk copy.
const [copyIds, setCopyIds] = useState(null)
- // Initial load.
- useEffect(() => { loadAll() }, [])
+ // Initial load. listsLoaded says the rail is fresh from this visit, which
+ // the route check below waits for before it calls a list id unknown. A load
+ // that fails leaves it off, so an offline page never drops the route.
+ const [listsLoaded, setListsLoaded] = useState(false)
+ useEffect(() => { loadAll().then(() => setListsLoaded(true), () => setListsLoaded(false)) }, [])
// Central admin-defined categories, for assigning a category to a saved place.
const [categories, setCategories] = useState([])
@@ -104,15 +107,41 @@ export function useCollections() {
return () => cancelAnimationFrame(raf)
}, [view, isWide])
+ // The invite notification links to /collections/:id, a list the invitee
+ // cannot read before they accept (#2645). When the route is such an invite
+ // the page offers it instead of opening the list, and lists it first.
+ const routeInvite = useMemo(() => {
+ if (!routeId) return null
+ const id = Number(routeId)
+ return incomingInvites.find(inv => inv.collection_id === id) ?? null
+ }, [routeId, incomingInvites])
+ const orderedInvites = useMemo(
+ () => (routeInvite ? [routeInvite, ...incomingInvites.filter(inv => inv !== routeInvite)] : incomingInvites),
+ [routeInvite, incomingInvites],
+ )
+
// Keep the active list in sync with the URL (/collections/:id, or the
- // "All saved" union at /collections).
+ // "All saved" union at /collections). A pending invite on the route shows
+ // the union behind its card.
useEffect(() => {
- const next: ActiveCollectionId = routeId ? Number(routeId) : ALL_SAVED
+ const next: ActiveCollectionId = routeId && !routeInvite ? Number(routeId) : ALL_SAVED
if (Number.isNaN(next as number)) return
if (next !== activeId) setActive(next)
- // Only re-run when the route or the loaded list set changes.
+ // Only re-run when the route, the loaded list set or the invite on it changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [routeId, collections.length])
+ }, [routeId, collections.length, routeInvite])
+
+ // A route id that is neither one of the lists nor a pending invite is a link
+ // to something gone: a declined or withdrawn invite, a deleted list, one the
+ // person was removed from. Drop it once the lists are in, rather than leave
+ // the URL on a list the page cannot show.
+ useEffect(() => {
+ if (!listsLoaded || loading || !routeId) return
+ const id = Number(routeId)
+ if (Number.isNaN(id)) return
+ if (collections.some(l => l.id === id) || incomingInvites.some(inv => inv.collection_id === id)) return
+ navigate('/collections', { replace: true })
+ }, [listsLoaded, loading, routeId, collections, incomingInvites, navigate])
// Live sync via WebSocket. collections:deleted / :removed must bounce a member
// off a list that was removed (or that they were kicked from) under them.
@@ -490,7 +519,7 @@ export function useCollections() {
collections, ownedLists, sharedLists, activeCollection, isAllSaved, isOwner,
myRole, canEdit, canDelete,
canShare, shareMemberCount,
- activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, counts,
+ activeId, places, visiblePlaces, mappable, hasMappable, members, incomingInvites, routeInvite, orderedInvites, counts,
view, statusFilter, categoryFilter, categoryOptions, ratingFilter, sortMode, search, selectedPlaceId, selectMode, selectedIds,
labels, labelFilter, labelOptions,
loading, placesLoading,
diff --git a/client/src/store/collectionStore.ts b/client/src/store/collectionStore.ts
index 265c558fa..3455be9df 100644
--- a/client/src/store/collectionStore.ts
+++ b/client/src/store/collectionStore.ts
@@ -180,8 +180,8 @@ export const useCollectionStore = create((set, get) => ({
} 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.
+ // it instead of leaving an uncaught 403/404 rejection; useCollections drops
+ // a route id that is no longer one of the lists.
if (get().activeId === id) set({ activeId: null, places: [], members: [], labels: [] })
} finally {
set({ placesLoading: false })
diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css
index 762bfd951..22bb9dc89 100644
--- a/client/src/styles/collections.css
+++ b/client/src/styles/collections.css
@@ -92,6 +92,9 @@
.trek-dash .col-invite-accept:hover { opacity: .9; }
.trek-dash .col-invite-decline { border: 1px solid var(--line-2); color: var(--ink-2); }
.trek-dash .col-invite-decline:hover { background: var(--surface); }
+/* the same cards at the top of the page body (no lists yet, or the invite's own link) */
+.trek-dash .col-invites-main { max-width: 420px; margin: 0 auto 22px; }
+.trek-dash .col-invites-main .col-invite { padding: 13px; }
/* ----------------- hero ----------------- */
.trek-dash .col-hero {
diff --git a/client/tests/unit/mobile/collections/MCollections.test.tsx b/client/tests/unit/mobile/collections/MCollections.test.tsx
index 1f90cd9ac..b44bec202 100644
--- a/client/tests/unit/mobile/collections/MCollections.test.tsx
+++ b/client/tests/unit/mobile/collections/MCollections.test.tsx
@@ -1,4 +1,4 @@
-// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-031
+// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-033
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../helpers/render'
@@ -159,6 +159,8 @@ function makeHook(over: Record = {}): Record {
mappable: [place(10, 'Elbphilharmonie', { lat: 53.5, lng: 10 })],
members: [],
incomingInvites: [],
+ orderedInvites: [],
+ routeInvite: null,
counts: { all: 1, idea: 1, want: 0, visited: 0 },
view: 'list',
statusFilter: 'all',
@@ -186,6 +188,7 @@ function makeHook(over: Record = {}): Record {
copyIds: null, openCopyForSelectedPlace: vi.fn(), openCopyForSelection: vi.fn(), closeCopy: vi.fn(), handleCopyToTrip: vi.fn(),
handleSelectList: vi.fn(), handleDeleteList: vi.fn(),
handleStatusChange: vi.fn(), handleRatePlace: vi.fn(), handleDeleteSelected: vi.fn(),
+ handleAcceptInvite: vi.fn(), handleDeclineInvite: vi.fn(),
allVisibleSelected: false, handleSelectAll: vi.fn(),
listPickerMode: null, setListPickerMode: vi.fn(), handleMoveToList: vi.fn(), handleDuplicateToList: vi.fn(),
...over,
@@ -658,3 +661,34 @@ describe('MCollections', () => {
expect(screen.getByText('add-target:null::1')).toBeInTheDocument()
})
})
+
+// #2645: the phone had no way to answer a list invite, and the notification's
+// link landed on "Start your first collection".
+describe('MCollections: invites (#2645)', () => {
+ const invite = { collection_id: 14, name: 'Lisbon', from: { id: 4, username: 'Lena' } }
+
+ it('FE-MOB-COLSCR-032: with no lists of their own, the invite can be accepted or declined', () => {
+ mocks.coll = makeHook({
+ collections: [], ownedLists: [], activeCollection: null, activeId: null, canShare: false,
+ incomingInvites: [invite], orderedInvites: [invite], routeInvite: invite,
+ })
+ render()
+
+ expect(screen.getByText('Lisbon')).toBeInTheDocument()
+ expect(screen.getByText(/Lena/)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /collections.invites.accept/ }))
+ expect(fn('handleAcceptInvite')).toHaveBeenCalledWith(14)
+ fireEvent.click(screen.getByRole('button', { name: 'collections.invites.decline' }))
+ expect(fn('handleDeclineInvite')).toHaveBeenCalledWith(14)
+ })
+
+ it('FE-MOB-COLSCR-033: invites sit above the lists too, in the order the hook gives', () => {
+ const other = { collection_id: 3, name: 'Rome', from: { id: 5, username: 'Tom' } }
+ mocks.coll = makeHook({ incomingInvites: [other, invite], orderedInvites: [invite, other] })
+ render()
+
+ const names = screen.getAllByText(/^(Lisbon|Rome)$/).map(el => el.textContent)
+ expect(names).toEqual(['Lisbon', 'Rome'])
+ expect(screen.getByPlaceholderText('collections.search')).toBeInTheDocument()
+ })
+})