Files
TREK/client/src/components/Packing/PackingListPanel.test.tsx
T
jubnl d4bb8be86b test: expand frontend test suite to 82% coverage
Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
2026-04-08 21:14:49 +02:00

1394 lines
58 KiB
TypeScript

// 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, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: false })
),
http.get('/api/admin/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/admin/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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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 when items exist', async () => {
const user = userEvent.setup();
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
const { container } = render(<PackingListPanel tripId={1} items={items} />);
// Save-as-template button uses FolderPlus icon and "Save as template" text
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
expect(folderPlusBtn).toBeTruthy();
// Click to show the name input
await user.click(folderPlusBtn!);
// Template name input appears
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
});
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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 () => {
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/admin/packing-templates', () =>
HttpResponse.json({ templates: [] })
)
);
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
const { container } = render(<PackingListPanel tripId={1} items={items} />);
// Click the FolderPlus "Save as template" button
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
await user.click(folderPlusBtn!);
// 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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
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/admin/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/admin/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/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
// 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/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
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/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
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/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
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/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
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/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
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();
});
});