From f19b52fb20fd5fbaf1c73810f38b109d96dde4ac Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 17:26:25 +0200 Subject: [PATCH] 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. --- client/src/components/Collab/CollabNotes.tsx | 8 ++--- .../Collab/useCollabNotesData.test.ts | 21 ++++++++++++- .../components/Collab/useCollabNotesData.ts | 13 +++++++- .../mobile/screens/trip/tabs/MCollabNotes.tsx | 4 ++- .../unit/mobile/trip/MCollabNotes.test.tsx | 31 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 043249601..95e540576 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -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 ── diff --git a/client/src/components/Collab/useCollabNotesData.test.ts b/client/src/components/Collab/useCollabNotesData.test.ts index 7c5c76852..62a701639 100644 --- a/client/src/components/Collab/useCollabNotesData.test.ts +++ b/client/src/components/Collab/useCollabNotesData.test.ts @@ -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); + } + }); }); diff --git a/client/src/components/Collab/useCollabNotesData.ts b/client/src/components/Collab/useCollabNotesData.ts index 7a07493b3..bffc28e59 100644 --- a/client/src/components/Collab/useCollabNotesData.ts +++ b/client/src/components/Collab/useCollabNotesData.ts @@ -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({ /** * 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({ toast.error(t('common.error')); } } + if (files.length > 0) announceNoteFilesChanged(); }, [tripId, toast, t] ); diff --git a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx index c1a1d2c13..d70b47dde 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx @@ -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')) diff --git a/client/tests/unit/mobile/trip/MCollabNotes.test.tsx b/client/tests/unit/mobile/trip/MCollabNotes.test.tsx index 8172ac3b9..0ebcd4880 100644 --- a/client/tests/unit/mobile/trip/MCollabNotes.test.tsx +++ b/client/tests/unit/mobile/trip/MCollabNotes.test.tsx @@ -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) + } + }) })