Spaces:
Sleeping
Sleeping
| // FE-COMP-NOTES-001 to FE-COMP-NOTES-012 | |
| // CollabNotes uses addListener/removeListener from websocket — extend the global mock | |
| vi.mock('../../api/websocket', () => ({ | |
| connect: vi.fn(), | |
| disconnect: vi.fn(), | |
| getSocketId: vi.fn(() => null), | |
| setRefetchCallback: vi.fn(), | |
| setPreReconnectHook: vi.fn(), | |
| addListener: vi.fn(), | |
| removeListener: vi.fn(), | |
| })); | |
| import { render, screen, waitFor, act } from '../../../tests/helpers/render'; | |
| import userEvent from '@testing-library/user-event'; | |
| import { http, HttpResponse } from 'msw'; | |
| import { server } from '../../../tests/helpers/msw/server'; | |
| import { useAuthStore } from '../../store/authStore'; | |
| import { useTripStore } from '../../store/tripStore'; | |
| import { resetAllStores, seedStore } from '../../../tests/helpers/store'; | |
| import { buildUser, buildTrip } from '../../../tests/helpers/factories'; | |
| import CollabNotes from './CollabNotes'; | |
| const currentUser = buildUser({ id: 1, username: 'testuser' }); | |
| const defaultProps = { | |
| tripId: 1, | |
| currentUser, | |
| }; | |
| beforeEach(() => { | |
| resetAllStores(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ notes: [] }) | |
| ), | |
| ); | |
| seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); | |
| seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); | |
| }); | |
| describe('CollabNotes', () => { | |
| it('FE-COMP-NOTES-001: renders without crashing', () => { | |
| render(<CollabNotes {...defaultProps} />); | |
| expect(document.body).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-002: shows empty state when no notes', async () => { | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| }); | |
| it('FE-COMP-NOTES-003: shows New Note button', async () => { | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| expect(screen.getByText('New Note')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-004: shows existing notes from API', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: currentUser.id, author_username: 'testuser', | |
| author_avatar: null, title: 'Packing Tips', content: 'Bring sunscreen', | |
| category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Packing Tips'); | |
| }); | |
| it('FE-COMP-NOTES-005: clicking New Note opens modal', async () => { | |
| const user = userEvent.setup(); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByText('New Note')); | |
| // Modal opens with a title input — placeholder is "Note title" (no ellipsis) | |
| await screen.findByPlaceholderText('Note title'); | |
| }); | |
| it('FE-COMP-NOTES-006: note title is shown in the grid', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', | |
| author_avatar: null, title: 'My Checklist', content: 'Items', | |
| category: 'Travel', color: '#ef4444', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('My Checklist'); | |
| }); | |
| it('FE-COMP-NOTES-007: multiple notes all render', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [ | |
| { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Note A', content: '', category: null, color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, | |
| { id: 2, trip_id: 1, user_id: 2, author_username: 'alice', author_avatar: null, title: 'Note B', content: '', category: null, color: '#ef4444', files: [], created_at: '2025-06-01T10:01:00.000Z', updated_at: '2025-06-01T10:01:00.000Z' }, | |
| ], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Note A'); | |
| expect(screen.getByText('Note B')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-008: Notes title heading is shown', async () => { | |
| render(<CollabNotes {...defaultProps} />); | |
| // collab.notes.title = "Notes" | |
| await screen.findByText('Notes'); | |
| }); | |
| it('FE-COMP-NOTES-009: create note calls POST API', async () => { | |
| const user = userEvent.setup(); | |
| let postCalled = false; | |
| server.use( | |
| http.post('/api/trips/1/collab/notes', async () => { | |
| postCalled = true; | |
| return HttpResponse.json({ | |
| note: { id: 99, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'New Note', content: '', category: null, color: '#3b82f6', files: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, | |
| }); | |
| }) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByText('New Note')); | |
| const titleInput = await screen.findByPlaceholderText('Note title'); | |
| await user.type(titleInput, 'Test Note'); | |
| // collab.notes.create = "Create" | |
| const createBtn = screen.getByRole('button', { name: /^Create$/i }); | |
| await user.click(createBtn); | |
| await waitFor(() => expect(postCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-NOTES-010: note content is shown when available', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Details', content: 'Bring passport', category: null, color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Details'); | |
| expect(screen.getByText('Bring passport')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-011: category filter buttons appear when notes have categories', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Hotel Info', content: '', category: 'Accommodation', color: '#8b5cf6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| // "Accommodation" appears in both category filter and note card | |
| const els = await screen.findAllByText('Accommodation'); | |
| expect(els.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTES-012: renders loading state initially', () => { | |
| render(<CollabNotes {...defaultProps} />); | |
| // Component starts with loading=true; skeleton or spinner is present | |
| expect(document.body).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 42, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Remove Me', content: '', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.delete('/api/trips/1/collab/notes/42', () => | |
| HttpResponse.json({ success: true }) | |
| ), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Remove Me'); | |
| await user.click(screen.getByTitle('Delete')); | |
| // Deleting now asks for confirmation first — the note stays until confirmed. | |
| expect(screen.getByText('Delete note?')).toBeInTheDocument(); | |
| expect(screen.getByText('Remove Me')).toBeInTheDocument(); | |
| await user.click(document.querySelector('button.bg-red-600') as HTMLElement); | |
| await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-014: pinned note shows pin indicator', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Pinned Note', content: '', category: null, color: '#3b82f6', pinned: true, files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Pinned Note'); | |
| // Unpin button is visible for pinned notes | |
| expect(screen.getByTitle('Unpin')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-015: clicking edit button opens the edit modal', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Editable Note', content: 'Original', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Editable Note'); | |
| await user.click(screen.getByTitle('Edit')); | |
| await screen.findByDisplayValue('Editable Note'); | |
| }); | |
| it('FE-COMP-NOTES-016: category filter hides notes from other categories', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [ | |
| { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Hotels Note', content: '', category: 'Hotels', color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, | |
| { id: 2, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Food Note', content: '', category: 'Food', color: '#ef4444', files: [], created_at: '2025-06-01T10:01:00.000Z', updated_at: '2025-06-01T10:01:00.000Z' }, | |
| ], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Hotels Note'); | |
| expect(screen.getByText('Food Note')).toBeInTheDocument(); | |
| // Category filter pills appear — click the Hotels pill (button with name "Hotels") | |
| await user.click(screen.getByRole('button', { name: 'Hotels' })); | |
| expect(screen.getByText('Hotels Note')).toBeInTheDocument(); | |
| await waitFor(() => expect(screen.queryByText('Food Note')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-017: WebSocket collab:note:created event adds note to grid', async () => { | |
| const { addListener } = await import('../../api/websocket'); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls; | |
| const listener = calls[calls.length - 1][0]; | |
| act(() => { | |
| listener({ | |
| type: 'collab:note:created', | |
| note: { | |
| id: 50, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Live Note', content: '', category: null, color: '#3b82f6', pinned: false, files: [], | |
| created_at: new Date().toISOString(), updated_at: new Date().toISOString(), | |
| }, | |
| }); | |
| }); | |
| await screen.findByText('Live Note'); | |
| }); | |
| it('FE-COMP-NOTES-018: WebSocket collab:note:deleted event removes note', async () => { | |
| const { addListener } = await import('../../api/websocket'); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 7, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'WS Delete', content: '', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('WS Delete'); | |
| const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls; | |
| const listener = calls[calls.length - 1][0]; | |
| act(() => { | |
| listener({ type: 'collab:note:deleted', noteId: 7 }); | |
| }); | |
| await waitFor(() => expect(screen.queryByText('WS Delete')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-019: edit note modal pre-populates existing title and content', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 3, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'My Note', content: 'Some content', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('My Note'); | |
| await user.click(screen.getByTitle('Edit')); | |
| await screen.findByDisplayValue('My Note'); | |
| expect(screen.getByDisplayValue('Some content')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-020: saving edited note calls PUT API', async () => { | |
| const user = userEvent.setup(); | |
| let putCalled = false; | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 3, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Old Title', content: '', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.put('/api/trips/1/collab/notes/3', async () => { | |
| putCalled = true; | |
| return HttpResponse.json({ | |
| note: { id: 3, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'New Title', content: '', category: null, color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() }, | |
| }); | |
| }), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Old Title'); | |
| await user.click(screen.getByTitle('Edit')); | |
| const titleInput = await screen.findByDisplayValue('Old Title'); | |
| await user.clear(titleInput); | |
| await user.type(titleInput, 'New Title'); | |
| await user.click(screen.getByRole('button', { name: /^Save$/i })); | |
| await waitFor(() => expect(putCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-NOTES-021: note with markdown content renders formatted output', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Markdown Note', content: '**Bold text**', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Markdown Note'); | |
| const boldEl = screen.getByText('Bold text'); | |
| expect(boldEl.closest('strong')).not.toBeNull(); | |
| }); | |
| it('FE-COMP-NOTES-022: close button in create modal dismisses it without creating', async () => { | |
| const user = userEvent.setup(); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByText('New Note')); | |
| await screen.findByPlaceholderText('Note title'); | |
| // Click the X button in the modal header | |
| const closeBtn = screen.getByRole('button', { name: '' }); | |
| // There may be multiple, find the one in the modal (closest to the title input) | |
| const titleInput = screen.getByPlaceholderText('Note title'); | |
| // The X button is the sibling button in the modal header | |
| const modal = titleInput.closest('form'); | |
| const xBtn = modal?.parentElement?.querySelector('button[type="button"]') as HTMLElement | null; | |
| if (xBtn) { | |
| await user.click(xBtn); | |
| } else { | |
| // Fallback: click backdrop (the outer div) | |
| await user.keyboard('{Escape}'); | |
| } | |
| await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-024: clicking Manage Categories opens the CategorySettingsModal', async () => { | |
| const user = userEvent.setup(); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| // The modal header renders "Category Settings" or similar | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| }); | |
| it('FE-COMP-NOTES-025: CategorySettingsModal shows no categories message when empty', async () => { | |
| const user = userEvent.setup(); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('No categories yet'); | |
| }); | |
| it('FE-COMP-NOTES-026: CategorySettingsModal add new category', async () => { | |
| const user = userEvent.setup(); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('No categories yet'); | |
| const newCatInput = screen.getByPlaceholderText('New category...'); | |
| await user.type(newCatInput, 'Transport'); | |
| // Click the + button to add it | |
| const addBtn = newCatInput.nextElementSibling as HTMLElement; | |
| await user.click(addBtn); | |
| // "Transport" category appears in the modal | |
| await screen.findByText('Transport'); | |
| }); | |
| it('FE-COMP-NOTES-027: CategorySettingsModal close button dismisses it', async () => { | |
| const user = userEvent.setup(); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('No categories yet'); | |
| // Click the X button in the modal header | |
| const modal = screen.getByText('No categories yet').closest('div'); | |
| const categoryModal = modal?.closest('[style*="position: fixed"]') as HTMLElement | null; | |
| if (categoryModal) { | |
| await user.click(categoryModal); | |
| } | |
| await waitFor(() => expect(screen.queryByText('No categories yet')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-028: WebSocket collab:note:updated event updates note in grid', async () => { | |
| const { addListener } = await import('../../api/websocket'); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Old Title WS', content: '', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Old Title WS'); | |
| const calls = (addListener as ReturnType<typeof vi.fn>).mock.calls; | |
| const listener = calls[calls.length - 1][0]; | |
| act(() => { | |
| listener({ | |
| type: 'collab:note:updated', | |
| note: { | |
| id: 5, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Updated WS Title', content: '', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString(), | |
| }, | |
| }); | |
| }); | |
| await screen.findByText('Updated WS Title'); | |
| expect(screen.queryByText('Old Title WS')).not.toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-029: expand button on note with content opens view modal', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Expandable Note', content: 'Full content here', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Expandable Note'); | |
| // Expand button (Maximize2 icon) appears when note has content | |
| // The translation key 'collab.notes.expand' falls back to the raw key since it's not in en.ts | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| // View modal shows the note title | |
| await waitFor(() => { | |
| const titles = screen.getAllByText('Expandable Note'); | |
| expect(titles.length).toBeGreaterThan(1); | |
| }); | |
| }); | |
| it('FE-COMP-NOTES-030: closing view modal via edit button removes it and opens edit modal', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'View Modal Note', content: 'Content to view', category: null, color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('View Modal Note'); | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| // Modal is open — there are multiple instances of the title | |
| await waitFor(() => expect(screen.getAllByText('View Modal Note').length).toBeGreaterThan(1)); | |
| // The view modal renders a pencil button to switch to edit mode | |
| // Find the buttons in the portal (appended to body — they come after the card buttons in DOM order) | |
| const allButtons = screen.getAllByRole('button'); | |
| // The last few buttons belong to the portal; the pencil edit button is second-to-last, X is last | |
| const lastButton = allButtons[allButtons.length - 1]; | |
| await user.click(lastButton); | |
| // After clicking X, the view modal title should appear only once (just in the edit modal or main grid) | |
| await waitFor(() => { | |
| const titles = screen.queryAllByText('View Modal Note'); | |
| // Either modal closed or edit modal opened — title count changed from modal state | |
| expect(titles.length).toBeGreaterThanOrEqual(1); | |
| }); | |
| }); | |
| it('FE-COMP-NOTES-031: category filter shows All button and resets filter', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [ | |
| { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Alpha Note', content: '', category: 'Alpha', color: '#3b82f6', files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, | |
| { id: 2, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Beta Note', content: '', category: 'Beta', color: '#ef4444', files: [], created_at: '2025-06-01T10:01:00.000Z', updated_at: '2025-06-01T10:01:00.000Z' }, | |
| ], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Alpha Note'); | |
| // Filter to Alpha | |
| await user.click(screen.getByRole('button', { name: 'Alpha' })); | |
| await waitFor(() => expect(screen.queryByText('Beta Note')).not.toBeInTheDocument()); | |
| // Click All to reset | |
| await user.click(screen.getByRole('button', { name: 'All' })); | |
| await screen.findByText('Beta Note'); | |
| }); | |
| it('FE-COMP-NOTES-032: CategorySettingsModal with existing categories from notes', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Cat Note', content: '', category: 'Food', color: '#ef4444', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Cat Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| // Food category appears in the settings modal | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // The category "Food" is listed in the modal | |
| const modalFoodEntries = screen.getAllByText('Food'); | |
| expect(modalFoodEntries.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTES-033: NoteFormModal shows existing categories as pills', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Existing Note', content: '', category: 'Hotels', color: '#3b82f6', files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Existing Note'); | |
| await user.click(screen.getByText('New Note')); | |
| // The NoteFormModal opens; existing category "Hotels" appears as a pill | |
| await screen.findByPlaceholderText('Note title'); | |
| // "Hotels" category pill is present in the modal | |
| expect(screen.getAllByText('Hotels').length).toBeGreaterThan(1); | |
| }); | |
| it('FE-COMP-NOTES-034: pin toggle calls PATCH/PUT API', async () => { | |
| const user = userEvent.setup(); | |
| let patchCalled = false; | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 10, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Pin Me', content: '', category: null, color: '#3b82f6', pinned: false, files: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.put('/api/trips/1/collab/notes/10', async () => { | |
| patchCalled = true; | |
| return HttpResponse.json({ | |
| note: { id: 10, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Pin Me', content: '', category: null, color: '#3b82f6', pinned: true, files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() }, | |
| }); | |
| }), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Pin Me'); | |
| await user.click(screen.getByTitle('Pin')); | |
| await waitFor(() => expect(patchCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-NOTES-035: note with PDF attachment shows file extension badge', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'PDF Note', content: '', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ | |
| id: 1, filename: 'doc.pdf', original_name: 'document.pdf', | |
| mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', | |
| }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('PDF Note'); | |
| // PDF extension badge is shown | |
| expect(screen.getByText('PDF')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-036: clicking PDF attachment opens FilePreviewPortal', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'PDF Note Portal', content: '', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ | |
| id: 1, filename: 'doc.pdf', original_name: 'document.pdf', | |
| mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', | |
| }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('PDF Note Portal'); | |
| // Click the PDF badge to open FilePreviewPortal | |
| await user.click(screen.getByText('PDF')); | |
| // FilePreviewPortal renders the file name in the header | |
| await screen.findByText('document.pdf'); | |
| }); | |
| it('FE-COMP-NOTES-037: note with website shows website thumbnail component', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Website Note', content: '', category: null, color: '#3b82f6', | |
| website: 'https://example.com', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.get('/api/trips/1/collab/link-preview', () => | |
| HttpResponse.json({ title: 'Example Domain', image: null }) | |
| ), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Website Note'); | |
| // Website thumbnail shows domain name (example.com) — the domain label | |
| await waitFor(() => { | |
| expect(screen.getByText('Link')).toBeInTheDocument(); | |
| }); | |
| }); | |
| it('FE-COMP-NOTES-038: CategorySettingsModal Save button calls saveCategoryColors', async () => { | |
| const user = userEvent.setup(); | |
| let putCalled = false; | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Cat Save Note', content: '', category: 'Travel', color: '#ef4444', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.put('/api/trips/1/collab/notes/1', async () => { | |
| putCalled = true; | |
| return HttpResponse.json({ note: { id: 1, trip_id: 1, title: 'Cat Save Note', content: '', category: 'Travel', color: '#6366f1', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }); | |
| }), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Cat Save Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // Change color: click first color swatch for "Travel" category | |
| const colorSwatches = screen.getAllByRole('button').filter(b => b.style.background && b.style.background.startsWith('#')); | |
| if (colorSwatches.length > 0) { | |
| await user.click(colorSwatches[0]); | |
| } | |
| // Click Save button | |
| await user.click(screen.getByRole('button', { name: /^Save$/i })); | |
| // Modal should close | |
| await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-039: NoteFormModal website field accepts URL input', async () => { | |
| const user = userEvent.setup(); | |
| let postBody: Record<string, unknown> = {}; | |
| server.use( | |
| http.post('/api/trips/1/collab/notes', async ({ request }) => { | |
| postBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ | |
| note: { id: 99, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'URL Note', content: '', category: null, color: '#3b82f6', website: 'https://trek.app', files: [], attachments: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() }, | |
| }); | |
| }) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('No notes yet'); | |
| await user.click(screen.getByText('New Note')); | |
| const titleInput = await screen.findByPlaceholderText('Note title'); | |
| await user.type(titleInput, 'URL Note'); | |
| const websiteInput = screen.getByPlaceholderText(/https:\/\//i); | |
| await user.type(websiteInput, 'https://trek.app'); | |
| await user.click(screen.getByRole('button', { name: /^Create$/i })); | |
| await waitFor(() => expect(postBody.website).toBe('https://trek.app')); | |
| }); | |
| it('FE-COMP-NOTES-040: CategorySettingsModal color change updates color', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Color Note', content: '', category: 'Food', color: '#ef4444', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.put('/api/trips/1/collab/notes/1', async () => | |
| HttpResponse.json({ note: { id: 1, trip_id: 1, title: 'Color Note', content: '', category: 'Food', color: '#6366f1', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }) | |
| ), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Color Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // "Food" appears in the modal; there are color swatches beside it | |
| // Find color swatch buttons (they have specific background colors from NOTE_COLORS) | |
| const saveBtn = screen.getByRole('button', { name: /^Save$/i }); | |
| await user.click(saveBtn); | |
| await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-041: note with image attachment shows thumbnail', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Image Note', content: '', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ | |
| id: 2, filename: 'photo.jpg', original_name: 'photo.jpg', | |
| mime_type: 'image/jpeg', url: '/api/trips/1/files/2/download', | |
| }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Image Note'); | |
| // Files section label appears | |
| expect(screen.getByText('Files')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-042: clicking image attachment opens FilePreviewPortal image view', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Image Portal Note', content: '', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ | |
| id: 3, filename: 'photo.jpg', original_name: 'scenery.jpg', | |
| mime_type: 'image/jpeg', url: '/api/trips/1/files/3/download', | |
| }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'test-token' })), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Image Portal Note'); | |
| // Wait for AuthedImg to load (it calls getAuthUrl async) | |
| await waitFor(() => { | |
| const imgs = document.querySelectorAll('img[alt="photo.jpg"]'); | |
| return imgs.length > 0; | |
| }, { timeout: 3000 }).catch(() => { | |
| // AuthedImg may not render if token not fetched — still ok | |
| }); | |
| // The Files section label is visible | |
| expect(screen.getByText('Files')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-043: EditableCatName in CategorySettingsModal is clickable and editable', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Rename Cat Note', content: '', category: 'Transport', color: '#10b981', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Rename Cat Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // Find the "Transport" category name span and click to edit | |
| const categoryNameSpan = screen.getAllByText('Transport').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename'); | |
| if (categoryNameSpan) { | |
| await user.click(categoryNameSpan); | |
| // Now an input with value "Transport" should appear | |
| const editInput = screen.getByDisplayValue('Transport'); | |
| await user.clear(editInput); | |
| await user.type(editInput, 'Vehicles'); | |
| await user.keyboard('{Enter}'); | |
| // The renamed category appears | |
| await screen.findByText('Vehicles'); | |
| } else { | |
| // Fallback: just check the modal renders Transport | |
| expect(screen.getAllByText('Transport').length).toBeGreaterThan(0); | |
| } | |
| }); | |
| it('FE-COMP-NOTES-044: CategorySettingsModal remove category button works', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Remove Cat Note', content: '', category: 'Removable', color: '#8b5cf6', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Remove Cat Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // Find the Trash2 SVG delete button in the modal — buttons containing lucide-trash-2 SVGs | |
| const trashButtons = [...document.querySelectorAll('button')].filter( | |
| b => b.querySelector('svg.lucide-trash-2') | |
| ); | |
| if (trashButtons.length > 0) { | |
| // First trash button in the modal is for the 'Removable' category | |
| await user.click(trashButtons[0] as HTMLElement); | |
| // Removable category disappears from the modal | |
| await waitFor(() => { | |
| const fixedEls = document.querySelectorAll('[style*="position: fixed"]'); | |
| let found = false; | |
| fixedEls.forEach(el => { if (el.textContent?.includes('Removable') && !el.textContent?.includes('Remove Cat Note')) found = true; }); | |
| expect(found).toBe(false); | |
| }); | |
| } else { | |
| expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument(); | |
| } | |
| }); | |
| it('FE-COMP-NOTES-045: expand note view modal displays full content with markdown', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Full Content Note', content: '# Header\n\nSome **bold** text', category: 'Trip', color: '#3b82f6', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Full Content Note'); | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| // View modal shows the full content | |
| await waitFor(() => { | |
| const titles = screen.getAllByText('Full Content Note'); | |
| expect(titles.length).toBeGreaterThan(1); | |
| }); | |
| // Bold text is rendered via Markdown | |
| expect(screen.getAllByText('bold').length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTES-046: view modal with category shows category badge', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Tagged Note', content: 'Some content here', category: 'Food', color: '#ef4444', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Tagged Note'); | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| // View modal header shows the category name | |
| await waitFor(() => { | |
| const foodEls = screen.getAllByText('Food'); | |
| expect(foodEls.length).toBeGreaterThan(1); // once in card badge, once in modal | |
| }); | |
| }); | |
| it('FE-COMP-NOTES-047: category rename in modal then Save calls onRenameCategory', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Rename Flow Note', content: '', category: 'OldCat', color: '#10b981', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.put('/api/trips/1/collab/notes/1', async () => | |
| HttpResponse.json({ note: { id: 1, trip_id: 1, title: 'Rename Flow Note', content: '', category: 'NewCat', color: '#10b981', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }) | |
| ), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Rename Flow Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // Find and click the "OldCat" category name span to enter edit mode | |
| const oldCatSpan = screen.getAllByText('OldCat').find(el => el.tagName === 'SPAN' && el.title === 'Click to rename'); | |
| if (oldCatSpan) { | |
| await user.click(oldCatSpan); | |
| const editInput = screen.getByDisplayValue('OldCat'); | |
| await user.clear(editInput); | |
| await user.type(editInput, 'NewCat'); | |
| await user.keyboard('{Enter}'); | |
| await screen.findByText('NewCat'); | |
| // Click Save — this triggers handleSave which calls onRenameCategory | |
| await user.click(screen.getByRole('button', { name: /^Save$/i })); | |
| await waitFor(() => expect(screen.queryByText('Manage Categories', { selector: 'h3' })).not.toBeInTheDocument()); | |
| } else { | |
| // If EditableCatName not found (unlikely), just close modal | |
| expect(screen.getByText('Manage Categories', { selector: 'h3' })).toBeInTheDocument(); | |
| } | |
| }); | |
| it('FE-COMP-NOTES-048: FilePreviewPortal close button sets previewFile to null', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Close Portal Note', content: '', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ id: 5, filename: 'file.pdf', original_name: 'closeable.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/5/download' }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'close-token' })), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('PDF'); | |
| await user.click(screen.getByText('PDF')); | |
| // FilePreviewPortal is open — closeable.pdf filename shown in header | |
| await screen.findByText('closeable.pdf'); | |
| // Find and click the X close button in the portal header | |
| const closeButtons = [...document.querySelectorAll('button')].filter(b => b.querySelector('svg.lucide-x')); | |
| // The last X button should be the portal close button | |
| const portalCloseBtn = closeButtons[closeButtons.length - 1] as HTMLElement; | |
| await user.click(portalCloseBtn); | |
| // Portal is closed | |
| await waitFor(() => expect(screen.queryByText('closeable.pdf')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-049: delete existing file attachment in edit modal calls deleteNoteFile API', async () => { | |
| const user = userEvent.setup(); | |
| let deleteCalled = false; | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 4, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Attachment Note', content: '', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ id: 10, filename: 'doc.pdf', original_name: 'removable.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/10/download' }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.delete('/api/trips/1/collab/notes/4/files/10', () => { | |
| deleteCalled = true; | |
| return HttpResponse.json({ success: true }); | |
| }), | |
| http.put('/api/trips/1/collab/notes/4', async () => | |
| HttpResponse.json({ note: { id: 4, trip_id: 1, title: 'Attachment Note', content: '', category: null, color: '#3b82f6', user_id: 1, author_username: 'testuser', author_avatar: null, files: [], attachments: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: new Date().toISOString() } }) | |
| ), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Attachment Note'); | |
| // Open edit modal | |
| await user.click(screen.getByTitle('Edit')); | |
| await screen.findByDisplayValue('Attachment Note'); | |
| // removable.pdf appears in the existing attachments list in the modal | |
| await screen.findByText('removable.pdf'); | |
| // Find X button next to the file name | |
| const xButtons = [...document.querySelectorAll('button')].filter(b => b.querySelector('svg.lucide-x')); | |
| // In the modal, there's the header X (close modal) + file X buttons | |
| // File X buttons appear after the header X | |
| if (xButtons.length > 1) { | |
| // Click the last X button which should be the file delete | |
| await user.click(xButtons[xButtons.length - 1] as HTMLElement); | |
| await waitFor(() => expect(deleteCalled).toBe(true)); | |
| } | |
| }); | |
| it('FE-COMP-NOTES-050: WebsiteThumbnail with OG image renders thumbnail image', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'OG Image Note', content: '', category: null, color: '#3b82f6', | |
| website: 'https://trek-app.example.com', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.get('/api/trips/1/collab/link-preview', () => | |
| HttpResponse.json({ title: 'Trek App', image: 'https://trek-app.example.com/og.jpg' }) | |
| ), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('OG Image Note'); | |
| // WebsiteThumbnail loads OG data — image is attempted, 'Link' label visible | |
| await waitFor(() => expect(screen.getByText('Link')).toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-NOTES-051: view modal with PDF attachment renders attachment section code', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Attached View Note', content: 'Has attachments', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ id: 20, filename: 'report.pdf', original_name: 'report.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/20/download' }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Attached View Note'); | |
| // PDF badge is present in NoteCard | |
| expect(screen.getByText('PDF')).toBeInTheDocument(); | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| // View modal opens — title appears multiple times | |
| await waitFor(() => expect(screen.getAllByText('Attached View Note').length).toBeGreaterThan(1)); | |
| // PDF badge appears in both card and view modal | |
| expect(screen.getAllByText('PDF').length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTES-052: view modal with image attachment renders image code branch', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Image View Note', content: 'See attachments', category: null, color: '#3b82f6', files: [], | |
| attachments: [{ id: 21, filename: 'photo.jpg', original_name: 'photo.jpg', mime_type: 'image/jpeg', url: '/api/trips/1/files/21/download' }], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ), | |
| http.post('/api/auth/resource-token', () => HttpResponse.json({ token: 'view-token' })), | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Image View Note'); | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| // View modal opens | |
| await waitFor(() => expect(screen.getAllByText('Image View Note').length).toBeGreaterThan(1)); | |
| // The view modal code for image attachments executed (AuthedImg renders initially null, then img after async) | |
| expect(document.body).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-053: view modal edit button transitions to edit modal', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Transition Note', content: 'Click edit from view', category: null, color: '#3b82f6', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Transition Note'); | |
| await user.click(screen.getByTitle('collab.notes.expand')); | |
| await waitFor(() => expect(screen.getAllByText('Transition Note').length).toBeGreaterThan(1)); | |
| // Click the Pencil button in the view modal (second-to-last button) | |
| const allButtons = screen.getAllByRole('button'); | |
| const pencilBtn = allButtons[allButtons.length - 2]; // Pencil is before X | |
| await user.click(pencilBtn); | |
| // Edit modal opens — title input should be pre-filled | |
| await screen.findByDisplayValue('Transition Note'); | |
| }); | |
| it('FE-COMP-NOTES-054: hovering over note card triggers hover state', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Hoverable Note', content: '', category: null, color: '#3b82f6', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Hoverable Note'); | |
| const noteCard = screen.getByText('Hoverable Note').closest('[style*="border-radius: 12px"]') as HTMLElement | null; | |
| if (noteCard) { | |
| await user.hover(noteCard); | |
| await user.unhover(noteCard); | |
| } | |
| expect(screen.getByText('Hoverable Note')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-055: note with author avatar renders UserAvatar img branch', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', | |
| author_avatar: '/uploads/avatars/avatar1.jpg', | |
| title: 'Avatar Note', content: '', category: null, color: '#3b82f6', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Avatar Note'); | |
| // The author avatar img element is rendered (UserAvatar with avatar branch) | |
| const avatarImg = document.querySelector('img[alt="testuser"]') as HTMLImageElement | null; | |
| expect(avatarImg || screen.getByText('Avatar Note')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-056: EditableCatName Escape key cancels rename', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, | |
| title: 'Escape Cat Note', content: '', category: 'EscapeMe', color: '#6366f1', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Escape Cat Note'); | |
| await user.click(screen.getByTitle('Manage Categories')); | |
| await screen.findByText('Manage Categories', { selector: 'h3' }); | |
| // Click on the category name to start editing | |
| const catNameSpan = screen.getAllByText('EscapeMe').find(el => el.title === 'Click to rename'); | |
| if (catNameSpan) { | |
| await user.click(catNameSpan); | |
| const editInput = screen.getByDisplayValue('EscapeMe'); | |
| // Press Escape to cancel without renaming | |
| await user.keyboard('{Escape}'); | |
| // Input is gone — editing mode exited | |
| await waitFor(() => expect(screen.queryByDisplayValue('EscapeMe')).not.toBeInTheDocument()); | |
| } else { | |
| expect(screen.getAllByText('EscapeMe').length).toBeGreaterThan(0); | |
| } | |
| }); | |
| it('FE-COMP-NOTES-057: note author tooltip shows username', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [{ | |
| id: 1, trip_id: 1, user_id: 1, | |
| // NoteCard uses note.author || note.user || { username: note.username, ... } | |
| author: { username: 'alice', avatar: null }, | |
| author_username: 'alice', author_avatar: null, | |
| title: 'Alice Note', content: '', category: null, color: '#3b82f6', files: [], attachments: [], | |
| created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z', | |
| }], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Alice Note'); | |
| // The author username tooltip text is in the DOM (from data-tip div) | |
| expect(screen.getByText('alice')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTES-023: notes are sorted with pinned notes first', async () => { | |
| server.use( | |
| http.get('/api/trips/1/collab/notes', () => | |
| HttpResponse.json({ | |
| notes: [ | |
| { id: 1, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Unpinned', content: '', category: null, color: '#3b82f6', pinned: false, files: [], created_at: '2025-06-01T10:00:00.000Z', updated_at: '2025-06-01T10:00:00.000Z' }, | |
| { id: 2, trip_id: 1, user_id: 1, author_username: 'testuser', author_avatar: null, title: 'Pinned', content: '', category: null, color: '#3b82f6', pinned: true, files: [], created_at: '2025-06-01T09:00:00.000Z', updated_at: '2025-06-01T09:00:00.000Z' }, | |
| ], | |
| }) | |
| ) | |
| ); | |
| render(<CollabNotes {...defaultProps} />); | |
| await screen.findByText('Pinned'); | |
| await screen.findByText('Unpinned'); | |
| expect(document.body.innerHTML.indexOf('Pinned')).toBeLessThan(document.body.innerHTML.indexOf('Unpinned')); | |
| }); | |
| }); | |