Add store item bulk delete mode #9
@ -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.
|
||||
|
||||
|
||||
42
docs/guides/management-modal-patterns.md
Normal file
42
docs/guides/management-modal-patterns.md
Normal 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.
|
||||
@ -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>
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "✓" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user