mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collab): share the notes list and attachment upload between desktop and phone
useCollabNotesData holds the note list with its first load and the live note events, and the attachment upload both shells run after saving a note. Writing notes, their colours and categories stays in each shell, since the desktop panel keeps empty categories and colour batches the phone tab does not have. A failed load still empties the desktop list only.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useId, useMemo } from 'react'
|
import { useState, useCallback, useId, useMemo } from 'react'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
@@ -10,13 +10,13 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
|
|||||||
import { collabApi } from '../../api/client'
|
import { collabApi } from '../../api/client'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { addListener, removeListener } from '../../api/websocket'
|
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
import type { CollabNote } from './CollabNotes.types'
|
import type { CollabNote } from './CollabNotes.types'
|
||||||
|
import { useCollabNotesData } from './useCollabNotesData'
|
||||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||||
import { NoteFormModal } from './CollabNotesFormModal'
|
import { NoteFormModal } from './CollabNotesFormModal'
|
||||||
import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
|
import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
|
||||||
@@ -43,8 +43,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('collab_edit', trip)
|
const canEdit = can('collab_edit', trip)
|
||||||
const [notes, setNotes] = useState([])
|
const { notes, setNotes, loading, uploadNoteFiles } = useCollabNotesData<CollabNote>({ tripId, t, toast, resetOnLoadError: true })
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [showNewModal, setShowNewModal] = useState(false)
|
const [showNewModal, setShowNewModal] = useState(false)
|
||||||
const [editingNote, setEditingNote] = useState(null)
|
const [editingNote, setEditingNote] = useState(null)
|
||||||
const [viewingNote, setViewingNote] = useState<CollabNote | null>(null)
|
const [viewingNote, setViewingNote] = useState<CollabNote | null>(null)
|
||||||
@@ -77,49 +76,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
return NOTE_COLORS[Object.keys(categoryColors).length % NOTE_COLORS.length].value
|
return NOTE_COLORS[Object.keys(categoryColors).length % NOTE_COLORS.length].value
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load notes on mount ──
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tripId) return
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
collabApi.getNotes(tripId)
|
|
||||||
.then(data => { if (!cancelled) setNotes(data?.notes || data || []) })
|
|
||||||
.catch(() => { if (!cancelled) setNotes([]) })
|
|
||||||
.finally(() => { if (!cancelled) setLoading(false) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
// ── WebSocket real-time sync ──
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tripId) return
|
|
||||||
|
|
||||||
const handler = (msg) => {
|
|
||||||
// The panel is not remounted on a trip change, so an event still in flight
|
|
||||||
// from the trip we just left must not land in this list.
|
|
||||||
if (String(msg?.tripId) !== String(tripId)) return
|
|
||||||
if (msg.type === 'collab:note:created' && msg.note) {
|
|
||||||
setNotes(prev => {
|
|
||||||
if (prev.some(n => n.id === msg.note.id)) return prev
|
|
||||||
return [msg.note, ...prev]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (msg.type === 'collab:note:updated' && msg.note) {
|
|
||||||
setNotes(prev =>
|
|
||||||
prev.map(n => (n.id === msg.note.id ? { ...n, ...msg.note } : n))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (msg.type === 'collab:note:deleted') {
|
|
||||||
const deletedId = msg.noteId || msg.id
|
|
||||||
if (deletedId) {
|
|
||||||
setNotes(prev => prev.filter(n => n.id !== deletedId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addListener(handler)
|
|
||||||
return () => removeListener(handler)
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
// ── Actions ──
|
// ── Actions ──
|
||||||
const handleCreateNote = useCallback(async (data) => {
|
const handleCreateNote = useCallback(async (data) => {
|
||||||
const pendingFiles = data._pendingFiles || []
|
const pendingFiles = data._pendingFiles || []
|
||||||
@@ -135,11 +91,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
const note = created.note || created
|
const note = created.note || created
|
||||||
// Upload pending files
|
// Upload pending files
|
||||||
if (pendingFiles.length > 0 && note.id) {
|
if (pendingFiles.length > 0 && note.id) {
|
||||||
for (const file of pendingFiles) {
|
await uploadNoteFiles(note.id, pendingFiles, err => console.error('Failed to upload note attachment:', err))
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err); toast.error(t('common.error')) }
|
|
||||||
}
|
|
||||||
// Reload note with attachments
|
// Reload note with attachments
|
||||||
const fresh = await collabApi.getNotes(tripId)
|
const fresh = await collabApi.getNotes(tripId)
|
||||||
if (fresh?.notes) setNotes(fresh.notes)
|
if (fresh?.notes) setNotes(fresh.notes)
|
||||||
@@ -151,7 +103,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
return [note, ...prev]
|
return [note, ...prev]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes, uploadNoteFiles])
|
||||||
|
|
||||||
const handleUpdateNote = useCallback(async (noteId, data, opts: { silent?: boolean } = {}) => {
|
const handleUpdateNote = useCallback(async (noteId, data, opts: { silent?: boolean } = {}) => {
|
||||||
let result
|
let result
|
||||||
@@ -168,7 +120,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
|
prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes])
|
||||||
|
|
||||||
// A colour or a rename is N single-note writes; if one of them is rejected the
|
// A colour or a rename is N single-note writes; if one of them is rejected the
|
||||||
// rest still have to run, and the list has to be re-read so it stops showing a
|
// rest still have to run, and the list has to be re-read so it stops showing a
|
||||||
@@ -178,7 +130,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
const fresh = await collabApi.getNotes(tripId)
|
const fresh = await collabApi.getNotes(tripId)
|
||||||
setNotes(fresh?.notes || fresh || [])
|
setNotes(fresh?.notes || fresh || [])
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [tripId])
|
}, [tripId, setNotes])
|
||||||
|
|
||||||
const saveCategoryColors = useCallback(async (newMap) => {
|
const saveCategoryColors = useCallback(async (newMap) => {
|
||||||
let failed = 0
|
let failed = 0
|
||||||
@@ -224,16 +176,12 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
delete data._pendingFiles
|
delete data._pendingFiles
|
||||||
await handleUpdateNote(editingNote.id, data)
|
await handleUpdateNote(editingNote.id, data)
|
||||||
if (pendingFiles.length > 0) {
|
if (pendingFiles.length > 0) {
|
||||||
for (const file of pendingFiles) {
|
await uploadNoteFiles(editingNote.id, pendingFiles)
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
try { await collabApi.uploadNoteFile(tripId, editingNote.id, fd) } catch { toast.error(t('common.error')) }
|
|
||||||
}
|
|
||||||
const fresh = await collabApi.getNotes(tripId)
|
const fresh = await collabApi.getNotes(tripId)
|
||||||
if (fresh?.notes) setNotes(fresh.notes)
|
if (fresh?.notes) setNotes(fresh.notes)
|
||||||
window.dispatchEvent(new Event('collab-files-changed'))
|
window.dispatchEvent(new Event('collab-files-changed'))
|
||||||
}
|
}
|
||||||
}, [editingNote, tripId, handleUpdateNote, toast, t])
|
}, [editingNote, tripId, handleUpdateNote, setNotes, uploadNoteFiles])
|
||||||
|
|
||||||
const handleDeleteNoteFile = useCallback(async (noteId, fileId) => {
|
const handleDeleteNoteFile = useCallback(async (noteId, fileId) => {
|
||||||
try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) }
|
try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) }
|
||||||
@@ -249,7 +197,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
}
|
}
|
||||||
setNotes(prev => prev.filter(n => n.id !== noteId))
|
setNotes(prev => prev.filter(n => n.id !== noteId))
|
||||||
window.dispatchEvent(new Event('collab-files-changed'))
|
window.dispatchEvent(new Event('collab-files-changed'))
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes])
|
||||||
|
|
||||||
// ── Derived data ──
|
// ── Derived data ──
|
||||||
const categories = [...new Set(notes.map(n => n.category).filter(Boolean))]
|
const categories = [...new Set(notes.map(n => n.category).filter(Boolean))]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface CollabNote {
|
|||||||
avatar: string | null
|
avatar: string | null
|
||||||
user_id: number
|
user_id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
updated_at?: string
|
||||||
author?: { username: string; avatar: string | null }
|
author?: { username: string; avatar: string | null }
|
||||||
user?: { username: string; avatar: string | null }
|
user?: { username: string; avatar: string | null }
|
||||||
files?: NoteFile[]
|
files?: NoteFile[]
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// FE-COLLAB-NOTESDATA-001 to FE-COLLAB-NOTESDATA-008: the note list behind the desktop
|
||||||
|
// Collab panel (empties on a failed load) and the phone's notes tab (keeps the list), with
|
||||||
|
// the live note events and the attachment upload both run after saving a note.
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { collabApi } from '../../api/client';
|
||||||
|
import { addListener, removeListener } from '../../api/websocket';
|
||||||
|
import type { CollabNoteData } from './collabModel';
|
||||||
|
import { useCollabNotesData, type CollabNotesDataOptions } from './useCollabNotesData';
|
||||||
|
|
||||||
|
const t = (key: string) => key;
|
||||||
|
|
||||||
|
function note(id: number, over: Partial<CollabNoteData> = {}): CollabNoteData {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
trip_id: 1,
|
||||||
|
user_id: 2,
|
||||||
|
title: `Note ${id}`,
|
||||||
|
content: null,
|
||||||
|
category: null,
|
||||||
|
color: null,
|
||||||
|
website: null,
|
||||||
|
pinned: false,
|
||||||
|
username: 'alice',
|
||||||
|
avatar: null,
|
||||||
|
avatar_url: null,
|
||||||
|
created_at: '2026-05-01T10:00:00Z',
|
||||||
|
attachments: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(over: Partial<CollabNotesDataOptions> = {}) {
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
const hook = renderHook(
|
||||||
|
(props: Partial<CollabNotesDataOptions>) => useCollabNotesData({ tripId: 1, t, toast, ...over, ...props }),
|
||||||
|
{ initialProps: {} }
|
||||||
|
);
|
||||||
|
return { ...hook, toast };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loaded(over: Partial<CollabNotesDataOptions> = {}) {
|
||||||
|
const view = setup(over);
|
||||||
|
await waitFor(() => expect(view.result.current.loading).toBe(false));
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsHandler(): (event: Record<string, unknown>) => void {
|
||||||
|
const calls = vi.mocked(addListener).mock.calls;
|
||||||
|
return calls[calls.length - 1][0] as (event: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(addListener).mockClear();
|
||||||
|
vi.mocked(removeListener).mockClear();
|
||||||
|
vi.spyOn(collabApi, 'getNotes').mockResolvedValue({ notes: [note(1), note(2)] });
|
||||||
|
vi.spyOn(collabApi, 'uploadNoteFile').mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCollabNotesData', () => {
|
||||||
|
it('FE-COLLAB-NOTESDATA-001: loads the notes, from an object or a bare array', async () => {
|
||||||
|
const { result } = setup();
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(collabApi.getNotes).toHaveBeenCalledWith(1);
|
||||||
|
expect(result.current.notes.map((n) => n.id)).toEqual([1, 2]);
|
||||||
|
|
||||||
|
vi.mocked(collabApi.getNotes).mockResolvedValue([note(3)]);
|
||||||
|
const bare = await loaded();
|
||||||
|
expect(bare.result.current.notes.map((n) => n.id)).toEqual([3]);
|
||||||
|
|
||||||
|
vi.mocked(collabApi.getNotes).mockResolvedValue({});
|
||||||
|
const empty = await loaded();
|
||||||
|
expect(empty.result.current.notes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-002: without a trip nothing loads or listens', () => {
|
||||||
|
const { result } = setup({ tripId: 0 });
|
||||||
|
expect(collabApi.getNotes).not.toHaveBeenCalled();
|
||||||
|
expect(addListener).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-003: a failed reload empties the desktop list and keeps the phone list', async () => {
|
||||||
|
const desktop = await loaded({ resetOnLoadError: true });
|
||||||
|
const phone = await loaded();
|
||||||
|
vi.mocked(collabApi.getNotes).mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
desktop.rerender({ tripId: 2 });
|
||||||
|
phone.rerender({ tripId: 2 });
|
||||||
|
await waitFor(() => expect(desktop.result.current.loading).toBe(false));
|
||||||
|
await waitFor(() => expect(phone.result.current.loading).toBe(false));
|
||||||
|
expect(desktop.result.current.notes).toEqual([]);
|
||||||
|
expect(phone.result.current.notes.map((n) => n.id)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-004: live events create, update and delete, only for this trip', async () => {
|
||||||
|
const { result } = await loaded();
|
||||||
|
const handler = wsHandler();
|
||||||
|
act(() => {
|
||||||
|
handler({ type: 'collab:note:created', tripId: 2, note: note(7) });
|
||||||
|
handler({ type: 'collab:note:created', tripId: '1', note: note(7) });
|
||||||
|
handler({ type: 'collab:note:created', tripId: 1, note: note(7) });
|
||||||
|
handler({ type: 'collab:note:updated', tripId: 1, note: { id: 1, title: 'renamed' } });
|
||||||
|
handler({ type: 'collab:note:updated', tripId: 1 });
|
||||||
|
});
|
||||||
|
expect(result.current.notes.map((n) => n.id)).toEqual([7, 1, 2]);
|
||||||
|
// An update folds into the note it names.
|
||||||
|
expect(result.current.notes[1]).toMatchObject({ title: 'renamed', username: 'alice' });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handler({ type: 'collab:note:deleted', tripId: 1, noteId: 7 });
|
||||||
|
handler({ type: 'collab:note:deleted', tripId: 1, id: 1 });
|
||||||
|
handler({ type: 'collab:note:deleted', tripId: 1 });
|
||||||
|
});
|
||||||
|
expect(result.current.notes.map((n) => n.id)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-005: unmounting removes the live listener', async () => {
|
||||||
|
const { unmount } = await loaded();
|
||||||
|
const handler = wsHandler();
|
||||||
|
unmount();
|
||||||
|
expect(removeListener).toHaveBeenCalledWith(handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-006: attachments upload one by one as a file field', async () => {
|
||||||
|
const { result, toast } = await loaded();
|
||||||
|
const a = new File(['a'], 'a.pdf');
|
||||||
|
const b = new File(['b'], 'b.png');
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.uploadNoteFiles(5, [a, b]);
|
||||||
|
});
|
||||||
|
const calls = vi.mocked(collabApi.uploadNoteFile).mock.calls;
|
||||||
|
expect(calls.map((c) => [c[0], c[1]])).toEqual([
|
||||||
|
[1, 5],
|
||||||
|
[1, 5],
|
||||||
|
]);
|
||||||
|
expect(((calls[0][2] as FormData).get('file') as File).name).toBe(a.name);
|
||||||
|
expect(((calls[1][2] as FormData).get('file') as File).name).toBe(b.name);
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-007: a refused file is reported and toasted, the rest still go', async () => {
|
||||||
|
const err = new Error('too big');
|
||||||
|
vi.mocked(collabApi.uploadNoteFile).mockRejectedValueOnce(err);
|
||||||
|
const onFileError = vi.fn();
|
||||||
|
const { result, toast } = await loaded();
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.uploadNoteFiles(5, [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')], onFileError);
|
||||||
|
});
|
||||||
|
expect(collabApi.uploadNoteFile).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onFileError).toHaveBeenCalledWith(err);
|
||||||
|
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('common.error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COLLAB-NOTESDATA-008: setNotes lets a shell write the list it reloaded', async () => {
|
||||||
|
const { result } = await loaded();
|
||||||
|
act(() => result.current.setNotes([note(9)]));
|
||||||
|
expect(result.current.notes.map((n) => n.id)).toEqual([9]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { collabApi } from '../../api/client';
|
||||||
|
import { addListener, removeListener } from '../../api/websocket';
|
||||||
|
import type { useTranslation } from '../../i18n';
|
||||||
|
import type { useToast } from '../shared/Toast';
|
||||||
|
import type { CollabNoteData } from './collabModel';
|
||||||
|
|
||||||
|
export interface CollabNotesDataOptions {
|
||||||
|
tripId: number;
|
||||||
|
t: ReturnType<typeof useTranslation>['t'];
|
||||||
|
toast: ReturnType<typeof useToast>;
|
||||||
|
/** The desktop panel outlives a trip change, so a failed load there empties the list. */
|
||||||
|
resetOnLoadError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* WebSocket note events, and the attachment upload both run after a note is saved. Writing
|
||||||
|
* a note, its colours and its categories stays with each shell, which differ there. Collab
|
||||||
|
* has no store slice, so the notes live here.
|
||||||
|
*/
|
||||||
|
export function useCollabNotesData<N extends { id: number } = CollabNoteData>({
|
||||||
|
tripId,
|
||||||
|
t,
|
||||||
|
toast,
|
||||||
|
resetOnLoadError = false,
|
||||||
|
}: CollabNotesDataOptions) {
|
||||||
|
const [notes, setNotes] = useState<N[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tripId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
collabApi
|
||||||
|
.getNotes(tripId)
|
||||||
|
.then((data: N[] | { notes?: N[] } | null) => {
|
||||||
|
if (!cancelled) setNotes(Array.isArray(data) ? data : data?.notes || []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled && resetOnLoadError) setNotes([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [tripId, resetOnLoadError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tripId) return;
|
||||||
|
const handler = (msg: Record<string, unknown>) => {
|
||||||
|
// An event still in flight from a trip just left must not land in this list.
|
||||||
|
if (String(msg?.tripId) !== String(tripId)) return;
|
||||||
|
const note = msg.note as N | undefined;
|
||||||
|
if (msg.type === 'collab:note:created' && note) {
|
||||||
|
setNotes((prev) => (prev.some((n) => n.id === note.id) ? prev : [note, ...prev]));
|
||||||
|
}
|
||||||
|
if (msg.type === 'collab:note:updated' && note) {
|
||||||
|
setNotes((prev) => prev.map((n) => (n.id === note.id ? { ...n, ...note } : n)));
|
||||||
|
}
|
||||||
|
if (msg.type === 'collab:note:deleted') {
|
||||||
|
const deletedId = (msg.noteId || msg.id) as number | undefined;
|
||||||
|
if (deletedId) setNotes((prev) => prev.filter((n) => n.id !== deletedId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addListener(handler);
|
||||||
|
return () => removeListener(handler);
|
||||||
|
}, [tripId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
const uploadNoteFiles = useCallback(
|
||||||
|
async (noteId: number, files: File[], onFileError?: (err: unknown) => void) => {
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
try {
|
||||||
|
await collabApi.uploadNoteFile(tripId, noteId, fd);
|
||||||
|
} catch (err) {
|
||||||
|
onFileError?.(err);
|
||||||
|
toast.error(t('common.error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tripId, toast, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { notes, setNotes, loading, uploadNoteFiles };
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../../../
|
|||||||
import { Check, ExternalLink, FileText, Paperclip, Pin, PinOff, Plus, StickyNote, Trash2, X } from 'lucide-react'
|
import { Check, ExternalLink, FileText, Paperclip, Pin, PinOff, Plus, StickyNote, Trash2, X } from 'lucide-react'
|
||||||
import MDancingTrek from '../../../components/MDancingTrek'
|
import MDancingTrek from '../../../components/MDancingTrek'
|
||||||
import { collabApi } from '../../../../api/client'
|
import { collabApi } from '../../../../api/client'
|
||||||
import { addListener, removeListener } from '../../../../api/websocket'
|
import { useCollabNotesData } from '../../../../components/Collab/useCollabNotesData'
|
||||||
import { openFile } from '../../../../utils/fileDownload'
|
import { openFile } from '../../../../utils/fileDownload'
|
||||||
import { safeExternalHref } from '../../../../utils/safeUrl'
|
import { safeExternalHref } from '../../../../utils/safeUrl'
|
||||||
import MSheet from '../../../components/MSheet'
|
import MSheet from '../../../components/MSheet'
|
||||||
@@ -47,9 +47,9 @@ function linkHost(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trip-tab Collab / Notes. Same architecture as MCollabChat: own state, own
|
* Trip-tab Collab / Notes. The list and its WebSocket listener come from
|
||||||
* `collabApi` calls, own WebSocket listener — no `tripStore`/`tripActions`
|
* useCollabNotesData, the hook the desktop panel reads too; no
|
||||||
* (10-tab-databindings.md §8.4). The demo only has a placeholder for this
|
* `tripStore`/`tripActions` (10-tab-databindings.md §8.4). The demo only has a placeholder for this
|
||||||
* sub-tab, so the card/filter/form design below is new (spec 03 §6.4 audit
|
* sub-tab, so the card/filter/form design below is new (spec 03 §6.4 audit
|
||||||
* item), built in the same visual language as MTransportsTab.
|
* item), built in the same visual language as MTransportsTab.
|
||||||
*/
|
*/
|
||||||
@@ -58,44 +58,12 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
|||||||
const canEdit = planner.can('collab_edit', planner.trip)
|
const canEdit = planner.can('collab_edit', planner.trip)
|
||||||
const canUploadFiles = planner.can('file_upload', planner.trip)
|
const canUploadFiles = planner.can('file_upload', planner.trip)
|
||||||
|
|
||||||
const [notes, setNotes] = useState<CollabNoteData[]>([])
|
const { notes, setNotes, loading, uploadNoteFiles } = useCollabNotesData({ tripId, t, toast })
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||||
const [formTarget, setFormTarget] = useState<NoteFormTarget | null>(null)
|
const [formTarget, setFormTarget] = useState<NoteFormTarget | null>(null)
|
||||||
const [viewingNote, setViewingNote] = useState<CollabNoteData | null>(null)
|
const [viewingNote, setViewingNote] = useState<CollabNoteData | null>(null)
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
|
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null)
|
||||||
|
|
||||||
// ── Load ──
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
collabApi.getNotes(tripId).then((data: GetNotesResponse) => {
|
|
||||||
if (!cancelled) setNotes(data.notes || [])
|
|
||||||
}).catch(() => { /* leave notes empty */ }).finally(() => { if (!cancelled) setLoading(false) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
// ── WebSocket (own listener, not handleRemoteEvent) ──
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (event: Record<string, unknown>) => {
|
|
||||||
if (String(event.tripId) !== String(tripId)) return
|
|
||||||
if (event.type === 'collab:note:created') {
|
|
||||||
const note = event.note as CollabNoteData
|
|
||||||
setNotes(prev => (prev.some(n => n.id === note.id) ? prev : [note, ...prev]))
|
|
||||||
}
|
|
||||||
if (event.type === 'collab:note:updated') {
|
|
||||||
const note = event.note as CollabNoteData
|
|
||||||
setNotes(prev => prev.map(n => (n.id === note.id ? { ...n, ...note } : n)))
|
|
||||||
}
|
|
||||||
if (event.type === 'collab:note:deleted') {
|
|
||||||
const noteId = event.noteId as number
|
|
||||||
setNotes(prev => prev.filter(n => n.id !== noteId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addListener(handler)
|
|
||||||
return () => removeListener(handler)
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
const categories = noteCategoriesList(notes)
|
const categories = noteCategoriesList(notes)
|
||||||
const colorMap = buildCategoryColorMap(notes)
|
const colorMap = buildCategoryColorMap(notes)
|
||||||
const sorted = sortNotes(notes, activeCategory)
|
const sorted = sortNotes(notes, activeCategory)
|
||||||
@@ -113,17 +81,13 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
|||||||
throw new Error('create failed')
|
throw new Error('create failed')
|
||||||
}
|
}
|
||||||
if (data.pendingFiles.length > 0) {
|
if (data.pendingFiles.length > 0) {
|
||||||
for (const file of data.pendingFiles) {
|
await uploadNoteFiles(created.id, data.pendingFiles)
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
try { await collabApi.uploadNoteFile(tripId, created.id, fd) } catch { toast.error(t('common.error')) }
|
|
||||||
}
|
|
||||||
const fresh = (await collabApi.getNotes(tripId)) as GetNotesResponse
|
const fresh = (await collabApi.getNotes(tripId)) as GetNotesResponse
|
||||||
setNotes(fresh.notes || [])
|
setNotes(fresh.notes || [])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setNotes(prev => (prev.some(n => n.id === created.id) ? prev : [created, ...prev]))
|
setNotes(prev => (prev.some(n => n.id === created.id) ? prev : [created, ...prev]))
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes, uploadNoteFiles])
|
||||||
|
|
||||||
const handleUpdate = useCallback(async (
|
const handleUpdate = useCallback(async (
|
||||||
noteId: number,
|
noteId: number,
|
||||||
@@ -139,17 +103,13 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
|||||||
throw new Error('update failed')
|
throw new Error('update failed')
|
||||||
}
|
}
|
||||||
if (pendingFiles.length > 0) {
|
if (pendingFiles.length > 0) {
|
||||||
for (const file of pendingFiles) {
|
await uploadNoteFiles(noteId, pendingFiles)
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
try { await collabApi.uploadNoteFile(tripId, noteId, fd) } catch { toast.error(t('common.error')) }
|
|
||||||
}
|
|
||||||
const fresh = (await collabApi.getNotes(tripId)) as GetNotesResponse
|
const fresh = (await collabApi.getNotes(tripId)) as GetNotesResponse
|
||||||
setNotes(fresh.notes || [])
|
setNotes(fresh.notes || [])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (updated) setNotes(prev => prev.map(n => (n.id === noteId ? { ...n, ...updated } : n)))
|
if (updated) setNotes(prev => prev.map(n => (n.id === noteId ? { ...n, ...updated } : n)))
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes, uploadNoteFiles])
|
||||||
|
|
||||||
const handleDelete = useCallback(async (noteId: number) => {
|
const handleDelete = useCallback(async (noteId: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -158,7 +118,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
}
|
}
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes])
|
||||||
|
|
||||||
// Returns whether the file is really gone — the form sheet only drops the
|
// Returns whether the file is really gone — the form sheet only drops the
|
||||||
// chip once the server confirmed it.
|
// chip once the server confirmed it.
|
||||||
@@ -173,7 +133,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
|||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}, [tripId, toast, t])
|
}, [tripId, toast, t, setNotes])
|
||||||
|
|
||||||
const openNote = (note: CollabNoteData) => (canEdit ? setFormTarget(note) : setViewingNote(note))
|
const openNote = (note: CollabNoteData) => (canEdit ? setFormTarget(note) : setViewingNote(note))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user