mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the desktop component with the identical bug sat in another package and was left alone, which leaves the two disagreeing about the same handler. So: the appearance tab flushes its debounced write on unmount, the journey share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted install), the admin collab toggle rolls back from a scoped snapshot instead of a stale one, the reservation modal parses metadata through the guarded helper rather than a bare JSON.parse that takes the route down, and the notification toggle surfaces its failures and stops racing concurrent toggles. The Journey components reached the memories provider-status endpoint with a raw fetch(), past every apiClient interceptor; they go through a small memoriesApi now. And the server half of the OSM menu_url finding: the client renders it through safeHttpUrl, so place-enrichment should not be handing a javascript: value out in the first place. The share section hides itself when the server says the link is not this user's to manage, rather than offering a create button that 403s.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
// FE-APISURF-001 to FE-APISURF-052
|
// FE-APISURF-001 to FE-APISURF-054
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import type { AxiosResponse } from 'axios'
|
import type { AxiosResponse } from 'axios'
|
||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
|
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
|
||||||
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
|
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
|
||||||
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
|
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
|
||||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi,
|
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
|
||||||
} from './client'
|
} from './client'
|
||||||
|
|
||||||
interface Recorded { method: string; url: string; body: unknown }
|
interface Recorded { method: string; url: string; body: unknown }
|
||||||
@@ -396,6 +396,20 @@ describe('client > endpoint wiring', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
|
||||||
|
await assertCalls([
|
||||||
|
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
|
||||||
|
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
|
||||||
|
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
|
||||||
|
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
|
||||||
|
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
|
||||||
|
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||||
await assertCalls([
|
await assertCalls([
|
||||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
} from '@trek/shared'
|
} from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
import { probeNow } from '../sync/connectivity'
|
import { probeNow } from '../sync/connectivity'
|
||||||
|
import { downloadBlob } from '../utils/fileDownload'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||||
@@ -968,6 +969,27 @@ export const journeyApi = {
|
|||||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
|
||||||
|
// The provider sits on the user's own hardware and the server proxies through to
|
||||||
|
// it, so these get a longer leash than the 8s default — a cold Immich answering a
|
||||||
|
// date-range search regularly needs more.
|
||||||
|
const MEMORIES_TIMEOUT = 20000
|
||||||
|
|
||||||
|
export const memoriesApi = {
|
||||||
|
status: (provider: string): Promise<{ connected: boolean }> =>
|
||||||
|
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
|
||||||
|
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
|
||||||
|
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
|
||||||
|
albums: (provider: string) =>
|
||||||
|
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
|
||||||
|
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
|
||||||
|
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
|
||||||
|
timeout: MEMORIES_TIMEOUT,
|
||||||
|
params: passphrase ? { passphrase } : undefined,
|
||||||
|
signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||||
@@ -1144,13 +1166,7 @@ export const backupApi = {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Download failed')
|
if (!res.ok) throw new Error('Download failed')
|
||||||
const blob = await res.blob()
|
downloadBlob(await res.blob(), filename)
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
},
|
},
|
||||||
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
||||||
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
||||||
|
|||||||
@@ -586,11 +586,11 @@ describe('EntryEditor', () => {
|
|||||||
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
|
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-JRN-EDITOR-029: reorders photos locally even when persisting the order fails', async () => {
|
it('FE-JRN-EDITOR-029: rolls the order back when persisting it fails', async () => {
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
server.use(http.patch('/api/journeys/photos/:id', () => {
|
server.use(http.patch('/api/journeys/photos/:id', () => {
|
||||||
attempts += 1
|
attempts += 1
|
||||||
return new HttpResponse(null, { status: 500 })
|
return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
|
||||||
}))
|
}))
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
|
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
|
||||||
@@ -598,8 +598,9 @@ describe('EntryEditor', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
|
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
|
||||||
|
|
||||||
await waitFor(() => expect(attempts).toBe(2))
|
await waitFor(() => expect(attempts).toBe(2))
|
||||||
|
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('sort rejected', 'error', undefined))
|
||||||
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
|
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
|
||||||
expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail'])
|
expect(order).toEqual(['/api/photos/100/thumbnail', '/api/photos/101/thumbnail'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-JRN-EDITOR-030: dropping an unsaved gallery pick cancels its link', async () => {
|
it('FE-JRN-EDITOR-030: dropping an unsaved gallery pick cancels its link', async () => {
|
||||||
@@ -865,6 +866,32 @@ describe('EntryEditor', () => {
|
|||||||
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
|
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-EDITOR-044: reports a rejected save and keeps the editor open', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { onSave, onDone } = mountEditor(buildEntry({ id: 10, title: 'Old' }))
|
||||||
|
onSave.mockRejectedValueOnce(new Error('server said no'))
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('server said no', 'error', undefined))
|
||||||
|
expect(onDone).not.toHaveBeenCalled()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Edit Entry' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-EDITOR-046: gives the preview blob URLs back when the editor closes', async () => {
|
||||||
|
const revoke = vi.spyOn(URL, 'revokeObjectURL')
|
||||||
|
const { container, unmount } = mountEditor(buildEntry())
|
||||||
|
|
||||||
|
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||||
|
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
expect(revoke).toHaveBeenCalledWith('blob:preview')
|
||||||
|
revoke.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
|
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
|
||||||
const { container } = mountEditor(buildEntry({ id: 10 }))
|
const { container } = mountEditor(buildEntry({ id: 10 }))
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useMemo, useState, useRef } from 'react'
|
||||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { journeyApi, mapsApi, addonsApi } from '../../api/client'
|
import { journeyApi, mapsApi, addonsApi, memoriesApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
@@ -52,6 +52,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
|||||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
|
// Minting the preview URL inline in the JSX would hand out a fresh blob on
|
||||||
|
// every keystroke in the story field and never give one back.
|
||||||
|
const pendingUrls = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles])
|
||||||
|
useEffect(() => () => { pendingUrls.forEach(u => URL.revokeObjectURL(u)) }, [pendingUrls])
|
||||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||||
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
|
const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload')
|
||||||
@@ -103,8 +107,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
|||||||
const connected: { id: string; name: string }[] = []
|
const connected: { id: string; name: string }[] = []
|
||||||
for (const provider of enabled) {
|
for (const provider of enabled) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
|
if ((await memoriesApi.status(provider.id)).connected) connected.push({ id: provider.id, name: provider.name })
|
||||||
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
setAvailableProviders(connected)
|
setAvailableProviders(connected)
|
||||||
@@ -186,6 +189,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
|||||||
setPendingProviderGroups([])
|
setPendingProviderGroups([])
|
||||||
}
|
}
|
||||||
onDone()
|
onDone()
|
||||||
|
} catch (err) {
|
||||||
|
// Neither the page callback nor journeyStore toasts, so without this the
|
||||||
|
// whole entry just fails to save with no sign of it.
|
||||||
|
toast.error(getApiErrorMessage(err, t('journey.settings.saveFailed')))
|
||||||
|
return
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -436,13 +444,18 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
|||||||
<button
|
<button
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setPhotos(prev => {
|
const prevOrder = photos
|
||||||
const next = [...prev]
|
const next = [...photos]
|
||||||
const [moved] = next.splice(idx, 1)
|
const [moved] = next.splice(idx, 1)
|
||||||
next.unshift(moved)
|
next.unshift(moved)
|
||||||
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
|
setPhotos(next)
|
||||||
return next
|
// The order is shared: other members, the share view and the
|
||||||
})
|
// PDF all read it, so a rejected write has to go back.
|
||||||
|
Promise.all(next.map((ph, i) => journeyApi.updatePhoto(ph.id, { sort_order: i })))
|
||||||
|
.catch(err => {
|
||||||
|
setPhotos(prevOrder)
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
@@ -468,7 +481,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
|||||||
))}
|
))}
|
||||||
{pendingFiles.map((f, i) => (
|
{pendingFiles.map((f, i) => (
|
||||||
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
|
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
|
||||||
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" />
|
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { normalizeImageFiles } from '../../utils/convertHeic'
|
|||||||
import { isVideoFile } from '../../utils/videoPoster'
|
import { isVideoFile } from '../../utils/videoPoster'
|
||||||
import { useJourneyStore } from '../../store/journeyStore'
|
import { useJourneyStore } from '../../store/journeyStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { journeyApi, addonsApi } from '../../api/client'
|
import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||||
@@ -42,11 +42,8 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
|||||||
const connected: { id: string; name: string }[] = []
|
const connected: { id: string; name: string }[] = []
|
||||||
for (const p of enabledProviders) {
|
for (const p of enabledProviders) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
|
const status = await memoriesApi.status(p.id)
|
||||||
if (res.ok) {
|
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||||
const status = await res.json()
|
|
||||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
setAvailableProviders(connected)
|
setAvailableProviders(connected)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { memoriesApi } from '../../api/client'
|
||||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||||
@@ -64,21 +65,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
|||||||
setSearchTo(to)
|
setSearchTo(to)
|
||||||
setSearchPage(page)
|
setSearchPage(page)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||||
method: 'POST', credentials: 'include', signal,
|
const assets = data.assets || []
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||||
body: JSON.stringify({ from, to, page, size: 50 }),
|
setHasMore(!!data.hasMore)
|
||||||
})
|
} catch {
|
||||||
if (res.ok) {
|
// A cancelled request is about to be replaced by the next one, so leave
|
||||||
const data = await res.json()
|
// its paging state alone.
|
||||||
const assets = data.assets || []
|
if (!signal.aborted) setHasMore(false)
|
||||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
|
||||||
setHasMore(!!data.hasMore)
|
|
||||||
} else {
|
|
||||||
setHasMore(false)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.name !== 'AbortError') setHasMore(false)
|
|
||||||
}
|
}
|
||||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||||
}
|
}
|
||||||
@@ -94,17 +88,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
|||||||
setPhotos([])
|
setPhotos([])
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
try {
|
try {
|
||||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
} catch { /* ignore */ }
|
||||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
|
||||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
|
||||||
if (!signal.aborted) setLoading(false)
|
if (!signal.aborted) setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAlbums = async () => {
|
const loadAlbums = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
|
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||||
if (res.ok) setAlbums((await res.json()).albums || [])
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ beforeEach(() => {
|
|||||||
writeText.mockClear()
|
writeText.mockClear()
|
||||||
window.__addToast = toastSpy
|
window.__addToast = toastSpy
|
||||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
|
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -37,6 +38,18 @@ describe('JourneyShareSection', () => {
|
|||||||
expect(screen.getByText('Public Share')).toBeInTheDocument()
|
expect(screen.getByText('Public Share')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-SHARE-001b: renders nothing at all when the link is not this user to manage', async () => {
|
||||||
|
// Anyone but the owner gets 403 on the read, and would be refused on the
|
||||||
|
// create too - so the section stays out of the dialog rather than offering
|
||||||
|
// a button that cannot work.
|
||||||
|
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })))
|
||||||
|
|
||||||
|
const { container } = render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(container).toBeEmptyDOMElement())
|
||||||
|
expect(screen.queryByText('Public Share')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-JRN-SHARE-002: renders nothing while the share link is still loading', async () => {
|
it('FE-JRN-SHARE-002: renders nothing while the share link is still loading', async () => {
|
||||||
server.use(http.get('/api/journeys/7/share-link', async () => {
|
server.use(http.get('/api/journeys/7/share-link', async () => {
|
||||||
await delay(30)
|
await delay(30)
|
||||||
@@ -99,7 +112,7 @@ describe('JourneyShareSection', () => {
|
|||||||
act(() => { copyBtn.click() })
|
act(() => { copyBtn.click() })
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-abc`)
|
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-abc`)
|
||||||
expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument()
|
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument()
|
||||||
|
|
||||||
act(() => { vi.advanceTimersByTime(2100) })
|
act(() => { vi.advanceTimersByTime(2100) })
|
||||||
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
|
||||||
@@ -179,4 +192,23 @@ describe('JourneyShareSection', () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole('button', { name: /create share link/i })).toBeInTheDocument()
|
expect(await screen.findByRole('button', { name: /create share link/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A self-hosted install served over plain HTTP has no navigator.clipboard, and the
|
||||||
|
// unguarded call used to throw before the button ever showed feedback.
|
||||||
|
it('FE-JRN-SHARE-012: copies through execCommand when the clipboard API is unavailable', async () => {
|
||||||
|
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true, writable: true })
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true, writable: true })
|
||||||
|
const execCommand = vi.fn(() => true)
|
||||||
|
Object.defineProperty(document, 'execCommand', { value: execCommand, configurable: true, writable: true })
|
||||||
|
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||||
|
|
||||||
|
const copyBtn = await screen.findByRole('button', { name: 'Copy' })
|
||||||
|
act(() => { copyBtn.click() })
|
||||||
|
|
||||||
|
await waitFor(() => expect(execCommand).toHaveBeenCalledWith('copy'))
|
||||||
|
expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument()
|
||||||
|
// The temporary textarea is removed again.
|
||||||
|
expect(document.querySelector('textarea')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,20 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
|||||||
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
|
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
/** The share token is the owner's to manage; a contributor is refused. */
|
||||||
|
const [manageable, setManageable] = useState(true)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
|
journeyApi.getShareLink(journeyId)
|
||||||
|
.then(d => setLink(d.link || null))
|
||||||
|
.catch((err: { response?: { status?: number } }) => {
|
||||||
|
// A 403 is the server saying this is not yours to manage. Showing the
|
||||||
|
// section anyway would offer an editor a "create link" button that is
|
||||||
|
// refused the moment they press it.
|
||||||
|
if (err?.response?.status === 403) setManageable(false)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [journeyId])
|
}, [journeyId])
|
||||||
|
|
||||||
const createLink = async () => {
|
const createLink = async () => {
|
||||||
@@ -42,13 +52,28 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
|||||||
|
|
||||||
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
|
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = async () => {
|
||||||
navigator.clipboard.writeText(shareUrl)
|
try {
|
||||||
setCopied(true)
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
setTimeout(() => setCopied(false), 2000)
|
await navigator.clipboard.writeText(shareUrl)
|
||||||
|
} else {
|
||||||
|
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = shareUrl
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.left = '-9999px'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.focus()
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return null
|
if (loading || !manageable) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1423,4 +1423,27 @@ describe('ReservationModal', () => {
|
|||||||
// Hotel place picker still shows its placeholder — no crash on the missing record.
|
// Hotel place picker still shows its placeholder — no crash on the missing record.
|
||||||
expect(screen.getByText('—')).toBeInTheDocument();
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A legacy or corrupted metadata column used to throw straight out of the open
|
||||||
|
// effect and take the whole planner route down.
|
||||||
|
it('FE-PLANNER-RESMODAL-087: unparseable metadata opens the modal with empty check-in times', () => {
|
||||||
|
const res = buildReservation({ id: 17, title: 'Grand Hotel', type: 'hotel' });
|
||||||
|
(res as unknown as { metadata: string }).metadata = '{not json';
|
||||||
|
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Grand Hotel')).toBeInTheDocument();
|
||||||
|
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
|
||||||
|
expect(times.map(i => i.value)).toEqual(['', '', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => {
|
||||||
|
const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' });
|
||||||
|
(res as unknown as { metadata: string }).metadata =
|
||||||
|
JSON.stringify(JSON.stringify({ check_in_time: '16:00', check_out_time: '10:30' }));
|
||||||
|
render(<ReservationModal {...defaultProps} reservation={res} days={reviewDays()} />);
|
||||||
|
|
||||||
|
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
|
||||||
|
expect(times[0].value).toBe('16:00');
|
||||||
|
expect(times[2].value).toBe('10:30');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||||
import { resolveDayId } from '../../utils/formatters'
|
import { resolveDayId } from '../../utils/formatters'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||||
import { BookingCostsSection } from './BookingCostsSection'
|
import { BookingCostsSection } from './BookingCostsSection'
|
||||||
@@ -133,7 +134,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
|
|
||||||
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
|
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
|
||||||
if (reservation) {
|
if (reservation) {
|
||||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
const meta = parseReservationMetadata(reservation)
|
||||||
const rawEnd = reservation.reservation_end_time || ''
|
const rawEnd = reservation.reservation_end_time || ''
|
||||||
let endDate = ''
|
let endDate = ''
|
||||||
let endTime = rawEnd
|
let endTime = rawEnd
|
||||||
|
|||||||
@@ -298,4 +298,35 @@ describe('AppearanceSettingsTab – dashboard widgets', () => {
|
|||||||
PERSIST,
|
PERSIST,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The scheme is already on the DOM by then, so dropping the write would revert the
|
||||||
|
// choice on the next load without ever telling the user.
|
||||||
|
it('FE-COMP-APPEARANCE-023: leaving the tab before the debounce fires flushes the pending save once', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const updateSetting = seedAppearance();
|
||||||
|
const { unmount } = render(<AppearanceSettingsTab />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Indigo'));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const calls = updateSetting.mock.calls.filter(c => c[0] === 'appearance');
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0][1]).toMatchObject({ schemeId: 'indigo' });
|
||||||
|
|
||||||
|
// The timer is cancelled, so nothing lands a second time.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-APPEARANCE-024: a debounce that already fired is not written again on unmount', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const updateSetting = seedAppearance();
|
||||||
|
const { unmount } = render(<AppearanceSettingsTab />);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Teal'));
|
||||||
|
await waitFor(() => expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1), PERSIST);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(updateSetting.mock.calls.filter(c => c[0] === 'appearance')).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
|
const [cfg, setCfg] = useState<AppearanceConfig>(() => normalizeAppearance(settings.appearance))
|
||||||
const persistTimer = useRef<number | undefined>(undefined)
|
const persistTimer = useRef<number | undefined>(undefined)
|
||||||
|
// What the pending timer would have written, so leaving the tab inside the
|
||||||
|
// debounce window still saves instead of silently dropping the change.
|
||||||
|
const pendingWrite = useRef<AppearanceConfig | null>(null)
|
||||||
|
|
||||||
// Re-sync when settings change elsewhere (e.g. server reconcile / another tab).
|
// Re-sync when settings change elsewhere (e.g. server reconcile / another tab).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,8 +92,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
// Flush any pending persist on unmount.
|
// Flush any pending persist on unmount.
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
if (!persistTimer.current) return
|
||||||
}, [])
|
window.clearTimeout(persistTimer.current)
|
||||||
|
// The component is gone, so a failure has nowhere to be shown.
|
||||||
|
if (pendingWrite.current) updateSetting('appearance', pendingWrite.current).catch(() => {})
|
||||||
|
}, [updateSetting])
|
||||||
|
|
||||||
const isDark =
|
const isDark =
|
||||||
settings.dark_mode === true ||
|
settings.dark_mode === true ||
|
||||||
@@ -103,7 +109,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
|||||||
setCfg(next)
|
setCfg(next)
|
||||||
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
|
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
|
||||||
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
||||||
|
pendingWrite.current = next
|
||||||
persistTimer.current = window.setTimeout(() => {
|
persistTimer.current = window.setTimeout(() => {
|
||||||
|
pendingWrite.current = null
|
||||||
updateSetting('appearance', next).catch((e: unknown) =>
|
updateSetting('appearance', next).catch((e: unknown) =>
|
||||||
toast.error(e instanceof Error ? e.message : t('common.error'))
|
toast.error(e instanceof Error ? e.message : t('common.error'))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -755,6 +755,54 @@ describe('NotificationsTab', () => {
|
|||||||
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-NOTIFICATIONS-030: a failing toggle surfaces the generic error', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||||
|
);
|
||||||
|
render(<><NotificationsTab /><ToastContainer /></>);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
const toggles = await screen.findAllByRole('button');
|
||||||
|
await user.click(toggles[0]);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-NOTIFICATIONS-031: a failing toggle leaves a later toggle alone', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let rejectEmail!: () => void;
|
||||||
|
server.use(
|
||||||
|
http.put('/api/notifications/preferences', async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Record<string, Record<string, boolean>>;
|
||||||
|
// The first write is the email flip; in-app is still untouched there.
|
||||||
|
if (body.trip_invite.inapp === true) {
|
||||||
|
return new Promise<Response>(resolve => {
|
||||||
|
rejectEmail = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<><NotificationsTab /><ToastContainer /></>);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
// Email is off in this matrix, in-app is on.
|
||||||
|
const [email, inapp] = await screen.findAllByRole('button');
|
||||||
|
await user.click(email);
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'true'));
|
||||||
|
|
||||||
|
// Flipped while the email write is still hanging.
|
||||||
|
await user.click(inapp);
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false'));
|
||||||
|
|
||||||
|
rejectEmail();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveAttribute('aria-pressed', 'false'));
|
||||||
|
// The in-app toggle the user made meanwhile is not undone.
|
||||||
|
expect(screen.getAllByRole('button')[1]).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -125,7 +125,13 @@ export default function NotificationsTab(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await notificationsApi.updatePreferences(updated)
|
await notificationsApi.updatePreferences(updated)
|
||||||
} catch {
|
} catch {
|
||||||
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
// Only this cell rolls back — restoring the whole snapshot would also undo a
|
||||||
|
// toggle the user made while this request was in flight.
|
||||||
|
setMatrix(m => m ? {
|
||||||
|
...m,
|
||||||
|
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: current } },
|
||||||
|
} : m)
|
||||||
|
toast.error(t('common.error'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { delay, http, HttpResponse } from 'msw';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { buildAdmin } from '../../tests/helpers/factories';
|
import { buildAdmin } from '../../tests/helpers/factories';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
@@ -27,16 +27,21 @@ vi.mock('../components/Admin/AddonManager', () => ({
|
|||||||
default: ({
|
default: ({
|
||||||
bagTrackingEnabled,
|
bagTrackingEnabled,
|
||||||
onToggleBagTracking,
|
onToggleBagTracking,
|
||||||
|
collabFeatures,
|
||||||
onToggleCollabFeature,
|
onToggleCollabFeature,
|
||||||
}: {
|
}: {
|
||||||
bagTrackingEnabled: boolean;
|
bagTrackingEnabled: boolean;
|
||||||
onToggleBagTracking: () => void;
|
onToggleBagTracking: () => void;
|
||||||
|
collabFeatures: Record<string, boolean>;
|
||||||
onToggleCollabFeature: (key: string) => void;
|
onToggleCollabFeature: (key: string) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="addon-manager">
|
<div data-testid="addon-manager">
|
||||||
<span data-testid="bag-tracking-state">{String(bagTrackingEnabled)}</span>
|
<span data-testid="bag-tracking-state">{String(bagTrackingEnabled)}</span>
|
||||||
|
<span data-testid="collab-chat-state">{String(collabFeatures?.chat)}</span>
|
||||||
|
<span data-testid="collab-notes-state">{String(collabFeatures?.notes)}</span>
|
||||||
<button onClick={() => onToggleBagTracking()}>toggle bag tracking</button>
|
<button onClick={() => onToggleBagTracking()}>toggle bag tracking</button>
|
||||||
<button onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
|
<button onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
|
||||||
|
<button onClick={() => onToggleCollabFeature('notes')}>toggle notes</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -1569,8 +1574,38 @@ describe('AdminPage', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
|
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
|
||||||
|
|
||||||
// The rollback re-renders the stub; the addon panel stays mounted either way
|
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
||||||
await waitFor(() => expect(screen.getByTestId('addon-manager')).toBeInTheDocument());
|
});
|
||||||
|
|
||||||
|
// A snapshot-wide rollback would undo the second toggle as well, leaving the
|
||||||
|
// panel disagreeing with the server until a reload.
|
||||||
|
it('a failing collab toggle keeps a second toggle made while it was in flight', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/admin/collab-features', () =>
|
||||||
|
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||||
|
),
|
||||||
|
http.put('/api/admin/collab-features', async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
if ('chat' in body) {
|
||||||
|
await delay(30);
|
||||||
|
return HttpResponse.json({}, { status: 500 });
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||||
|
render(<AdminPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^addons$/i }));
|
||||||
|
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /toggle chat/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /toggle notes/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('collab-chat-state')).toHaveTextContent('true'));
|
||||||
|
expect(screen.getByTestId('collab-notes-state')).toHaveTextContent('false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -161,9 +161,15 @@ function AdminPageDesktop(): React.ReactElement {
|
|||||||
setBagTrackingEnabled(next)
|
setBagTrackingEnabled(next)
|
||||||
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
|
try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) }
|
||||||
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
|
}} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => {
|
||||||
const next = { ...collabFeatures, [key]: !collabFeatures[key] }
|
const previous = collabFeatures[key]
|
||||||
setCollabFeatures(next)
|
setCollabFeatures({ ...collabFeatures, [key]: !previous })
|
||||||
try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) }
|
try {
|
||||||
|
await adminApi.updateCollabFeatures({ [key]: !previous })
|
||||||
|
} catch {
|
||||||
|
// Only this key rolls back — a slower request must not undo a toggle
|
||||||
|
// the admin made in the meantime.
|
||||||
|
setCollabFeatures(prev => ({ ...prev, [key]: previous }))
|
||||||
|
}
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3720,13 +3720,15 @@ describe('JourneyDetailPage', () => {
|
|||||||
it('clicking Copy on share link copies to clipboard', async () => {
|
it('clicking Copy on share link copies to clipboard', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
// Mock clipboard
|
// Mock clipboard. jsdom leaves isSecureContext undefined, which the copy
|
||||||
|
// handler reads as plain HTTP and answers with its execCommand fallback.
|
||||||
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
const mockWriteText = vi.fn().mockResolvedValue(undefined);
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
value: { writeText: mockWriteText },
|
value: { writeText: mockWriteText },
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true, configurable: true });
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/journeys/1/share-link', () => {
|
http.get('/api/journeys/1/share-link', () => {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
|||||||
unlinkTrip: null, setUnlinkTrip: vi.fn(),
|
unlinkTrip: null, setUnlinkTrip: vi.fn(),
|
||||||
showSettings: false, setShowSettings: vi.fn(),
|
showSettings: false, setShowSettings: vi.fn(),
|
||||||
hideSkeletons: false, setHideSkeletons: vi.fn(),
|
hideSkeletons: false, setHideSkeletons: vi.fn(),
|
||||||
mapRef: { current: null }, fullMapRef: { current: null },
|
mapRef: { current: null }, fullMapRef: { current: null }, galleryUploadRef: { current: null },
|
||||||
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
|
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
|
||||||
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
|
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
|
||||||
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
|
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-ADMHOOK-001 to FE-ADMHOOK-045
|
// FE-ADMHOOK-001 to FE-ADMHOOK-049
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -32,8 +32,13 @@ function wrapper({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Mounts the hook and waits until the initial parallel loads have settled. */
|
/** Mounts the hook and waits until the initial parallel loads have settled. */
|
||||||
async function mountAdmin() {
|
async function mountAdmin(url = '/admin') {
|
||||||
const view = renderHook(() => useAdmin(), { wrapper });
|
const at = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<MemoryRouter initialEntries={[url]}>
|
||||||
|
<TranslationProvider>{children}</TranslationProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const view = renderHook(() => useAdmin(), { wrapper: url === '/admin' ? wrapper : at });
|
||||||
await waitFor(() => expect(view.result.current.isLoading).toBe(false));
|
await waitFor(() => expect(view.result.current.isLoading).toBe(false));
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
@@ -732,6 +737,36 @@ describe('useAdmin', () => {
|
|||||||
expect(result.current.users).toHaveLength(2);
|
expect(result.current.users).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-ADMHOOK-046: opens on the tab the URL asks for', async () => {
|
||||||
|
const { result } = await mountAdmin('/admin?tab=audit');
|
||||||
|
expect(result.current.activeTab).toBe('audit');
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A tab id with no panel behind it — a typo, or a link from an install that
|
||||||
|
* has tabs this one does not — used to open the page on an empty content
|
||||||
|
* area with nothing selected in the sidebar.
|
||||||
|
*/
|
||||||
|
it('FE-ADMHOOK-047: falls back to users when the URL names a tab that does not exist', async () => {
|
||||||
|
const { result } = await mountAdmin('/admin?tab=nonsense');
|
||||||
|
expect(result.current.activeTab).toBe('users');
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The sidebar entries are built conditionally but the panels are not, so a
|
||||||
|
* hosted install had its hidden panels one URL away.
|
||||||
|
*/
|
||||||
|
it('FE-ADMHOOK-048: refuses a tab a managed install hides', async () => {
|
||||||
|
seedStore(useAuthStore, { isAuthenticated: true, user: me, managed: true });
|
||||||
|
const { result } = await mountAdmin('/admin?tab=backup');
|
||||||
|
await waitFor(() => expect(result.current.activeTab).toBe('users'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMHOOK-049: keeps that same tab on an install that runs itself', async () => {
|
||||||
|
const { result } = await mountAdmin('/admin?tab=backup');
|
||||||
|
expect(result.current.activeTab).toBe('backup');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-ADMHOOK-044: mcpEnabled follows the addon store', async () => {
|
it('FE-ADMHOOK-044: mcpEnabled follows the addon store', async () => {
|
||||||
const { result } = await mountAdmin();
|
const { result } = await mountAdmin();
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,32 @@ import { useAddonStore } from '../../store/addonStore'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { getApiErrorMessage } from '../../types'
|
import { getApiErrorMessage } from '../../types'
|
||||||
import { useToast } from '../../components/shared/Toast'
|
import { useToast } from '../../components/shared/Toast'
|
||||||
|
import { managedAdminTabs } from '../../managed'
|
||||||
import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel'
|
import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tab id AdminPage can render a panel for, whatever this install offers.
|
||||||
|
*
|
||||||
|
* ?tab= is user input and was taken as read: an id with no panel behind it —
|
||||||
|
* a typo, or a link from an install that has tabs this one does not — opened
|
||||||
|
* the admin page on an empty content area with nothing selected in the
|
||||||
|
* sidebar. Labels and icons stay in AdminPage; this is only the vocabulary.
|
||||||
|
*/
|
||||||
|
const ADMIN_TAB_IDS = [
|
||||||
|
'users', 'defaults', 'config', 'settings', 'addons', 'plugins', 'storage',
|
||||||
|
'notifications', 'mcp-tokens', 'github', 'backup', 'audit', 'dev-notifications',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ones a managed install deliberately does not offer.
|
||||||
|
*
|
||||||
|
* The sidebar entries are built conditionally, but the panels are not, so
|
||||||
|
* ?tab=backup opened a backup schedule that competes with the real one on a
|
||||||
|
* hosted instance. Checked in an effect rather than in the initial state
|
||||||
|
* because `managed` arrives with /app-config, after the first render.
|
||||||
|
*/
|
||||||
|
const MANAGED_HIDDEN = ['storage', 'github', 'backup']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin page logic — owns every admin data slice (users, stats, invites, auth
|
* Admin page logic — owns every admin data slice (users, stats, invites, auth
|
||||||
* toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD
|
* toggles, OIDC, feature flags, API keys, SMTP, version/update) plus the CRUD
|
||||||
@@ -29,7 +53,11 @@ export function useAdmin() {
|
|||||||
// page with eleven of them. Read once for the initial value and written back
|
// page with eleven of them. Read once for the initial value and written back
|
||||||
// on every change, so the address bar keeps saying where the reader is.
|
// on every change, so the address bar keeps saying where the reader is.
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [activeTab, setActiveTabState] = useState<string>(() => searchParams.get('tab') || 'users')
|
const [activeTab, setActiveTabState] = useState<string>(() => {
|
||||||
|
const asked = searchParams.get('tab')
|
||||||
|
const known = [...ADMIN_TAB_IDS, ...managedAdminTabs.map(tab => tab.id)]
|
||||||
|
return asked && known.includes(asked) ? asked : 'users'
|
||||||
|
})
|
||||||
const setActiveTab = useCallback((tab: string) => {
|
const setActiveTab = useCallback((tab: string) => {
|
||||||
setActiveTabState(tab)
|
setActiveTabState(tab)
|
||||||
// replace, not push: eleven tabs would otherwise fill the back button with
|
// replace, not push: eleven tabs would otherwise fill the back button with
|
||||||
@@ -41,6 +69,10 @@ export function useAdmin() {
|
|||||||
return next
|
return next
|
||||||
}, { replace: true })
|
}, { replace: true })
|
||||||
}, [setSearchParams])
|
}, [setSearchParams])
|
||||||
|
useEffect(() => {
|
||||||
|
if (managed && MANAGED_HIDDEN.includes(activeTab)) setActiveTab('users')
|
||||||
|
}, [managed, activeTab, setActiveTab])
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { placeWebsiteSchema } from '@trek/shared';
|
||||||
import type {
|
import type {
|
||||||
MapsPlaceEnrichmentRequest,
|
MapsPlaceEnrichmentRequest,
|
||||||
MapsPlaceEnrichmentResult,
|
MapsPlaceEnrichmentResult,
|
||||||
@@ -173,8 +174,11 @@ export function collectFacts(details: Record<string, unknown> | null): PlaceFact
|
|||||||
// OSM writes several cuisines semicolon-separated and underscored.
|
// OSM writes several cuisines semicolon-separated and underscored.
|
||||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
||||||
|
|
||||||
const menu = typeof details.menu_url === 'string' ? details.menu_url : null;
|
// `menu_url` is a community-editable OSM tag that becomes an href on the
|
||||||
if (menu) push('menu', null, menu);
|
// client, so it goes through the same allow-list as a place's website —
|
||||||
|
// anything but http(s) is dropped rather than rendered as a link.
|
||||||
|
const menu = typeof details.menu_url === 'string' ? details.menu_url.trim() : '';
|
||||||
|
if (placeWebsiteSchema.safeParse(menu).success) push('menu', null, menu);
|
||||||
|
|
||||||
if (yesNo(details.outdoor_seating) === 'yes') push('outdoorSeating', null);
|
if (yesNo(details.outdoor_seating) === 'yes') push('outdoorSeating', null);
|
||||||
if (yesNo(details.takeaway) === 'yes') push('takeaway', null);
|
if (yesNo(details.takeaway) === 'yes') push('takeaway', null);
|
||||||
|
|||||||
@@ -729,6 +729,19 @@ describe('collectFacts', () => {
|
|||||||
expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null });
|
expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ENRICH-052c: drops a menu_url that is not http(s)', () => {
|
||||||
|
// Anyone can edit the OSM tag and the client turns it into an href.
|
||||||
|
for (const menu_url of ['javascript:alert(1)', 'data:text/html,<script>1</script>', 'ftp://host/menu', '']) {
|
||||||
|
expect(collectFacts(osm({ menu_url })).map((f) => f.kind)).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ENRICH-052d: keeps an http menu_url and trims the surrounding whitespace', () => {
|
||||||
|
expect(collectFacts(osm({ menu_url: ' http://example.org/menu ' }))).toEqual([
|
||||||
|
{ kind: 'menu', value: null, url: 'http://example.org/menu' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('ENRICH-053: takes no chips at all from a Google place', () => {
|
it('ENRICH-053: takes no chips at all from a Google place', () => {
|
||||||
expect(collectFacts(null)).toEqual([]);
|
expect(collectFacts(null)).toEqual([]);
|
||||||
// cuisine/outdoor_seating are OSM tagging and never appear on a Google blob;
|
// cuisine/outdoor_seating are OSM tagging and never appear on a Google blob;
|
||||||
|
|||||||
Reference in New Issue
Block a user