fix: adapt tests for last-page-only dismiss and fix editor z-index

- SystemNoticeModal tests: navigate to last page before testing
  X button, ESC, and CTA dismiss (matches new last-page-only behavior)
- EntryEditor: use z-[9999] instead of portal (fixes iOS stacking
  without breaking test DOM queries)
- Pros/cons inputs: remove colored backgrounds in dark mode
This commit is contained in:
Maurice
2026-04-16 23:46:07 +02:00
parent 0f44d7d264
commit 0e5c819f7c
2 changed files with 41 additions and 20 deletions
@@ -112,8 +112,9 @@ describe('ModalRenderer', () => {
}); });
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => { it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } }); // CTA is only shown on the last page; navigate there first
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss'); const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
@@ -121,6 +122,12 @@ describe('ModalRenderer', () => {
await flushGraceDelay(); await flushGraceDelay();
// Navigate to last page
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' }); const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
await act(async () => { await act(async () => {
fireEvent.click(ctaBtn); fireEvent.click(ctaBtn);
@@ -299,17 +306,22 @@ describe('ModalRenderer', () => {
expect(screen.getByText('Notice A')).toBeTruthy(); expect(screen.getByText('Notice A')).toBeTruthy();
expect(screen.getByText('1 / 3')).toBeTruthy(); expect(screen.getByText('1 / 3')).toBeTruthy();
// Dismiss notice A — store shrinks, parent re-renders with [B, C] // Navigate to last page where X button is available
await act(async () => { await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss')); fireEvent.click(screen.getByLabelText('Go to notice 3'));
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
}); });
await flushGraceDelay(); await flushGraceDelay();
// Must show B (idx=0), not C (idx=1 — the old buggy behavior) // Dismiss all from last page — store shrinks
expect(screen.getByText('Notice B')).toBeTruthy(); await act(async () => {
expect(screen.getByText('1 / 2')).toBeTruthy(); fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [], loaded: true });
rerender(<ModalRenderer notices={[]} />);
});
await flushGraceDelay();
// All dismissed — modal should be gone
expect(screen.queryByRole('dialog')).toBeNull();
}); });
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => { it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
@@ -321,6 +333,12 @@ describe('ModalRenderer', () => {
render(<ModalRenderer notices={[noticeA, noticeB]} />); render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay(); await flushGraceDelay();
// X button only appears on the last page — navigate there
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
await act(async () => { await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss')); fireEvent.click(screen.getByLabelText('Dismiss'));
}); });
@@ -330,7 +348,7 @@ describe('ModalRenderer', () => {
expect(dismissSpy).toHaveBeenCalledTimes(2); expect(dismissSpy).toHaveBeenCalledTimes(2);
}); });
it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => { it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' }); const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' }); const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true }); useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
@@ -339,6 +357,12 @@ describe('ModalRenderer', () => {
render(<ModalRenderer notices={[noticeA, noticeB]} />); render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay(); await flushGraceDelay();
// ESC only works on last page — navigate there first
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
await act(async () => { await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' }); fireEvent.keyDown(document, { key: 'Escape' });
}); });
+7 -10
View File
@@ -1,5 +1,4 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
@@ -228,16 +227,15 @@ export default function JourneyDetailPage() {
/> />
)} )}
{/* Fullscreen entry view (mobile) — portal to body for iOS stacking */} {/* Fullscreen entry view (mobile) */}
{viewingEntry && createPortal( {viewingEntry && (
<MobileEntryView <MobileEntryView
entry={viewingEntry} entry={viewingEntry}
onClose={() => setViewingEntry(null)} onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
/>, />
document.body
)} )}
{/* Floating tab toggle on mobile combined view */} {/* Floating tab toggle on mobile combined view */}
@@ -580,8 +578,8 @@ export default function JourneyDetailPage() {
</div> </div>
</div> </div>
{/* Entry Editor — portal to body to escape stacking context on iOS */} {/* Entry Editor */}
{editingEntry && createPortal( {editingEntry && (
<EntryEditor <EntryEditor
entry={editingEntry} entry={editingEntry}
journeyId={current.id} journeyId={current.id}
@@ -605,8 +603,7 @@ export default function JourneyDetailPage() {
setEditingEntry(null) setEditingEntry(null)
loadJourney(Number(id)) loadJourney(Number(id))
}} }}
/>, />
document.body
)} )}
{/* Journey Settings */} {/* Journey Settings */}
@@ -2093,7 +2090,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}> <div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]"> <div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">