mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
d4bb8be86b
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.
1394 lines
58 KiB
TypeScript
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();
|
|
});
|
|
});
|