fix(collab): note attachments added or removed on the phone show in the Files tab at once

On the phone, attaching a file to a note, detaching one or deleting a
note with attachments left the trip's Files tab on the old list until a
reload. The desktop panel fires the local collab-files-changed event
that useTripWebSocket listens for, because the server leaves the
writer's own socket out of its broadcast; the phone notes tab never
fired it.

The event now lives in the shared useCollabNotesData hook:
uploadNoteFiles announces it once after a batch, and
announceNoteFilesChanged is what both shells call after deleting a note
or a note file. The desktop no longer fires it by hand after an upload.

Red before the fix: tests/unit/mobile/trip/MCollabNotes.test.tsx
FE-MOB-CNOTE-034 failed with "expected 0 to be 1" on the event count
after detaching a file. FE-COLLAB-NOTESDATA-009 covers the hook.
No existing assertions changed.
This commit is contained in:
Maurice
2026-10-09 17:26:25 +02:00
parent 0246e5c317
commit f19b52fb20
5 changed files with 69 additions and 8 deletions
+3 -5
View File
@@ -16,7 +16,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import EmptyState from '../shared/EmptyState'
import type { User } from '../../types'
import type { CollabNote } from './CollabNotes.types'
import { useCollabNotesData } from './useCollabNotesData'
import { announceNoteFilesChanged, useCollabNotesData } from './useCollabNotesData'
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
import { NoteFormModal } from './CollabNotesFormModal'
import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
@@ -101,7 +101,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
// Reload note with attachments
const fresh = await collabApi.getNotes(tripId)
if (fresh?.notes) setNotes(fresh.notes)
window.dispatchEvent(new Event('collab-files-changed'))
return
}
setNotes(prev => {
@@ -185,13 +184,12 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
await uploadNoteFiles(editingNote.id, pendingFiles)
const fresh = await collabApi.getNotes(tripId)
if (fresh?.notes) setNotes(fresh.notes)
window.dispatchEvent(new Event('collab-files-changed'))
}
}, [editingNote, tripId, handleUpdateNote, setNotes, uploadNoteFiles])
const handleDeleteNoteFile = useCallback(async (noteId, fileId) => {
try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) }
window.dispatchEvent(new Event('collab-files-changed'))
announceNoteFilesChanged()
}, [tripId, toast, t])
const handleDeleteNote = useCallback(async (noteId) => {
@@ -202,7 +200,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
throw err
}
setNotes(prev => prev.filter(n => n.id !== noteId))
window.dispatchEvent(new Event('collab-files-changed'))
announceNoteFilesChanged()
}, [tripId, toast, t, setNotes])
// ── Derived data ──
@@ -1,4 +1,4 @@
// FE-COLLAB-NOTESDATA-001 to FE-COLLAB-NOTESDATA-008: the note list behind the desktop
// FE-COLLAB-NOTESDATA-001 to FE-COLLAB-NOTESDATA-009: the note list behind the desktop
// Collab panel (empties on a failed load, waits for a trip id) and the phone's notes tab
// (keeps the list, always loads), with
// the live note events and the attachment upload both run after saving a note.
@@ -170,4 +170,23 @@ describe('useCollabNotesData', () => {
act(() => result.current.setNotes([note(9)]));
expect(result.current.notes.map((n) => n.id)).toEqual([9]);
});
it('FE-COLLAB-NOTESDATA-009: an upload batch tells the Files tab to reload once, an empty one does not', async () => {
const onFilesChanged = vi.fn();
window.addEventListener('collab-files-changed', onFilesChanged);
try {
vi.mocked(collabApi.uploadNoteFile).mockRejectedValueOnce(new Error('too big'));
const { result } = await loaded();
await act(async () => {
await result.current.uploadNoteFiles(5, []);
});
expect(onFilesChanged).not.toHaveBeenCalled();
await act(async () => {
await result.current.uploadNoteFiles(5, [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')]);
});
expect(onFilesChanged).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener('collab-files-changed', onFilesChanged);
}
});
});
@@ -16,6 +16,15 @@ export interface CollabNotesDataOptions {
waitForTripId?: boolean;
}
/**
* Tells the trip's Files tab that note attachments changed. The server leaves the writer's
* own socket out of its broadcast, so the tab that made the change has to say it itself;
* useTripWebSocket listens and reloads the files.
*/
export function announceNoteFilesChanged(): void {
window.dispatchEvent(new Event('collab-files-changed'));
}
/**
* The trip notes behind the desktop Collab panel and the phone's notes tab, each reading
* the rows through its own note type: the list as the server has it, kept current by the
@@ -76,7 +85,8 @@ export function useCollabNotesData<N extends { id: number } = CollabNoteData>({
/**
* Uploads a saved note's attachments one by one; a file the server refuses is toasted
* and the rest still go. `onFileError` sees each refusal before the toast.
* and the rest still go. `onFileError` sees each refusal before the toast. Once the
* batch is through, the Files tab is told to reload.
*/
const uploadNoteFiles = useCallback(
async (noteId: number, files: File[], onFileError?: (err: unknown) => void) => {
@@ -90,6 +100,7 @@ export function useCollabNotesData<N extends { id: number } = CollabNoteData>({
toast.error(t('common.error'));
}
}
if (files.length > 0) announceNoteFilesChanged();
},
[tripId, toast, t]
);
@@ -6,7 +6,7 @@ import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../../../
import { Check, ExternalLink, FileText, Paperclip, Pin, PinOff, Plus, StickyNote, Trash2, X } from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { collabApi } from '../../../../api/client'
import { useCollabNotesData } from '../../../../components/Collab/useCollabNotesData'
import { announceNoteFilesChanged, useCollabNotesData } from '../../../../components/Collab/useCollabNotesData'
import { openFile } from '../../../../utils/fileDownload'
import { safeExternalHref } from '../../../../utils/safeUrl'
import MSheet from '../../../components/MSheet'
@@ -115,6 +115,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
try {
await collabApi.deleteNote(tripId, noteId)
setNotes(prev => prev.filter(n => n.id !== noteId))
announceNoteFilesChanged()
} catch {
toast.error(t('common.error'))
}
@@ -128,6 +129,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
setNotes(prev => prev.map(n => (
n.id === noteId ? { ...n, attachments: n.attachments.filter(f => f.id !== fileId) } : n
)))
announceNoteFilesChanged()
return true
} catch {
toast.error(t('common.error'))
@@ -607,4 +607,35 @@ describe('MCollabNotes', () => {
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('href', 'https://visa.test')
})
it('FE-MOB-CNOTE-034: attaching, detaching and deleting tell the Files tab to reload', async () => {
let filesChanged = 0
const onFilesChanged = () => { filesChanged += 1 }
window.addEventListener('collab-files-changed', onFilesChanged)
try {
serveNotes([note(1, {
title: 'Visa',
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
})])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.click(within(screen.getByText('passport.pdf')).getByRole('button'))
await waitFor(() => expect(deletedFiles).toEqual(['1/5']))
await waitFor(() => expect(filesChanged).toBe(1))
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 'x.pdf')] } })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
await waitFor(() => expect(uploadedTo).toEqual(['1']))
await waitFor(() => expect(filesChanged).toBe(2))
fireEvent.click(await screen.findByRole('button', { name: 'collab.notes.delete' }))
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deletedIds).toEqual(['1']))
await waitFor(() => expect(filesChanged).toBe(3))
} finally {
window.removeEventListener('collab-files-changed', onFilesChanged)
}
})
})