Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,17 @@ export const assignmentsApi = {

export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
listCategories: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/categories`).then(r => r.data),
createCategory: (tripId: number | string, data: { name: string; type: 'shared' | 'personal' | 'private' }) => apiClient.post(`/trips/${tripId}/packing/categories`, data).then(r => r.data),
updateCategory: (tripId: number | string, catId: number, data: { name?: string; type?: 'shared' | 'personal' | 'private' }) => apiClient.patch(`/trips/${tripId}/packing/categories/${catId}`, data).then(r => r.data),
deleteCategory: (tripId: number | string, catId: number) => apiClient.delete(`/trips/${tripId}/packing/categories/${catId}`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${categoryId}`, { user_ids: userIds }).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds }).then(r => r.data),
Expand Down
62 changes: 31 additions & 31 deletions client/src/components/Packing/PackingListPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ 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 { buildUser, buildTrip, buildPackingItem, packingCategoryIdFor, resetPackingCategoryIds } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';

beforeEach(() => {
resetAllStores();
resetPackingCategoryIds();
// Side-effect APIs PackingListPanel calls on mount
server.use(
http.get('/api/trips/:id/members', () =>
Expand All @@ -20,6 +21,9 @@ beforeEach(() => {
http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: {} })
),
http.get('/api/trips/:id/packing/categories', () =>
HttpResponse.json({ categories: [] })
),
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: false })
),
Expand Down Expand Up @@ -313,13 +317,13 @@ describe('PackingListPanel', () => {
await waitFor(() => expect(patchBody).toMatchObject({ quantity: 5 }));
});

it('FE-COMP-PACKING-027: add new category via form calls POST', async () => {
it('FE-COMP-PACKING-027: add new category via form POSTs to /categories with name and type', async () => {
const user = userEvent.setup();
let postBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', async ({ request }) => {
http.post('/api/trips/1/packing/categories', async ({ request }) => {
postBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ name: '...', category: 'Valuables' }) });
return HttpResponse.json({ category: { id: 999, trip_id: 1, name: 'Valuables', type: 'shared', owner_user_id: null, sort_order: 0 } });
})
);
render(<PackingListPanel tripId={1} items={[]} />);
Expand All @@ -329,7 +333,7 @@ describe('PackingListPanel', () => {
await user.type(input, 'Valuables');
await user.keyboard('{Enter}');

await waitFor(() => expect(postBody).toMatchObject({ category: 'Valuables' }));
await waitFor(() => expect(postBody).toMatchObject({ name: 'Valuables', type: 'shared' }));
});

it('FE-COMP-PACKING-028: category group collapse hides items, expand shows them', async () => {
Expand Down Expand Up @@ -460,33 +464,31 @@ describe('PackingListPanel', () => {
});
});

it('FE-COMP-PACKING-035: category rename via context menu calls PUT', async () => {
it('FE-COMP-PACKING-035: category rename via context menu PATCHes /categories/:catId', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 90, name: 'Shirt', category: 'Clothing' });
let putBody: Record<string, unknown> | null = null;
const catId = packingCategoryIdFor('Clothing')!;
let patchBody: 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' }) });
http.patch(`/api/trips/1/packing/categories/${catId}`, async ({ request }) => {
patchBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ category: { id: catId, trip_id: 1, name: 'Apparel', type: 'shared', owner_user_id: null, sort_order: 0 } });
})
);
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' }));
await waitFor(() => expect(patchBody).toMatchObject({ name: 'Apparel' }));
});

it('FE-COMP-PACKING-036: assignee dropdown opens and lists members when clicked', async () => {
Expand Down Expand Up @@ -879,9 +881,10 @@ describe('PackingListPanel', () => {
});

it('FE-COMP-PACKING-052: category assignee chip renders when assignees exist', async () => {
const catId = packingCategoryIdFor('Electronics')!;
server.use(
http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: { Electronics: [{ user_id: 2, username: 'alice', avatar: null }] } })
HttpResponse.json({ assignees: { [catId]: [{ user_id: 2, username: 'alice', avatar: null }] } })
)
);
const item = buildPackingItem({ name: 'Camera', category: 'Electronics' });
Expand Down Expand Up @@ -959,28 +962,25 @@ describe('PackingListPanel', () => {
expect(screen.getByText('8 items')).toBeInTheDocument();
});

it('FE-COMP-PACKING-037: delete category via context menu calls DELETE for all items', async () => {
it('FE-COMP-PACKING-037: delete category via context menu DELETEs /categories/:catId once', 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[] = [];
const catId = packingCategoryIdFor('Gear')!;
let deletedCatId = -1;
server.use(
http.delete('/api/trips/1/packing/:itemId', ({ params }) => {
deletedIds.push(Number(params.itemId));
http.delete(`/api/trips/1/packing/categories/${catId}`, () => {
deletedCatId = catId;
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);
});
await waitFor(() => expect(deletedCatId).toBe(catId));
});

it('FE-COMP-PACKING-056: pressing Enter in quantity input commits value', async () => {
Expand Down Expand Up @@ -1019,9 +1019,10 @@ describe('PackingListPanel', () => {
});
});

it('FE-COMP-PACKING-058: selecting a different category in picker calls PUT with new category', async () => {
it('FE-COMP-PACKING-058: selecting a different category in picker PUTs with new category_id', async () => {
const itemA = buildPackingItem({ id: 74, name: 'Camera', category: 'Electronics' });
const itemB = buildPackingItem({ id: 75, name: 'Passport', category: 'Documents' });
const documentsId = packingCategoryIdFor('Documents')!;
let putBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/packing/74', async ({ request }) => {
Expand All @@ -1031,15 +1032,13 @@ describe('PackingListPanel', () => {
);
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' });
const docBtn = await screen.findByRole('button', { name: /Documents/ });
fireEvent.click(docBtn);

await waitFor(() => expect(putBody).toMatchObject({ category: 'Documents' }));
await waitFor(() => expect(putBody).toMatchObject({ category_id: documentsId }));
});

it('FE-COMP-PACKING-059: clicking member in UserPlus dropdown calls setCategoryAssignees', async () => {
Expand Down Expand Up @@ -1075,10 +1074,11 @@ describe('PackingListPanel', () => {
});

it('FE-COMP-PACKING-060: clicking assignee chip removes assignee via setCategoryAssignees', async () => {
const catId = packingCategoryIdFor('Electronics')!;
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 }] } })
HttpResponse.json({ assignees: { [catId]: [{ user_id: 2, username: 'alice', avatar: null }] } })
),
http.get('/api/trips/:id/members', () =>
HttpResponse.json({
Expand All @@ -1087,7 +1087,7 @@ describe('PackingListPanel', () => {
current_user_id: 1,
})
),
http.put('/api/trips/1/packing/category-assignees/:cat', async ({ request }) => {
http.put(`/api/trips/1/packing/category-assignees/${catId}`, async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ assignees: [] });
})
Expand Down
Loading
Loading