diff --git a/docs/README.md b/docs/README.md index 24b6be8..36bc9fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/guides/management-modal-patterns.md b/docs/guides/management-modal-patterns.md new file mode 100644 index 0000000..61060b8 --- /dev/null +++ b/docs/guides/management-modal-patterns.md @@ -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. diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx index a31c8c5..bcd6c42 100644 --- a/frontend/src/components/manage/StoreAvailableItemsManager.jsx +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -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 + {isAdmin && catalogReady && items.length > 0 ? ( +
+ + {deleteMode ? ( + + ) : null} +
+ ) : null} +
{!catalogReady ? (

Run the latest database migrations to enable store item management.

@@ -198,9 +272,27 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
{items.map((item) => { const imageSrc = itemImageSource(item); + const isSelectedForDelete = selectedDeleteIds.has(item.item_id); return ( -
+ - {isAdmin ? ( - - ) : null} -
-
-
+ {deleteMode ? ( + + ) : null} + ); })} @@ -265,15 +339,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin /> 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} /> diff --git a/frontend/src/styles/components/manage/StoreAvailableItemsManager.css b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css index bba3f5f..fed8721 100644 --- a/frontend/src/styles/components/manage/StoreAvailableItemsManager.css +++ b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css @@ -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; } } diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts index 44091f2..c6b294d 100644 --- a/frontend/tests/available-items-catalog.spec.ts +++ b/frontend/tests/available-items-catalog.spec.ts @@ -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();