feat: add store item bulk delete mode

This commit is contained in:
Nico 2026-05-31 18:59:16 -07:00
parent f968d304cc
commit 7e46e25366
5 changed files with 237 additions and 60 deletions

View File

@ -24,6 +24,7 @@ This directory contains practical project documentation. Root-level rules still
## Guides
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `guides/frontend-readme.md`: frontend development notes.
- `guides/management-modal-patterns.md`: reusable modal patterns for managing scoped item/list records.
- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.

View File

@ -0,0 +1,42 @@
# Management Modal Patterns
Use this guide for modals that manage scoped lists of app-owned records, such as store items now and store zones or locations later.
## Purpose
- Management modals should keep users in the current workflow while they inspect, edit, add, or remove records for a scoped parent.
- The parent scope must be obvious in the title, for example `Costco Items`.
- Modals should avoid repeating table labels inside every row. Use row layout, grouping, and the edit surface for detail.
## Structure
- Header: title, one short description when the scope is not obvious, and a close button.
- Primary toolbar: search input plus the primary create action inline.
- Bulk toolbar: destructive or multi-select actions above the list, separate from search/create controls.
- List: compact rows with the record's primary identity and any essential visual affordance.
- Editor: clicking or tapping a row opens the edit/settings modal for that record.
- Confirmation: destructive actions must use `ConfirmSlideModal`, not browser dialogs.
## Row Behavior
- Normal mode: the entire row opens settings for that record.
- Delete mode: the row toggles selected/unselected state and does not open settings.
- Selection state must be visible on the row and must not rely only on color.
- Avoid per-row action buttons when the same action applies to every row.
## Bulk Delete Pattern
- Show a `Delete Items` button above the list for users with delete permission.
- Clicking `Delete Items` enters delete mode, clears any previous selection, and changes the button to `Confirm Delete (# selected)`.
- Show a `Cancel` button while delete mode is active.
- Disable confirm while zero items are selected.
- Clicking confirm opens `ConfirmSlideModal`; only the slide confirmation performs the mutation.
- On success, exit delete mode, clear selection, refresh the list, and show a toast.
- On failure, keep the modal open and show a toast with the API error summary.
## Permission Rules
- Keep authorization server-side. Client visibility only improves UX.
- Members can open item settings when the API allows them to manage item details.
- Delete controls should be shown only to owners/admins when deletion is admin-scoped.
## Accessibility
- Modal containers should use dialog semantics when practical.
- Rows that perform actions should be keyboard reachable.
- Delete-mode rows should expose selected state with `aria-pressed` or an equivalent state.
- Buttons must have stable labels that describe the action in the current mode.

View File

@ -31,7 +31,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteItems, setPendingDeleteItems] = useState([]);
const selectedDeleteItems = items.filter((item) => selectedDeleteIds.has(item.item_id));
const selectedDeleteCount = selectedDeleteItems.length;
const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) {
@ -82,7 +87,9 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const closeManager = () => {
setIsOpen(false);
setPendingDeleteItem(null);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteItems([]);
};
const handleUpdate = async (payload) => {
@ -115,19 +122,64 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
}
};
const openEditor = (item) => {
setEditorItem(item);
setShowEditor(true);
};
const toggleDeleteSelection = (itemId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(itemId)) {
nextIds.delete(itemId);
} else {
nextIds.add(itemId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (selectedDeleteCount === 0) {
return;
}
setPendingDeleteItems(selectedDeleteItems);
};
const handleDeleteConfirm = async () => {
if (!pendingDeleteItem) {
if (pendingDeleteItems.length === 0) {
return;
}
try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
setPendingDeleteItem(null);
await Promise.all(
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
);
const count = pendingDeleteItems.length;
toast.success(
count === 1 ? "Deleted store item" : "Deleted store items",
`Deleted ${count} ${count === 1 ? "item" : "items"} from ${store.display_name || store.name}`
);
setPendingDeleteItems([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
toast.error("Delete store items failed", `Delete store items failed: ${message}`);
}
};
@ -186,6 +238,28 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</button>
</div>
{isAdmin && catalogReady && items.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount} selected)` : "Delete Items"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
{!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
@ -198,9 +272,27 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<div className="store-items-table-body">
{items.map((item) => {
const imageSrc = itemImageSource(item);
const isSelectedForDelete = selectedDeleteIds.has(item.item_id);
return (
<div key={item.item_id} className="store-items-table-row">
<button
key={item.item_id}
type="button"
className={`store-items-table-row store-items-table-row-button ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${item.item_name} for deletion`
: `Edit settings for ${item.item_name}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onClick={() => {
if (deleteMode) {
toggleDeleteSelection(item.item_id);
} else {
openEditor(item);
}
}}
>
<div className="store-items-table-cell store-items-table-item">
<div className="store-available-items-summary">
{imageSrc ? (
@ -219,30 +311,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</div>
</div>
<div className="store-items-table-cell store-items-table-actions">
<div className="store-available-items-actions">
<button
type="button"
className="btn-secondary btn-small store-available-items-action"
onClick={() => {
setEditorItem(item);
setShowEditor(true);
}}
>
Edit Settings
</button>
{isAdmin ? (
<button
type="button"
className="btn-danger btn-small store-available-items-action"
onClick={() => setPendingDeleteItem(item)}
>
Delete Item
</button>
) : null}
</div>
</div>
</div>
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "✓" : ""}
</span>
) : null}
</button>
);
})}
</div>
@ -265,15 +339,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
/>
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteItem)}
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
isOpen={pendingDeleteItems.length > 0}
title={
pendingDeleteItems.length === 1
? `Delete ${pendingDeleteItems[0].item_name}?`
: `Delete ${pendingDeleteItems.length} items?`
}
description={
pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
pendingDeleteItems.length > 0
? `Slide to confirm. This permanently deletes ${pendingDeleteItems.length === 1 ? pendingDeleteItems[0].item_name : `${pendingDeleteItems.length} items`} from ${store.display_name || store.name} for this household, including current list entries and history.`
: ""
}
confirmLabel="Delete Item"
onClose={() => setPendingDeleteItem(null)}
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
onClose={() => setPendingDeleteItems([])}
onConfirm={handleDeleteConfirm}
/>
</>

View File

@ -72,6 +72,19 @@
white-space: nowrap;
}
.store-items-bulk-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-xs);
justify-content: flex-end;
}
.store-items-delete-toggle,
.store-items-delete-cancel {
min-height: 38px;
min-width: 132px;
}
.store-available-items-search {
flex: 1 1 auto;
width: 100%;
@ -110,6 +123,15 @@
align-items: center;
}
.store-items-table-row-button {
width: 100%;
appearance: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.store-items-table-body {
display: flex;
flex-direction: column;
@ -123,6 +145,22 @@
background: var(--color-bg-surface);
}
.store-items-table-row-button:hover,
.store-items-table-row-button:focus-visible {
border-color: var(--color-primary);
background: var(--color-bg-hover);
outline: none;
}
.store-items-table-row-button.is-delete-selectable {
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border-light));
}
.store-items-table-row-button.is-selected {
border-color: var(--color-danger);
background: var(--color-danger-light);
}
.store-items-table-cell {
min-width: 0;
}
@ -176,18 +214,23 @@
justify-self: end;
}
.store-available-items-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
justify-content: flex-end;
.store-items-delete-indicator {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-full);
color: var(--color-text-inverse);
background: var(--color-bg-surface);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
}
.store-available-items-action {
min-width: 112px;
min-height: 36px;
border-radius: var(--button-border-radius);
padding-inline: var(--spacing-md);
.store-items-table-row-button.is-selected .store-items-delete-indicator {
border-color: var(--color-danger);
background: var(--color-danger);
}
@media (max-width: 720px) {
@ -201,16 +244,16 @@
gap: var(--spacing-sm);
}
.store-items-table-actions {
justify-self: stretch;
.store-items-table-row-button.is-delete-selectable {
grid-template-columns: minmax(0, 1fr) auto;
}
.store-available-items-actions {
.store-items-bulk-toolbar {
width: 100%;
justify-content: stretch;
align-items: stretch;
}
.store-available-items-actions button {
.store-items-bulk-toolbar button {
flex: 1 1 0;
}
}

View File

@ -118,6 +118,9 @@ test("manage stores opens a modal to edit and delete household store items", asy
await expect(managerModal.locator(".store-available-items-thumb-placeholder").first()).toHaveText("\uD83D\uDCE6");
await expect(managerModal.getByText("Store Defaults")).toHaveCount(0);
await expect(managerModal.getByText("No store defaults set")).toHaveCount(0);
await expect(managerModal.getByText("Edit Settings", { exact: true })).toHaveCount(0);
await expect(managerModal.getByText("Delete Item", { exact: true })).toHaveCount(0);
await expect(managerModal.locator(".store-available-items-action")).toHaveCount(0);
const searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox();
const addButtonBox = await managerModal.getByRole("button", { name: "Add Item" }).boundingBox();
@ -130,13 +133,10 @@ test("manage stores opens a modal to edit and delete household store items", asy
)
).toBeLessThan(2);
const appleRow = managerModal.locator(".store-items-table-row").filter({ hasText: "apples" });
const appleEditButton = appleRow.getByRole("button", { name: "Edit Settings" });
const milkDeleteButton = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" });
await expect(appleEditButton).toHaveClass(/store-available-items-action/);
await expect(milkDeleteButton).toHaveClass(/store-available-items-action/);
const appleRow = managerModal.getByRole("button", { name: "Edit settings for apples" });
const milkRow = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" });
await appleEditButton.click();
await appleRow.click();
const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
@ -148,7 +148,20 @@ test("manage stores opens a modal to edit and delete household store items", asy
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0);
await milkDeleteButton.click();
await managerModal.getByRole("button", { name: "Delete Items" }).click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0 selected)" })).toBeDisabled();
await expect(managerModal.getByRole("button", { name: "Cancel" })).toBeVisible();
await milkRow.click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (1 selected)" })).toBeEnabled();
await expect(milkRow).toHaveClass(/is-selected/);
await milkRow.click();
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0 selected)" })).toBeDisabled();
await expect(milkRow).not.toHaveClass(/is-selected/);
await milkRow.click();
await managerModal.getByRole("button", { name: "Confirm Delete (1 selected)" }).click();
const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();