mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +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:
@@ -586,11 +586,11 @@ describe('EntryEditor', () => {
|
||||
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
|
||||
server.use(http.patch('/api/journeys/photos/:id', () => {
|
||||
attempts += 1
|
||||
return new HttpResponse(null, { status: 500 })
|
||||
return HttpResponse.json({ error: 'sort rejected' }, { status: 500 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
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 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'))
|
||||
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 () => {
|
||||
@@ -865,6 +866,32 @@ describe('EntryEditor', () => {
|
||||
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', () => {
|
||||
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 { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
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 { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||
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 [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
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 [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||
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 }[] = []
|
||||
for (const provider of enabled) {
|
||||
try {
|
||||
const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
|
||||
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
|
||||
if ((await memoriesApi.status(provider.id)).connected) connected.push({ id: provider.id, name: provider.name })
|
||||
} catch {}
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
@@ -186,6 +189,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
setPendingProviderGroups([])
|
||||
}
|
||||
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 {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -436,13 +444,18 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setPhotos(prev => {
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.unshift(moved)
|
||||
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
|
||||
return next
|
||||
})
|
||||
const prevOrder = photos
|
||||
const next = [...photos]
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.unshift(moved)
|
||||
setPhotos(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"
|
||||
>
|
||||
@@ -468,7 +481,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<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
|
||||
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"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, addonsApi } from '../../api/client'
|
||||
import { journeyApi, addonsApi, memoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
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 }[] = []
|
||||
for (const p of enabledProviders) {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const status = await res.json()
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
}
|
||||
const status = await memoriesApi.status(p.id)
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
} catch {}
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
@@ -64,21 +65,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
||||
method: 'POST', credentials: 'include', signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to, page, size: 50 }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') setHasMore(false)
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
@@ -94,17 +88,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
|
||||
if (res.ok) setAlbums((await res.json()).albums || [])
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ beforeEach(() => {
|
||||
writeText.mockClear()
|
||||
window.__addToast = toastSpy
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true, writable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,6 +38,18 @@ describe('JourneyShareSection', () => {
|
||||
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 () => {
|
||||
server.use(http.get('/api/journeys/7/share-link', async () => {
|
||||
await delay(30)
|
||||
@@ -99,7 +112,7 @@ describe('JourneyShareSection', () => {
|
||||
act(() => { copyBtn.click() })
|
||||
|
||||
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) })
|
||||
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument()
|
||||
@@ -179,4 +192,23 @@ describe('JourneyShareSection', () => {
|
||||
|
||||
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 [loading, setLoading] = useState(true)
|
||||
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()
|
||||
|
||||
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])
|
||||
|
||||
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 copyLink = () => {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
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 (
|
||||
<div>
|
||||
|
||||
@@ -1423,4 +1423,27 @@ describe('ReservationModal', () => {
|
||||
// Hotel place picker still shows its placeholder — no crash on the missing record.
|
||||
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 CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
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)))
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
const meta = parseReservationMetadata(reservation)
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
|
||||
@@ -298,4 +298,35 @@ describe('AppearanceSettingsTab – dashboard widgets', () => {
|
||||
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 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).
|
||||
useEffect(() => {
|
||||
@@ -89,8 +92,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
|
||||
// Flush any pending persist on unmount.
|
||||
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 =
|
||||
settings.dark_mode === true ||
|
||||
@@ -103,7 +109,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
setCfg(next)
|
||||
applyAppearance({ darkMode: settings.dark_mode, appearance: next, isSharedPage: false })
|
||||
if (persistTimer.current) window.clearTimeout(persistTimer.current)
|
||||
pendingWrite.current = next
|
||||
persistTimer.current = window.setTimeout(() => {
|
||||
pendingWrite.current = null
|
||||
updateSetting('appearance', next).catch((e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : t('common.error'))
|
||||
)
|
||||
|
||||
@@ -755,6 +755,54 @@ describe('NotificationsTab', () => {
|
||||
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 {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} 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 {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user