Add store item bulk delete mode #9

Merged
nalalangan merged 1 commits from feature/store-item-bulk-delete into feature-custom-store-locations 2026-05-31 17:01:06 -09:00
5 changed files with 237 additions and 60 deletions
Showing only changes of commit 7e46e25366 - Show all commits

View File

@ -24,6 +24,7 @@ This directory contains practical project documentation. Root-level rules still
## Guides ## Guides
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs. - `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
- `guides/frontend-readme.md`: frontend development notes. - `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/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands. - `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 [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null); const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false); 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) => { const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) { if (!householdId || !store?.id) {
@ -82,7 +87,9 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const closeManager = () => { const closeManager = () => {
setIsOpen(false); setIsOpen(false);
setPendingDeleteItem(null); setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteItems([]);
}; };
const handleUpdate = async (payload) => { 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 () => { const handleDeleteConfirm = async () => {
if (!pendingDeleteItem) { if (pendingDeleteItems.length === 0) {
return; return;
} }
try { try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); await Promise.all(
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`); pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
setPendingDeleteItem(null); );
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); await loadItems(query);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item"); 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> </button>
</div> </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"> <div className="store-items-modal-body">
{!catalogReady ? ( {!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p> <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"> <div className="store-items-table-body">
{items.map((item) => { {items.map((item) => {
const imageSrc = itemImageSource(item); const imageSrc = itemImageSource(item);
const isSelectedForDelete = selectedDeleteIds.has(item.item_id);
return ( 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-items-table-cell store-items-table-item">
<div className="store-available-items-summary"> <div className="store-available-items-summary">
{imageSrc ? ( {imageSrc ? (
@ -219,30 +311,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
</div> </div>
</div> </div>
<div className="store-items-table-cell store-items-table-actions"> {deleteMode ? (
<div className="store-available-items-actions"> <span className="store-items-delete-indicator" aria-hidden="true">
<button {isSelectedForDelete ? "✓" : ""}
type="button" </span>
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} ) : null}
</div> </button>
</div>
</div>
); );
})} })}
</div> </div>
@ -265,15 +339,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
/> />
<ConfirmSlideModal <ConfirmSlideModal
isOpen={Boolean(pendingDeleteItem)} isOpen={pendingDeleteItems.length > 0}
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"} title={
pendingDeleteItems.length === 1
? `Delete ${pendingDeleteItems[0].item_name}?`
: `Delete ${pendingDeleteItems.length} items?`
}
description={ description={
pendingDeleteItem pendingDeleteItems.length > 0
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.` ? `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" confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
onClose={() => setPendingDeleteItem(null)} onClose={() => setPendingDeleteItems([])}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
/> />
</> </>

View File

@ -72,6 +72,19 @@
white-space: nowrap; 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 { .store-available-items-search {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
@ -110,6 +123,15 @@
align-items: center; 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 { .store-items-table-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -123,6 +145,22 @@
background: var(--color-bg-surface); 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 { .store-items-table-cell {
min-width: 0; min-width: 0;
} }
@ -176,18 +214,23 @@
justify-self: end; justify-self: end;
} }
.store-available-items-actions { .store-items-delete-indicator {
display: flex; width: 28px;
gap: var(--spacing-xs); height: 28px;
flex-wrap: wrap; display: inline-flex;
justify-content: flex-end; 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 { .store-items-table-row-button.is-selected .store-items-delete-indicator {
min-width: 112px; border-color: var(--color-danger);
min-height: 36px; background: var(--color-danger);
border-radius: var(--button-border-radius);
padding-inline: var(--spacing-md);
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@ -201,16 +244,16 @@
gap: var(--spacing-sm); gap: var(--spacing-sm);
} }
.store-items-table-actions { .store-items-table-row-button.is-delete-selectable {
justify-self: stretch; grid-template-columns: minmax(0, 1fr) auto;
} }
.store-available-items-actions { .store-items-bulk-toolbar {
width: 100%; width: 100%;
justify-content: stretch; align-items: stretch;
} }
.store-available-items-actions button { .store-items-bulk-toolbar button {
flex: 1 1 0; 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.locator(".store-available-items-thumb-placeholder").first()).toHaveText("\uD83D\uDCE6");
await expect(managerModal.getByText("Store Defaults")).toHaveCount(0); await expect(managerModal.getByText("Store Defaults")).toHaveCount(0);
await expect(managerModal.getByText("No store defaults set")).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 searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox();
const addButtonBox = await managerModal.getByRole("button", { name: "Add Item" }).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); ).toBeLessThan(2);
const appleRow = managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }); const appleRow = managerModal.getByRole("button", { name: "Edit settings for apples" });
const appleEditButton = appleRow.getByRole("button", { name: "Edit Settings" }); const milkRow = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" });
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/);
await appleEditButton.click(); await appleRow.click();
const editorModal = page.locator(".available-item-editor-modal"); const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible(); await expect(editorModal).toBeVisible();
await expect(editorModal.getByLabel("Item Name")).toBeDisabled(); 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(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0); 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"); const confirmModal = page.locator(".confirm-slide-modal");
await expect(confirmModal).toBeVisible(); await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete milk?")).toBeVisible(); await expect(confirmModal.getByText("Delete milk?")).toBeVisible();