diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index f75b27fc..41e7bc36 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -174,7 +174,9 @@ describe('PackingListPanel', () => { 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' }); + // Uncategorized item: deleting it is a plain DELETE (a custom category's last + // item is instead converted to a placeholder — see FE-COMP-PACKING-070). + const item = buildPackingItem({ id: 99, name: 'To Remove', category: null }); let deleteCalled = false; server.use( http.delete('/api/trips/1/packing/99', () => { @@ -1415,4 +1417,83 @@ describe('PackingListPanel', () => { expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); + + it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => { + const user = userEvent.setup(); + const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' }); + // handleDeleteItem decides "last in category" from the rendered list. + seedStore(useTripStore, { packingItems: [item] }); + let deleted = false; + let putBody: Record | null = null; + server.use( + http.delete('/api/trips/1/packing/99', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }), + http.put('/api/trips/1/packing/99', async ({ request }) => { + putBody = await request.json() as Record; + return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) }); + }) + ); + render(); + + await user.click(screen.getByTitle('Delete')); + + // The row is updated in place (same id) rather than deleted, so colour/position hold. + await waitFor(() => expect(putBody).toMatchObject({ name: '...' })); + expect(deleted).toBe(false); + }); + + it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => { + const user = userEvent.setup(); + const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' }); + seedStore(useTripStore, { packingItems: [placeholder] }); + let deleted = false; + let converted = false; + server.use( + http.delete('/api/trips/1/packing/5', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }), + http.put('/api/trips/1/packing/5', () => { + converted = true; + return HttpResponse.json({ item: placeholder }); + }) + ); + render(); + + await user.click(screen.getByTitle('Delete')); + + await waitFor(() => expect(deleted).toBe(true)); + // It is the placeholder itself — it must be removed, not re-converted. + expect(converted).toBe(false); + }); + + it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => { + const user = userEvent.setup(); + const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' }); + seedStore(useTripStore, { packingItems: [placeholder] }); + let posted = false; + let putBody: Record | null = null; + server.use( + http.post('/api/trips/1/packing', () => { + posted = true; + return HttpResponse.json({ item: buildPackingItem({ id: 6 }) }); + }), + http.put('/api/trips/1/packing/5', async ({ request }) => { + putBody = await request.json() as Record; + return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) }); + }) + ); + render(); + + // Open the category's inline "Add item" and add a real entry. + await user.click(screen.getByText('Add item')); + const input = await screen.findByPlaceholderText('Item name...'); + await user.type(input, 'Tent'); + await user.keyboard('{Enter}'); + + await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' })); + expect(posted).toBe(false); + }); }); diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index 9c7a7dfe..a21b90d1 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -18,6 +18,7 @@ interface KategorieGruppeProps { allCategories: string[] onRename: (oldName: string, newName: string) => Promise onDeleteAll: (items: PackingItem[]) => Promise + onDeleteItem: (item: PackingItem) => Promise onAddItem: (category: string, name: string) => Promise assignees: CategoryAssignee[] tripMembers: TripMember[] @@ -28,7 +29,7 @@ interface KategorieGruppeProps { canEdit?: boolean } -export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) { +export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) { const [offen, setOffen] = useState(true) const [editingName, setEditingName] = useState(false) const [editKatName, setEditKatName] = useState(kategorie) @@ -231,7 +232,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen {offen && (
{items.map(item => ( - {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} /> + {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} /> ))} {/* Inline add item */} {canEdit && (showAddItem ? ( diff --git a/client/src/components/Packing/PackingListPanelItemRow.tsx b/client/src/components/Packing/PackingListPanelItemRow.tsx index 845e51fc..8ddf0c9e 100644 --- a/client/src/components/Packing/PackingListPanelItemRow.tsx +++ b/client/src/components/Packing/PackingListPanelItemRow.tsx @@ -15,13 +15,14 @@ interface ArtikelZeileProps { tripId: number categories: string[] onCategoryChange: () => void + onDelete?: (item: PackingItem) => Promise bagTrackingEnabled?: boolean bags?: PackingBag[] onCreateBag: (name: string) => Promise canEdit?: boolean } -export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { +export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME const [editing, setEditing] = useState(false) const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) @@ -43,6 +44,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTr } const handleDelete = async () => { + // The panel routes deletion through onDelete so an emptied custom category + // keeps its placeholder; fall back to a plain delete when used standalone. + if (onDelete) { await onDelete(item); return } try { await deletePackingItem(tripId, item.id) } catch { toast.error(t('packing.toast.deleteError')) } } diff --git a/client/src/components/Packing/PackingListPanelList.tsx b/client/src/components/Packing/PackingListPanelList.tsx index 832b1b05..112da514 100644 --- a/client/src/components/Packing/PackingListPanelList.tsx +++ b/client/src/components/Packing/PackingListPanelList.tsx @@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup' export function PackingList(S: PackingState) { const { - items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, + items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees, bagTrackingEnabled, bags, handleCreateBagByName, canEdit, } = S @@ -31,6 +31,7 @@ export function PackingList(S: PackingState) { allCategories={allCategories} onRename={handleRenameCategory} onDeleteAll={handleDeleteCategory} + onDeleteItem={handleDeleteItem} onAddItem={handleAddItemToCategory} assignees={categoryAssignees[kat] || []} tripMembers={tripMembers} diff --git a/client/src/components/Packing/usePackingListPanel.ts b/client/src/components/Packing/usePackingListPanel.ts index ef09daa7..71a25131 100644 --- a/client/src/components/Packing/usePackingListPanel.ts +++ b/client/src/components/Packing/usePackingListPanel.ts @@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n' import { packingApi, tripsApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' import type { PackingItem, PackingBag } from '../../types' -import { BAG_COLORS } from './packingListPanel.constants' +import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants' import { parseImportLines } from './packingListPanel.helpers' export interface TripMember { @@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt' const [addingCategory, setAddingCategory] = useState(false) const [newCatName, setNewCatName] = useState('') - const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore() + const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore() const can = useCanDo() const trip = useTripStore((s) => s.trip) const canEdit = can('packing_edit', trip) @@ -106,10 +106,45 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck const handleAddItemToCategory = async (category: string, name: string) => { try { - await addPackingItem(tripId, { name, category }) + // Reuse the '...' placeholder slot when the category already has one, so a + // freshly-emptied category keeps its position (and therefore its colour) + // instead of the new item being appended to the end of the list. + const placeholder = useTripStore.getState().packingItems.find( + i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME + ) + if (placeholder) { + await updatePackingItem(tripId, placeholder.id, { name }) + } else { + await addPackingItem(tripId, { name, category }) + } } catch { toast.error(t('packing.toast.addError')) } } + // Deleting an item from a row. When it is the last item of a user-created + // category, turn that row back into the '...' placeholder in place rather than + // deleting it (#1289). Updating the row keeps its id, list position and colour, + // so the category neither disappears nor jumps to the end. The default + // (uncategorized) group and the placeholder row itself are deleted normally — + // removing the placeholder is how an empty category is dismissed. + const handleDeleteItem = async (item: PackingItem) => { + const category = item.category + const isLastInCategory = !!category + && item.name !== PACKING_PLACEHOLDER_NAME + && !items.some(i => i.id !== item.id && i.category === category) + try { + if (isLastInCategory) { + if (item.checked) await togglePackingItem(tripId, item.id, false) + await updatePackingItem(tripId, item.id, { + name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1, + }) + } else { + await deletePackingItem(tripId, item.id) + } + } catch { + toast.error(t('packing.toast.deleteError')) + } + } + const handleAddNewCategory = async () => { if (!newCatName.trim()) return let catName = newCatName.trim() @@ -308,7 +343,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck tripId, items, inlineHeader, t, canEdit, isAdmin, font, filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName, tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, - handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked, + handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked, bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal, handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers, availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,