mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 07:11:46 +00:00
fix(packing): keep a custom category when its last item is removed (#1289)
Removing the only item of a user-created category deleted the whole category. Turn that row back into the existing ... placeholder in place instead, so the category keeps its position and colour; adding an item reuses the placeholder slot. Deleting the placeholder (or the category menu) still removes an empty category.
This commit is contained in:
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
|
||||
})
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[item]} />);
|
||||
|
||||
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(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
|
||||
})
|
||||
);
|
||||
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ interface KategorieGruppeProps {
|
||||
allCategories: string[]
|
||||
onRename: (oldName: string, newName: string) => Promise<void>
|
||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||
onDeleteItem: (item: PackingItem) => Promise<void>
|
||||
onAddItem: (category: string, name: string) => Promise<void>
|
||||
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 && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{canEdit && (showAddItem ? (
|
||||
|
||||
@@ -15,13 +15,14 @@ interface ArtikelZeileProps {
|
||||
tripId: number
|
||||
categories: string[]
|
||||
onCategoryChange: () => void
|
||||
onDelete?: (item: PackingItem) => Promise<void>
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
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')) }
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user