Spaces:
Sleeping
Sleeping
| // FE-COMP-PACKING-001 to FE-COMP-PACKING-020 | |
| import { vi } from 'vitest'; | |
| import { render, screen, waitFor, fireEvent } 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, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; | |
| import PackingListPanel, { itemWeight } from './PackingListPanel'; | |
| describe('itemWeight (bag total weight calc)', () => { | |
| it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => { | |
| expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360); | |
| }); | |
| it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => { | |
| expect(itemWeight({ weight_grams: 250 })).toBe(250); | |
| }); | |
| it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => { | |
| expect(itemWeight({ quantity: 5 })).toBe(0); | |
| expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0); | |
| expect(itemWeight({})).toBe(0); | |
| }); | |
| }); | |
| beforeEach(() => { | |
| resetAllStores(); | |
| // Side-effect APIs PackingListPanel calls on mount | |
| server.use( | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ owner: null, members: [], current_user_id: 1 }) | |
| ), | |
| http.get('/api/trips/:id/packing/category-assignees', () => | |
| HttpResponse.json({ assignees: {} }) | |
| ), | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: false, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/templates', () => | |
| HttpResponse.json({ templates: [] }) | |
| ), | |
| ); | |
| seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); | |
| seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); | |
| }); | |
| describe('PackingListPanel', () => { | |
| it('FE-COMP-PACKING-001: renders Packing List title', () => { | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| expect(screen.getByText('Packing List')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-002: shows empty state when no items', () => { | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| // Both the subtitle and the empty content area say "Packing list is empty" | |
| const els = screen.getAllByText('Packing list is empty'); | |
| expect(els.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-PACKING-003: empty state shows hint text', () => { | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| expect(screen.getByText(/Add items or use the suggestions/i)).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-004: shows items from props grouped by category', () => { | |
| const items = [ | |
| buildPackingItem({ name: 'Passport', category: 'Documents' }), | |
| buildPackingItem({ name: 'Charger', category: 'Electronics' }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('Passport')).toBeInTheDocument(); | |
| expect(screen.getByText('Charger')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-005: shows category group headers', () => { | |
| const items = [ | |
| buildPackingItem({ name: 'Toothbrush', category: 'Hygiene' }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('Hygiene')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-006: shows progress count in subtitle', () => { | |
| const items = [ | |
| buildPackingItem({ name: 'Item1', checked: 1 }), | |
| buildPackingItem({ name: 'Item2', checked: 0 }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText(/1 of 2 packed/i)).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-007: shows progress bar for packed items', () => { | |
| const items = [ | |
| buildPackingItem({ name: 'Item1', checked: 1 }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // 1/1 = 100% packed shows "All packed!" | |
| expect(screen.getByText('All packed!')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-008: items without category are grouped under default category', () => { | |
| const items = [ | |
| buildPackingItem({ name: 'Sunscreen', category: null }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('Sunscreen')).toBeInTheDocument(); | |
| // default category is "Other" | |
| expect(screen.getByText('Other')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-009: clicking Add item reveals input form', async () => { | |
| const user = userEvent.setup(); | |
| const items = [buildPackingItem({ name: 'Shorts', category: 'Clothing' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // Click "Add item" button to reveal input | |
| await user.click(screen.getByText('Add item')); | |
| expect(screen.getByPlaceholderText('Item name...')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-010: typing in add item input and pressing Enter calls POST', async () => { | |
| const user = userEvent.setup(); | |
| const existingItem = buildPackingItem({ name: 'Existing', category: 'Clothing' }); | |
| let postCalled = false; | |
| server.use( | |
| http.post('/api/trips/1/packing', async ({ request }) => { | |
| postCalled = true; | |
| const body = await request.json() as Record<string, unknown>; | |
| const item = buildPackingItem({ name: String(body.name), category: String(body.category) }); | |
| return HttpResponse.json({ item }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[existingItem]} />); | |
| await user.click(screen.getByText('Add item')); | |
| const addInput = screen.getByPlaceholderText('Item name...'); | |
| await user.type(addInput, 'T-Shirt{Enter}'); | |
| await waitFor(() => expect(postCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-PACKING-011: checked item has checked state visually (1=checked)', () => { | |
| const items = [buildPackingItem({ name: 'Packed Item', checked: 1 })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('Packed Item')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-012: unchecked item renders in open state', () => { | |
| const items = [buildPackingItem({ name: 'Unpacked Item', checked: 0 })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('Unpacked Item')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-013: multiple categories render independently', () => { | |
| const items = [ | |
| buildPackingItem({ name: 'Shirt', category: 'Clothing' }), | |
| buildPackingItem({ name: 'Passport', category: 'Documents' }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('Clothing')).toBeInTheDocument(); | |
| expect(screen.getByText('Documents')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-014: Add category button is shown', () => { | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| // The "Add category" button should be present in the toolbar | |
| expect(screen.getByText('Add category')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-015: clicking Add Category shows the category name input', async () => { | |
| const user = userEvent.setup(); | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| await user.click(screen.getByText('Add category')); | |
| await screen.findByPlaceholderText('Category name (e.g. Clothing)'); | |
| }); | |
| it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' }); | |
| let deleteCalled = false; | |
| server.use( | |
| http.delete('/api/trips/1/packing/99', () => { | |
| deleteCalled = true; | |
| return HttpResponse.json({ success: true }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| expect(screen.getByText('To Remove')).toBeInTheDocument(); | |
| // Delete button is in the DOM (opacity 0 on desktop but exists) | |
| const deleteBtn = screen.getByTitle('Delete'); | |
| await user.click(deleteBtn); | |
| await waitFor(() => expect(deleteCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-PACKING-017: shows filter buttons (All, Open, Done) when items exist', () => { | |
| const items = [buildPackingItem({ name: 'Shirt', category: 'Clothing' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| expect(screen.getByText('All')).toBeInTheDocument(); | |
| expect(screen.getByText('Open')).toBeInTheDocument(); | |
| expect(screen.getByText('Done')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-018: filtering to Done hides unchecked items', async () => { | |
| const user = userEvent.setup(); | |
| const items = [ | |
| buildPackingItem({ name: 'Done Item', checked: 1, category: 'Test' }), | |
| buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| await user.click(screen.getByText('Done')); | |
| expect(screen.getByText('Done Item')).toBeInTheDocument(); | |
| expect(screen.queryByText('Open Item')).not.toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-019: filtering to Open hides checked items', async () => { | |
| const user = userEvent.setup(); | |
| const items = [ | |
| buildPackingItem({ name: 'Done Item', checked: 1, category: 'Test' }), | |
| buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| await user.click(screen.getByText('Open')); | |
| expect(screen.queryByText('Done Item')).not.toBeInTheDocument(); | |
| expect(screen.getByText('Open Item')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-020: renders empty filter message when filter yields nothing', async () => { | |
| const user = userEvent.setup(); | |
| const items = [ | |
| buildPackingItem({ name: 'Open Item', checked: 0, category: 'Test' }), | |
| ]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| await user.click(screen.getByText('Done')); | |
| expect(screen.getByText('No items match this filter')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 42, name: 'Sunscreen', category: 'Toiletries' }); | |
| let patchBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.put('/api/trips/1/packing/42', async ({ request }) => { | |
| patchBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: 42, name: 'Sunblock', category: 'Toiletries' }) }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Click the rename (pencil) button | |
| await user.click(screen.getByTitle('Rename')); | |
| // Input appears pre-filled with 'Sunscreen' | |
| const input = screen.getByDisplayValue('Sunscreen'); | |
| expect(input).toBeInTheDocument(); | |
| // Clear and type new name, then press Enter | |
| await user.clear(input); | |
| await user.type(input, 'Sunblock'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(patchBody).toMatchObject({ name: 'Sunblock' })); | |
| }); | |
| it('FE-COMP-PACKING-024: toggle item checked state calls PUT', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 50, name: 'Shorts', checked: 0, category: 'Clothing' }); | |
| let patchBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.put('/api/trips/1/packing/50', async ({ request }) => { | |
| patchBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: 50, checked: 1 }) }); | |
| }) | |
| ); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item]} />); | |
| // The toggle button contains the Square icon for unchecked items | |
| const toggleBtn = container.querySelector('svg.lucide-square')?.closest('button'); | |
| expect(toggleBtn).toBeTruthy(); | |
| await user.click(toggleBtn!); | |
| await waitFor(() => expect(patchBody).toMatchObject({ checked: true })); | |
| }); | |
| it('FE-COMP-PACKING-025: "Check all" bulk action calls PUT for all unchecked items', async () => { | |
| const user = userEvent.setup(); | |
| const item1 = buildPackingItem({ id: 60, name: 'Item1', checked: 0, category: 'TestCat' }); | |
| const item2 = buildPackingItem({ id: 61, name: 'Item2', checked: 0, category: 'TestCat' }); | |
| const patchedIds: number[] = []; | |
| server.use( | |
| http.put('/api/trips/1/packing/:itemId', ({ params }) => { | |
| patchedIds.push(Number(params.itemId)); | |
| return HttpResponse.json({ item: buildPackingItem() }); | |
| }) | |
| ); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item1, item2]} />); | |
| // Open the MoreHorizontal context menu | |
| const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); | |
| expect(moreBtn).toBeTruthy(); | |
| await user.click(moreBtn!); | |
| // Click "Check All" | |
| await user.click(await screen.findByText('Check All')); | |
| await waitFor(() => { | |
| expect(patchedIds).toContain(60); | |
| expect(patchedIds).toContain(61); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-026: quantity input change calls PUT with new quantity', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 70, name: 'T-Shirts', quantity: 2, category: 'Clothing' }); | |
| let patchBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.put('/api/trips/1/packing/70', async ({ request }) => { | |
| patchBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: 70, quantity: 5 }) }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Find the quantity input showing '2' | |
| const qtyInput = screen.getByDisplayValue('2'); | |
| await user.clear(qtyInput); | |
| await user.type(qtyInput, '5'); | |
| await user.tab(); // blur triggers commit | |
| await waitFor(() => expect(patchBody).toMatchObject({ quantity: 5 })); | |
| }); | |
| it('FE-COMP-PACKING-027: add new category via form calls POST', async () => { | |
| const user = userEvent.setup(); | |
| let postBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.post('/api/trips/1/packing', async ({ request }) => { | |
| postBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ name: '...', category: 'Valuables' }) }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| await user.click(screen.getByText('Add category')); | |
| const input = await screen.findByPlaceholderText('Category name (e.g. Clothing)'); | |
| await user.type(input, 'Valuables'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(postBody).toMatchObject({ category: 'Valuables' })); | |
| }); | |
| it('FE-COMP-PACKING-028: category group collapse hides items, expand shows them', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' }); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Item is visible initially | |
| expect(screen.getByText('Sunscreen')).toBeInTheDocument(); | |
| // Click the ChevronDown button to collapse | |
| const chevronDown = container.querySelector('svg.lucide-chevron-down')?.closest('button'); | |
| expect(chevronDown).toBeTruthy(); | |
| await user.click(chevronDown!); | |
| // Item should no longer be visible | |
| expect(screen.queryByText('Sunscreen')).not.toBeInTheDocument(); | |
| // Click the ChevronRight button to expand again | |
| const chevronRight = container.querySelector('svg.lucide-chevron-right')?.closest('button'); | |
| expect(chevronRight).toBeTruthy(); | |
| await user.click(chevronRight!); | |
| // Item visible again | |
| expect(screen.getByText('Sunscreen')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-029: bag tracking sidebar not shown when disabled', async () => { | |
| render(<PackingListPanel tripId={1} items={[buildPackingItem({ category: 'Test' })]} />); | |
| // No "Bags" heading or luggage sidebar should appear | |
| await waitFor(() => { | |
| expect(screen.queryByText('Bags')).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-030: packing template button present when templates available', async () => { | |
| server.use( | |
| http.get('/api/trips/:id/packing/templates', () => | |
| HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] }) | |
| ) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| // "Apply template" button appears when templates are available | |
| await screen.findByText('Apply template'); | |
| }); | |
| it('FE-COMP-PACKING-031: "Uncheck All" bulk action calls PUT to uncheck checked items', async () => { | |
| const user = userEvent.setup(); | |
| const item1 = buildPackingItem({ id: 80, name: 'ItemA', checked: 1, category: 'Gear' }); | |
| const item2 = buildPackingItem({ id: 81, name: 'ItemB', checked: 1, category: 'Gear' }); | |
| const patchedIds: number[] = []; | |
| server.use( | |
| http.put('/api/trips/1/packing/:itemId', ({ params }) => { | |
| patchedIds.push(Number(params.itemId)); | |
| return HttpResponse.json({ item: buildPackingItem() }); | |
| }) | |
| ); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item1, item2]} />); | |
| // Open the MoreHorizontal context menu | |
| const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); | |
| expect(moreBtn).toBeTruthy(); | |
| await user.click(moreBtn!); | |
| // Click "Uncheck All" | |
| await user.click(await screen.findByText('Uncheck All')); | |
| await waitFor(() => { | |
| expect(patchedIds).toContain(80); | |
| expect(patchedIds).toContain(81); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-032: category assignee button shown when trip members exist', async () => { | |
| server.use( | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ | |
| owner: { id: 1, username: 'owner', avatar_url: null }, | |
| members: [{ id: 2, username: 'alice', avatar_url: null }], | |
| current_user_id: 1, | |
| }) | |
| ) | |
| ); | |
| const item = buildPackingItem({ name: 'Passport', category: 'Documents' }); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item]} />); | |
| // UserPlus assignee button should appear in the category header | |
| await waitFor(() => { | |
| const userPlusBtn = container.querySelector('svg.lucide-user-plus'); | |
| expect(userPlusBtn).toBeTruthy(); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-033: import modal opens and closes', async () => { | |
| const user = userEvent.setup(); | |
| const { container } = render(<PackingListPanel tripId={1} items={[]} />); | |
| // Click the Import button (Upload icon in the header) | |
| const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); | |
| expect(importBtn).toBeTruthy(); | |
| await user.click(importBtn!); | |
| // Import modal title appears | |
| expect(await screen.findByText('Import Packing List')).toBeInTheDocument(); | |
| // Cancel closes modal | |
| await user.click(screen.getByText('Cancel')); | |
| await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => { | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Laptop', category: 'Electronics' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // Bags button/sidebar appears when bag tracking is enabled | |
| await waitFor(() => { | |
| const bagsEls = screen.getAllByText('Bags'); | |
| expect(bagsEls.length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-035: category rename via context menu calls PUT', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 90, name: 'Shirt', category: 'Clothing' }); | |
| let putBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.put('/api/trips/1/packing/90', async ({ request }) => { | |
| putBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: 90, name: 'Shirt', category: 'Apparel' }) }); | |
| }) | |
| ); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Open the category context menu | |
| const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); | |
| expect(moreBtn).toBeTruthy(); | |
| await user.click(moreBtn!); | |
| // Click "Rename" in the menu | |
| await user.click(await screen.findByText('Rename')); | |
| // Category name input appears β type new name and save | |
| const catInput = screen.getByDisplayValue('Clothing'); | |
| await user.clear(catInput); | |
| await user.type(catInput, 'Apparel'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(putBody).toMatchObject({ category: 'Apparel' })); | |
| }); | |
| it('FE-COMP-PACKING-036: assignee dropdown opens and lists members when clicked', async () => { | |
| server.use( | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ | |
| owner: { id: 1, username: 'owner', avatar_url: null }, | |
| members: [{ id: 2, username: 'alice', avatar_url: null }], | |
| current_user_id: 1, | |
| }) | |
| ) | |
| ); | |
| const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Wait for members to load, then click the UserPlus button | |
| await waitFor(() => { | |
| expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy(); | |
| }); | |
| const userPlusBtn = container.querySelector('svg.lucide-user-plus')?.closest('button'); | |
| await userEvent.setup().click(userPlusBtn!); | |
| // Member names appear in the dropdown | |
| await screen.findByText('owner'); | |
| expect(screen.getByText('alice')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-038: import modal - typing text updates import count', async () => { | |
| const user = userEvent.setup(); | |
| const { container } = render(<PackingListPanel tripId={1} items={[]} />); | |
| // Open import modal | |
| const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); | |
| await user.click(importBtn!); | |
| await screen.findByText('Import Packing List'); | |
| // Textarea is present | |
| const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); | |
| expect(textarea).toBeInTheDocument(); | |
| // "Load CSV/TXT" button is present inside the modal | |
| expect(screen.getByText('Load CSV/TXT')).toBeInTheDocument(); | |
| // Close by clicking backdrop (covers the onClick on the backdrop div) | |
| const modalTitle = screen.getByText('Import Packing List'); | |
| const modalContent = modalTitle.closest('div[style*="width: 420"]'); | |
| // Dismiss via Cancel button | |
| await user.click(screen.getByText('Cancel')); | |
| await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Charger', category: 'Electronics' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for Bags button to appear | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Bags').length).toBeGreaterThan(0); | |
| }); | |
| // Click the Bags button (xl:!hidden - visible in jsdom) | |
| const luggageBtn = container.querySelector('button svg.lucide-luggage')?.closest('button'); | |
| expect(luggageBtn).toBeTruthy(); | |
| await user.click(luggageBtn!); | |
| // Modal opens β "Main Bag" text appears (sidebar + modal β use getAllByText) | |
| await waitFor(() => { | |
| const bagTexts = screen.getAllByText('Main Bag'); | |
| expect(bagTexts.length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => { | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // BagCard in sidebar shows the bag name (may appear once or more with modal) | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Backpack').length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => { | |
| seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true }); | |
| const user = userEvent.setup(); | |
| const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // Save-as-template button shows its label "Save as template" | |
| const saveBtn = screen.getByText('Save as template').closest('button'); | |
| expect(saveBtn).toBeTruthy(); | |
| // Click to show the name input | |
| await user.click(saveBtn!); | |
| // Template name input appears | |
| expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => { | |
| // Default seeded user (beforeEach) is a non-admin trip owner with edit rights. | |
| const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // The "Save as template" action must not be available to normal users. | |
| expect(screen.queryByText('Save as template')).not.toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/:id/packing/templates', () => | |
| HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] }) | |
| ) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| // Wait for template button | |
| const templateBtn = await screen.findByText('Apply template'); | |
| // Click to open dropdown | |
| await user.click(templateBtn); | |
| // Template name appears in the dropdown | |
| expect(await screen.findByText('Summer Packing')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-043: import modal textarea change updates text', async () => { | |
| const user = userEvent.setup(); | |
| const { container } = render(<PackingListPanel tripId={1} items={[]} />); | |
| // Open import modal | |
| const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); | |
| await user.click(importBtn!); | |
| await screen.findByText('Import Packing List'); | |
| // Type in textarea | |
| const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); | |
| await user.type(textarea, 'Clothing, T-Shirt'); | |
| // The textarea value reflects the typed text | |
| expect(textarea).toHaveValue('Clothing, T-Shirt'); | |
| // Import button count updates to 1 | |
| expect(screen.getByText(/Import 1/)).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => { | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for bag tracking to enable (weight input 'g' label appears) | |
| await waitFor(() => { | |
| expect(container.querySelector('input[placeholder="β"]')).toBeTruthy(); | |
| }); | |
| // The 'g' gram label appears next to the weight input | |
| expect(container.querySelector('span[style*="g"]')).toBeTruthy(); | |
| }); | |
| it('FE-COMP-PACKING-045: "Remove checked" button appears when checked items exist', async () => { | |
| const user = userEvent.setup(); | |
| const items = [ | |
| buildPackingItem({ name: 'Done1', checked: 1, category: 'Test' }), | |
| buildPackingItem({ name: 'Done2', checked: 1, category: 'Test' }), | |
| ]; | |
| server.use( | |
| http.delete('/api/trips/1/packing/:itemId', () => HttpResponse.json({ success: true })) | |
| ); | |
| // Mock window.confirm to return true | |
| vi.spyOn(window, 'confirm').mockReturnValue(true); | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // The "Remove N checked" button should be visible (two spans exist - one sm:hidden, one hidden sm:inline) | |
| const removeBtns = screen.getAllByText(/Remove 2/); | |
| expect(removeBtns.length).toBeGreaterThan(0); | |
| // Click the parent button (either span's closest button) | |
| const removeBtn = removeBtns[0].closest('button')!; | |
| expect(removeBtn).toBeTruthy(); | |
| await user.click(removeBtn); | |
| // confirm was called | |
| expect(window.confirm).toHaveBeenCalled(); | |
| vi.restoreAllMocks(); | |
| }); | |
| it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => { | |
| seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true }); | |
| const user = userEvent.setup(); | |
| let savedTemplateName = ''; | |
| server.use( | |
| http.post('/api/trips/1/packing/save-as-template', async ({ request }) => { | |
| const body = await request.json() as Record<string, unknown>; | |
| savedTemplateName = String(body.name); | |
| return HttpResponse.json({ success: true }); | |
| }), | |
| http.get('/api/trips/:id/packing/templates', () => | |
| HttpResponse.json({ templates: [] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Item', category: 'Test' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // Click the "Save as template" button | |
| const saveBtn = screen.getByText('Save as template').closest('button'); | |
| await user.click(saveBtn!); | |
| // Type template name | |
| const nameInput = await screen.findByPlaceholderText('Template name'); | |
| await user.type(nameInput, 'My Template'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(savedTemplateName).toBe('My Template')); | |
| }); | |
| it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for bag tracking to enable (Package icon button in item row) | |
| await waitFor(() => { | |
| expect(container.querySelector('svg.lucide-package')).toBeTruthy(); | |
| }); | |
| // Click the bag button (Package icon) to open bag picker | |
| const packageBtn = container.querySelector('svg.lucide-package')?.closest('button'); | |
| expect(packageBtn).toBeTruthy(); | |
| await user.click(packageBtn!); | |
| // Bag picker dropdown shows the bag name (may also appear in sidebar) | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Carry-on').length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Jacket', category: 'Clothing' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for Bags button | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Bags').length).toBeGreaterThan(0); | |
| }); | |
| // Open bag modal | |
| const luggageBtn = container.querySelector('button svg.lucide-luggage')?.closest('button'); | |
| await user.click(luggageBtn!); | |
| // Wait for modal to show ("Add bag" button appears β may be in both sidebar and modal) | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Add bag').length).toBeGreaterThan(0); | |
| }); | |
| // Click the last "Add bag" (in the modal) | |
| const addBagBtns = screen.getAllByText('Add bag'); | |
| await user.click(addBagBtns[addBagBtns.length - 1]); | |
| // Add bag name input appears (may exist in both sidebar and modal) | |
| await waitFor(() => { | |
| const bagInputs = screen.queryAllByPlaceholderText('Bag name...'); | |
| expect(bagInputs.length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-049: weight input change with bag tracking enabled calls PUT', async () => { | |
| const user = userEvent.setup(); | |
| let putBody: Record<string, unknown> | null = null; | |
| const itemId = 120; | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [] }) | |
| ), | |
| http.put(`/api/trips/1/packing/${itemId}`, async ({ request }) => { | |
| putBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: itemId }) }); | |
| }) | |
| ); | |
| const items = [buildPackingItem({ id: itemId, name: 'Camera', category: 'Electronics' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for weight input to appear (bag tracking enabled) | |
| await waitFor(() => { | |
| expect(container.querySelector('input[placeholder="β"]')).toBeTruthy(); | |
| }); | |
| // Change the weight input value | |
| const weightInput = container.querySelector('input[placeholder="β"]') as HTMLInputElement; | |
| await user.clear(weightInput); | |
| await user.type(weightInput, '500'); | |
| await user.tab(); // blur to trigger change | |
| await waitFor(() => { | |
| expect(putBody).toBeTruthy(); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-050: ArtikelZeile category change picker opens on dot button click', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); | |
| const item2 = buildPackingItem({ name: 'Passport', category: 'Documents' }); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item, item2]} />); | |
| // The category change picker is triggered by a small dot button (no title) | |
| // It's rendered inside the action buttons group (sm:opacity-0 sm:group-hover:opacity-100) | |
| // In jsdom, CSS classes don't apply so the buttons are accessible | |
| // The dot button has a circle span inside with category color | |
| // Find all buttons with the 'Change Category' title | |
| const catChangeBtn = screen.getAllByTitle('Change Category'); | |
| expect(catChangeBtn.length).toBeGreaterThan(0); | |
| await user.click(catChangeBtn[0]); | |
| // Category picker shows both category names | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Electronics').length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-051: bag assignment from picker calls PUT with bag_id', async () => { | |
| const user = userEvent.setup(); | |
| const itemId = 130; | |
| let putBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] }) | |
| ), | |
| http.put(`/api/trips/1/packing/${itemId}`, async ({ request }) => { | |
| putBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: itemId }) }); | |
| }) | |
| ); | |
| const items = [buildPackingItem({ id: itemId, name: 'Shoes', category: 'Clothing' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for bag tracking to enable (Package icon appears) | |
| await waitFor(() => { | |
| expect(container.querySelector('svg.lucide-package')).toBeTruthy(); | |
| }); | |
| // Use fireEvent (no pointer events) to open the picker without triggering mouseLeave | |
| const packageBtn = container.querySelector('svg.lucide-package')?.closest('button'); | |
| fireEvent.click(packageBtn!); | |
| // Picker is open - find "Trolley" button inside the dropdown | |
| // The dropdown renders as an absolute positioned div inside the item row | |
| const trolleyBtn = await screen.findByRole('button', { name: /Trolley/ }); | |
| fireEvent.click(trolleyBtn); | |
| await waitFor(() => expect(putBody).toMatchObject({ bag_id: 7 })); | |
| }); | |
| it('FE-COMP-PACKING-052: category assignee chip renders when assignees exist', async () => { | |
| server.use( | |
| http.get('/api/trips/:id/packing/category-assignees', () => | |
| HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } }) | |
| ) | |
| ); | |
| const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| // The assignee chip shows the first letter of username | |
| await waitFor(() => { | |
| // The chip shows 'A' (first letter of 'alice') | |
| const chips = document.querySelectorAll('.assignee-chip'); | |
| expect(chips.length).toBeGreaterThan(0); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-053: import modal closes when backdrop is clicked', async () => { | |
| const user = userEvent.setup(); | |
| const { container } = render(<PackingListPanel tripId={1} items={[]} />); | |
| // Open import modal | |
| const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); | |
| await user.click(importBtn!); | |
| await screen.findByText('Import Packing List'); | |
| // Click on the backdrop (the outer div that closes the modal) | |
| // The backdrop div has no specific identifier so we use the document.body portal | |
| const backdrop = document.querySelector('[style*="backdrop-filter"]') as HTMLElement; | |
| expect(backdrop).toBeTruthy(); | |
| fireEvent.click(backdrop!); | |
| await waitFor(() => expect(screen.queryByText('Import Packing List')).not.toBeInTheDocument()); | |
| }); | |
| it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => { | |
| const itemId = 140; | |
| server.use( | |
| http.get('/api/addons', () => | |
| HttpResponse.json({ bagTracking: true, addons: [] }) | |
| ), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] }) | |
| ), | |
| http.put(`/api/trips/1/packing/${itemId}`, async () => | |
| HttpResponse.json({ item: buildPackingItem({ id: itemId }) }) | |
| ) | |
| ); | |
| // Item that already has a bag assigned | |
| const items = [buildPackingItem({ id: itemId, name: 'Jacket', category: 'Clothing', bag_id: 5 } as any)]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for bag tracking to enable | |
| await waitFor(() => { | |
| // When bag_id is set, the bag button shows a colored dot (not Package icon) | |
| expect(container.querySelector('svg.lucide-package')).toBeFalsy(); | |
| }); | |
| // Verify the bags section renders in sidebar | |
| await screen.findByText('MyBag'); | |
| }); | |
| it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/:id/packing/templates', () => | |
| HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] }) | |
| ) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| // Wait for and click template button | |
| const templateBtn = await screen.findByText('Apply template'); | |
| await user.click(templateBtn); | |
| // Template name appears in dropdown | |
| expect(await screen.findByText('Weekend Pack')).toBeInTheDocument(); | |
| // Item count appears too | |
| expect(screen.getByText('8 items')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-037: delete category via context menu calls DELETE for all items', async () => { | |
| const user = userEvent.setup(); | |
| const item1 = buildPackingItem({ id: 100, name: 'Rope', category: 'Gear' }); | |
| const item2 = buildPackingItem({ id: 101, name: 'Map', category: 'Gear' }); | |
| const deletedIds: number[] = []; | |
| server.use( | |
| http.delete('/api/trips/1/packing/:itemId', ({ params }) => { | |
| deletedIds.push(Number(params.itemId)); | |
| return HttpResponse.json({ success: true }); | |
| }) | |
| ); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item1, item2]} />); | |
| // Open context menu and click Delete Category | |
| const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); | |
| await user.click(moreBtn!); | |
| await user.click(await screen.findByText('Delete Category')); | |
| await waitFor(() => { | |
| expect(deletedIds).toContain(100); | |
| expect(deletedIds).toContain(101); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-056: pressing Enter in quantity input commits value', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 71, name: 'Socks', quantity: 3, category: 'Clothing' }); | |
| let putBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.put('/api/trips/1/packing/71', async ({ request }) => { | |
| putBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: 71, quantity: 7 }) }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| const qtyInput = screen.getByDisplayValue('3'); | |
| await user.clear(qtyInput); | |
| await user.type(qtyInput, '7'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(putBody).toMatchObject({ quantity: 7 })); | |
| }); | |
| it('FE-COMP-PACKING-057: clicking unchecked item name enters inline edit mode', async () => { | |
| const user = userEvent.setup(); | |
| const item = buildPackingItem({ id: 73, name: 'Jacket', checked: 0, category: 'Clothing' }); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Click the item name span (not the Rename button β the name span itself) | |
| const nameSpan = screen.getByText('Jacket'); | |
| await user.click(nameSpan); | |
| // An edit input should appear with the item's name pre-filled | |
| await waitFor(() => { | |
| const input = screen.getByDisplayValue('Jacket'); | |
| expect(input.tagName).toBe('INPUT'); | |
| }); | |
| }); | |
| it('FE-COMP-PACKING-058: selecting a different category in picker calls PUT with new category', async () => { | |
| const itemA = buildPackingItem({ id: 74, name: 'Camera', category: 'Electronics' }); | |
| const itemB = buildPackingItem({ id: 75, name: 'Passport', category: 'Documents' }); | |
| let putBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.put('/api/trips/1/packing/74', async ({ request }) => { | |
| putBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ item: buildPackingItem({ id: 74, category: 'Documents' }) }); | |
| }) | |
| ); | |
| render(<PackingListPanel tripId={1} items={[itemA, itemB]} />); | |
| // Use fireEvent (no pointer events) to open the category picker β avoids mouseLeave closing picker | |
| const catChangeBtns = screen.getAllByTitle('Change Category'); | |
| fireEvent.click(catChangeBtns[0]); | |
| // Picker shows available categories β find and click the 'Documents' button (role=button, text=Documents) | |
| const docBtn = await screen.findByRole('button', { name: 'Documents' }); | |
| fireEvent.click(docBtn); | |
| await waitFor(() => expect(putBody).toMatchObject({ category: 'Documents' })); | |
| }); | |
| it('FE-COMP-PACKING-059: clicking member in UserPlus dropdown calls setCategoryAssignees', async () => { | |
| let assignBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ | |
| owner: { id: 1, username: 'owner', avatar_url: null }, | |
| members: [{ id: 2, username: 'alice', avatar_url: null }], | |
| current_user_id: 1, | |
| }) | |
| ), | |
| http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => { | |
| assignBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ assignees: [{ user_id: 2, username: 'alice', avatar: null }] }); | |
| }) | |
| ); | |
| const item = buildPackingItem({ name: 'Tripod', category: 'Electronics' }); | |
| const { container } = render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Wait for members to load | |
| await waitFor(() => expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy()); | |
| // Click UserPlus to open assignee dropdown | |
| const userPlusBtn = container.querySelector('svg.lucide-user-plus')?.closest('button'); | |
| await userEvent.setup().click(userPlusBtn!); | |
| // Click member 'alice' in dropdown | |
| const aliceBtn = await screen.findByRole('button', { name: /alice/i }); | |
| await userEvent.setup().click(aliceBtn); | |
| await waitFor(() => expect(assignBody).toMatchObject({ user_ids: [2] })); | |
| }); | |
| it('FE-COMP-PACKING-060: clicking assignee chip removes assignee via setCategoryAssignees', async () => { | |
| let putBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/trips/:id/packing/category-assignees', () => | |
| HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } }) | |
| ), | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ | |
| owner: { id: 1, username: 'owner', avatar_url: null }, | |
| members: [{ id: 2, username: 'alice', avatar_url: null }], | |
| current_user_id: 1, | |
| }) | |
| ), | |
| http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => { | |
| putBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ assignees: [] }); | |
| }) | |
| ); | |
| const item = buildPackingItem({ name: 'Camera', category: 'Electronics' }); | |
| render(<PackingListPanel tripId={1} items={[item]} />); | |
| // Wait for the assignee chip to appear | |
| await waitFor(() => expect(document.querySelectorAll('.assignee-chip').length).toBeGreaterThan(0)); | |
| // Click the chip wrapper div to remove the assignee | |
| const chip = document.querySelector('.assignee-chip')!.parentElement!; | |
| fireEvent.click(chip); | |
| // setCategoryAssignees called with empty user_ids (removing alice) | |
| await waitFor(() => expect(putBody).toMatchObject({ user_ids: [] })); | |
| }); | |
| it('FE-COMP-PACKING-061: applying a template calls applyTemplate API', async () => { | |
| const user = userEvent.setup(); | |
| let applyCalled = false; | |
| server.use( | |
| http.get('/api/trips/:id/packing/templates', () => | |
| HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] }) | |
| ), | |
| http.post('/api/trips/1/packing/apply-template/5', () => { | |
| applyCalled = true; | |
| return HttpResponse.json({ count: 12 }); | |
| }) | |
| ); | |
| // jsdom window.location.reload is not configurable; it just emits a "not implemented" warning | |
| render(<PackingListPanel tripId={1} items={[]} />); | |
| // Wait for template button and open dropdown | |
| const templateBtn = await screen.findByText('Apply template'); | |
| await user.click(templateBtn); | |
| // Click the template in the dropdown | |
| const tmplBtn = await screen.findByText('Beach Trip'); | |
| await user.click(tmplBtn); | |
| await waitFor(() => expect(applyCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-PACKING-062: handleBulkImport calls import API and closes modal', async () => { | |
| const user = userEvent.setup(); | |
| let importBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.post('/api/trips/1/packing/import', async ({ request }) => { | |
| importBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ count: 2 }); | |
| }) | |
| ); | |
| const { container } = render(<PackingListPanel tripId={1} items={[]} />); | |
| // Open import modal | |
| const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); | |
| await user.click(importBtn!); | |
| await screen.findByText('Import Packing List'); | |
| // Type two lines in the textarea | |
| const textarea = screen.getByPlaceholderText(/Hygiene, Toothbrush/); | |
| await user.type(textarea, 'Clothing, Shirt\nDocuments, Passport'); | |
| // Click Import button | |
| const importActionBtn = await screen.findByText(/Import 2/); | |
| await user.click(importActionBtn); | |
| await waitFor(() => expect(importBody).toBeTruthy()); | |
| }); | |
| it('FE-COMP-PACKING-063: creating a bag via sidebar form calls createBag API', async () => { | |
| const user = userEvent.setup(); | |
| let createBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), | |
| // Start with one bag so the sidebar renders (sidebar requires bags.length > 0) | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) | |
| ), | |
| http.post('/api/trips/1/packing/bags', async ({ request }) => { | |
| createBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ bag: { id: 10, name: 'Hiking Pack', color: '#ec4899', weight_limit_grams: null, members: [] } }); | |
| }) | |
| ); | |
| const items = [buildPackingItem({ name: 'Boots', category: 'Clothing' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for sidebar "Add bag" button (sidebar renders when bags.length > 0) | |
| await waitFor(() => expect(screen.getAllByText('Add bag').length).toBeGreaterThan(0)); | |
| const addBagBtns = screen.getAllByText('Add bag'); | |
| await user.click(addBagBtns[0]); | |
| // Bag name input appears | |
| const bagInput = await screen.findByPlaceholderText('Bag name...'); | |
| await user.type(bagInput, 'Hiking Pack'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(createBody).toMatchObject({ name: 'Hiking Pack' })); | |
| }); | |
| it('FE-COMP-PACKING-064: deleting a bag from sidebar calls deleteBag API', async () => { | |
| const user = userEvent.setup(); | |
| let deleteCalled = false; | |
| server.use( | |
| http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] }) | |
| ), | |
| http.delete('/api/trips/1/packing/bags/9', () => { | |
| deleteCalled = true; | |
| return HttpResponse.json({ success: true }); | |
| }) | |
| ); | |
| const items = [buildPackingItem({ name: 'Shirt', category: 'Clothing' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for bag to appear in sidebar | |
| await waitFor(() => expect(screen.getAllByText('Old Bag').length).toBeGreaterThan(0)); | |
| // Click the X (delete) button on the BagCard in the sidebar | |
| // The X button is in BagCard: <button onClick={onDelete}><X size={...} /></button> | |
| const xBtns = container.querySelectorAll('svg.lucide-x'); | |
| expect(xBtns.length).toBeGreaterThan(0); | |
| await user.click(xBtns[0].closest('button')!); | |
| await waitFor(() => expect(deleteCalled).toBe(true)); | |
| }); | |
| it('FE-COMP-PACKING-065: clicking bag name in sidebar enters edit mode and saves', async () => { | |
| const user = userEvent.setup(); | |
| let updateBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] }) | |
| ), | |
| http.put('/api/trips/1/packing/bags/11', async ({ request }) => { | |
| updateBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ bag: { id: 11, name: 'Luggage', color: '#10b981', weight_limit_grams: null, members: [] } }); | |
| }) | |
| ); | |
| const items = [buildPackingItem({ name: 'Shoes', category: 'Clothing' })]; | |
| render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for bag name in sidebar | |
| await waitFor(() => expect(screen.getAllByText('Carry-on').length).toBeGreaterThan(0)); | |
| // Click the bag name span to enter edit mode | |
| const bagNameSpans = screen.getAllByText('Carry-on'); | |
| await user.click(bagNameSpans[0]); | |
| // An edit input should appear | |
| const bagNameInput = await screen.findByDisplayValue('Carry-on'); | |
| await user.clear(bagNameInput); | |
| await user.type(bagNameInput, 'Luggage'); | |
| await user.keyboard('{Enter}'); | |
| await waitFor(() => expect(updateBody).toMatchObject({ name: 'Luggage' })); | |
| }); | |
| it('FE-COMP-PACKING-066: BagCard Plus button opens user picker with trip members', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ | |
| owner: { id: 1, username: 'owner', avatar_url: null }, | |
| members: [{ id: 2, username: 'bob', avatar_url: null }], | |
| current_user_id: 1, | |
| }) | |
| ), | |
| http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ name: 'Camera', category: 'Electronics' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for the BagCard to render in the sidebar | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Day Pack').length).toBeGreaterThan(0); | |
| }); | |
| // Wait for tripMembers to load β UserPlus icon appears in category header when members exist | |
| await waitFor(() => { | |
| expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy(); | |
| }); | |
| // Find BagCard Plus button by navigating from the bag name span: | |
| // bag name <span> β header row <div> β outer BagCard <div> β querySelector for dashed button | |
| const bagNameEl = screen.getAllByText('Day Pack')[0]; | |
| const bagCardOuter = bagNameEl.parentElement!.parentElement!; | |
| const bagCardPlusBtn = bagCardOuter.querySelector('button[style*="dashed"]') as HTMLElement; | |
| expect(bagCardPlusBtn).toBeTruthy(); | |
| await user.click(bagCardPlusBtn); | |
| // User picker dropdown appears with member names (tripMembers already loaded) | |
| await screen.findByText('bob'); | |
| expect(screen.getByText('owner')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-PACKING-067: BagCard user picker member click calls setBagMembers', async () => { | |
| let membersBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/trips/:id/members', () => | |
| HttpResponse.json({ | |
| owner: { id: 1, username: 'owner', avatar_url: null }, | |
| members: [{ id: 3, username: 'carol', avatar_url: null }], | |
| current_user_id: 1, | |
| }) | |
| ), | |
| http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), | |
| http.get('/api/trips/:id/packing/bags', () => | |
| HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] }) | |
| ), | |
| http.put('/api/trips/1/packing/bags/13/members', async ({ request }) => { | |
| membersBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ members: [{ user_id: 3, username: 'carol', avatar: null }] }); | |
| }) | |
| ); | |
| const items = [buildPackingItem({ name: 'Laptop', category: 'Tech' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for the BagCard to render and tripMembers to load | |
| await waitFor(() => { | |
| expect(screen.getAllByText('Weekend Bag').length).toBeGreaterThan(0); | |
| }); | |
| await waitFor(() => { | |
| expect(container.querySelector('svg.lucide-user-plus')).toBeTruthy(); | |
| }); | |
| // Find BagCard Plus button within the BagCard's DOM subtree: | |
| // bag name <span> β header row <div> β outer BagCard <div> β find dashed button | |
| const bagNameEl = screen.getAllByText('Weekend Bag')[0]; | |
| const bagCardOuter = bagNameEl.parentElement!.parentElement!; | |
| const bagCardPlusBtn = bagCardOuter.querySelector('button[style*="dashed"]') as HTMLElement; | |
| expect(bagCardPlusBtn).toBeTruthy(); | |
| fireEvent.click(bagCardPlusBtn); | |
| // Click 'carol' in the picker (accessible name: "C carol" from avatar initial + username) | |
| const carolBtn = await screen.findByText('carol'); | |
| fireEvent.click(carolBtn.closest('button')!); | |
| await waitFor(() => expect(membersBody).toMatchObject({ user_ids: [3] })); | |
| }); | |
| it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => { | |
| let createBody: Record<string, unknown> | null = null; | |
| server.use( | |
| http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })), | |
| http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })), | |
| http.post('/api/trips/1/packing/bags', async ({ request }) => { | |
| createBody = await request.json() as Record<string, unknown>; | |
| return HttpResponse.json({ bag: { id: 20, name: 'New Bag', color: '#6366f1', weight_limit_grams: null, members: [] } }); | |
| }), | |
| http.put('/api/trips/1/packing/150', async () => | |
| HttpResponse.json({ item: buildPackingItem({ id: 150 }) }) | |
| ) | |
| ); | |
| const items = [buildPackingItem({ id: 150, name: 'Sunglasses', category: 'Accessories' })]; | |
| const { container } = render(<PackingListPanel tripId={1} items={items} />); | |
| // Wait for Package icon (bag button in item row) | |
| await waitFor(() => expect(container.querySelector('svg.lucide-package')).toBeTruthy()); | |
| // Use fireEvent to open picker (avoids mouseLeave pointer events) | |
| const packageBtn = container.querySelector('svg.lucide-package')?.closest('button'); | |
| fireEvent.click(packageBtn!); | |
| // Click "Add bag" inside picker to show inline create | |
| const addBagInPickerBtns = await screen.findAllByText('Add bag'); | |
| fireEvent.click(addBagInPickerBtns[addBagInPickerBtns.length - 1]); | |
| // Inline input appears in picker | |
| const inlineInput = await screen.findByPlaceholderText('Bag name...'); | |
| fireEvent.change(inlineInput, { target: { value: 'New Bag' } }); | |
| fireEvent.keyDown(inlineInput, { key: 'Enter' }); | |
| await waitFor(() => expect(createBody).toMatchObject({ name: 'New Bag' })); | |
| }); | |
| it('FE-COMP-PACKING-069: Load CSV/TXT button clicks the hidden file input', async () => { | |
| const user = userEvent.setup(); | |
| const { container } = render(<PackingListPanel tripId={1} items={[]} />); | |
| // Open import modal | |
| const importBtn = container.querySelector('svg.lucide-upload')?.closest('button'); | |
| await user.click(importBtn!); | |
| await screen.findByText('Import Packing List'); | |
| // Spy on the hidden file input's click method | |
| const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; | |
| expect(fileInput).toBeTruthy(); | |
| const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); | |
| // Click the "Load CSV/TXT" button | |
| await user.click(screen.getByText('Load CSV/TXT')); | |
| expect(clickSpy).toHaveBeenCalled(); | |
| clickSpy.mockRestore(); | |
| }); | |
| }); | |