diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index 280ebca..c535c08 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -155,6 +155,7 @@ For `app/api/**/[param]/route.ts`: - Tap targets remain >= 40px on mobile. - Modal overlays must close on outside click/tap. - For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure). +- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception. - Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency. - Add Playwright UI tests for new UI features and critical flows. diff --git a/frontend/src/components/manage/ManageStores.jsx b/frontend/src/components/manage/ManageStores.jsx index 67ea8f0..1f73f23 100644 --- a/frontend/src/components/manage/ManageStores.jsx +++ b/frontend/src/components/manage/ManageStores.jsx @@ -92,7 +92,7 @@ export default function ManageStores() {

Your Stores ({householdStores.length})

- Item management lives inside each store card below for items already used in that household/store. + Use each store card's Manage Items button to edit or delete the household/store item list.

{!isAdmin && (

diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx index d903eff..a886fed 100644 --- a/frontend/src/components/manage/StoreAvailableItemsManager.jsx +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -7,6 +7,7 @@ import { import useActionToast from "../../hooks/useActionToast"; import getApiErrorMessage from "../../lib/getApiErrorMessage"; import AvailableItemEditorModal from "../modals/AvailableItemEditorModal"; +import ConfirmSlideModal from "../modals/ConfirmSlideModal"; function itemImageSource(item) { if (!item?.item_image) { @@ -19,7 +20,7 @@ function itemImageSource(item) { export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) { const toast = useActionToast(); - const [expanded, setExpanded] = useState(true); + const [isOpen, setIsOpen] = useState(false); const [items, setItems] = useState([]); const [catalogReady, setCatalogReady] = useState(true); const [catalogMessage, setCatalogMessage] = useState(""); @@ -27,6 +28,7 @@ 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 loadItems = useCallback(async (search = query) => { if (!householdId || !store?.id) { @@ -41,10 +43,10 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin setCatalogReady(response.data.catalog_ready !== false); setCatalogMessage(response.data.message || ""); } catch (error) { - console.error("Failed to load available items:", error); + console.error("Failed to load store items:", error); setCatalogReady(false); - setCatalogMessage("Store item catalog is unavailable right now."); - const message = getApiErrorMessage(error, "Failed to load available items"); + setCatalogMessage("Store item management is unavailable right now."); + const message = getApiErrorMessage(error, "Failed to load store items"); toast.error("Load store items failed", `Load store items failed: ${message}`); } finally { setLoading(false); @@ -52,18 +54,23 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin }, [householdId, query, store?.id, toast]); useEffect(() => { - if (!expanded) { + if (!isOpen) { return; } loadItems(query); - }, [expanded, query, loadItems]); + }, [isOpen, query, loadItems]); + + const closeManager = () => { + setIsOpen(false); + setPendingDeleteItem(null); + }; const handleUpdate = async (payload) => { if (!catalogReady) { toast.info( - "Store item catalog unavailable", - catalogMessage || "Store item catalog is unavailable until the latest database migration is applied." + "Store item management unavailable", + catalogMessage || "Store item management is unavailable until the latest database migration is applied." ); return; } @@ -75,32 +82,25 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin setEditorItem(null); await loadItems(query); } catch (error) { - const message = getApiErrorMessage(error, "Failed to update available item"); + const message = getApiErrorMessage(error, "Failed to update store item"); toast.error("Update store item failed", `Update store item failed: ${message}`); throw error; } }; - const handleDelete = async (item) => { - if (!catalogReady) { - toast.info( - "Store item catalog unavailable", - catalogMessage || "Store item catalog is unavailable until the latest database migration is applied." - ); - return; - } - - if (!confirm(`Remove ${item.item_name} from ${store.name}'s available items?`)) { + const handleDeleteConfirm = async () => { + if (!pendingDeleteItem) { return; } try { - await deleteAvailableItem(householdId, store.id, item.item_id); - toast.success("Cleared store item settings", `Cleared settings for ${item.item_name} in ${store.name}`); + await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); + toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`); + setPendingDeleteItem(null); await loadItems(query); } catch (error) { - const message = getApiErrorMessage(error, "Failed to clear store item settings"); - toast.error("Clear store item settings failed", `Clear store item settings failed: ${message}`); + const message = getApiErrorMessage(error, "Failed to delete store item"); + toast.error("Delete store item failed", `Delete store item failed: ${message}`); } }; @@ -109,90 +109,123 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin } return ( -

-
-
-

Store Item Catalog

-

Manage settings for items already used in {store.name} for this household.

-
- -
+ <> + - {expanded ? ( -
- {!catalogReady ? ( -

- {catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."} -

- ) : null} -
- setQuery(event.target.value)} - placeholder="Search household/store items" - disabled={!catalogReady} - /> -
- - {!catalogReady ? ( -

Run the latest database migrations to enable this catalog.

- ) : loading ? ( -

Loading store items...

- ) : items.length === 0 ? ( -

No household items found for this store yet.

- ) : ( -
- {items.map((item) => { - const imageSrc = itemImageSource(item); - const details = [item.item_type, item.item_group, item.zone].filter(Boolean); - return ( -
-
- {imageSrc ? ( - - ) : ( - - {item.item_name?.slice(0, 1).toUpperCase() || "?"} - - )} -
- {item.item_name} - {details.join(" | ") || "No store defaults set"} -
-
-
- - {item.has_managed_settings ? ( - - ) : null} -
-
- ); - })} + {isOpen ? ( +
+
event.stopPropagation()}> +
+
+

{store.name} Items

+

Manage the household/store items used for suggestions and store defaults.

+
+
- )} + + {!catalogReady ? ( +

+ {catalogMessage || "Store item management is unavailable until the latest database migration is applied."} +

+ ) : null} + +
+ setQuery(event.target.value)} + placeholder="Search household/store items" + disabled={!catalogReady} + /> +
+ +
+ {!catalogReady ? ( +

Run the latest database migrations to enable store item management.

+ ) : loading ? ( +

Loading store items...

+ ) : items.length === 0 ? ( +

No household items found for this store yet.

+ ) : ( +
+ +
+ {items.map((item) => { + const imageSrc = itemImageSource(item); + const details = [item.item_type, item.item_group, item.zone].filter(Boolean); + + return ( +
+
+ Item +
+ {imageSrc ? ( + + ) : ( + + {item.item_name?.slice(0, 1).toUpperCase() || "?"} + + )} +
+ {item.item_name} +
+
+
+ +
+ Store Defaults + + {details.join(" | ") || "No store defaults set"} + +
+ +
+ Actions +
+ + +
+
+
+ ); + })} +
+
+ )} +
+
) : null} @@ -205,6 +238,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin }} onSave={handleUpdate} /> -
+ + setPendingDeleteItem(null)} + onConfirm={handleDeleteConfirm} + /> + ); } diff --git a/frontend/src/components/modals/AvailableItemEditorModal.jsx b/frontend/src/components/modals/AvailableItemEditorModal.jsx index 9e54191..582a0c6 100644 --- a/frontend/src/components/modals/AvailableItemEditorModal.jsx +++ b/frontend/src/components/modals/AvailableItemEditorModal.jsx @@ -101,10 +101,10 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
event.stopPropagation()}>

- {item ? `Edit ${item.item_name}` : "Add Available Item"} + {item ? `Edit ${item.item_name}` : "Edit Store Item"}

- Save store-specific item defaults for this household. + Save store-specific defaults for this household/store item.

diff --git a/frontend/src/styles/components/manage/StoreAvailableItemsManager.css b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css index a8321b2..42b870b 100644 --- a/frontend/src/styles/components/manage/StoreAvailableItemsManager.css +++ b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css @@ -1,32 +1,75 @@ -.store-available-items { - border-top: var(--border-width-thin) solid var(--color-border-light); - padding-top: var(--spacing-md); +.store-available-items-trigger { + width: 100%; } -.store-available-items-header { +.store-items-modal-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-md); + background: var(--modal-backdrop-bg); +} + +.store-items-modal { + width: min(960px, 100%); + max-height: min(80vh, 760px); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding: var(--spacing-lg); + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-xl); + background: var(--modal-bg); + box-shadow: var(--shadow-xl); +} + +.store-items-modal-header { display: flex; justify-content: space-between; gap: var(--spacing-md); align-items: flex-start; } -.store-available-items-header h4 { +.store-items-modal-header h3 { margin: 0; color: var(--color-text-primary); - font-size: var(--font-size-base); + font-size: var(--font-size-xl); } -.store-available-items-header p { +.store-items-modal-header p { margin: var(--spacing-xs) 0 0; color: var(--color-text-secondary); font-size: var(--font-size-sm); } -.store-available-items-panel { - margin-top: var(--spacing-md); - display: flex; - flex-direction: column; - gap: var(--spacing-md); +.store-items-modal-close { + width: 40px; + height: 40px; + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: 50%; + background: var(--color-bg-surface); + color: var(--color-text-primary); + font-size: var(--font-size-lg); + line-height: 1; +} + +.store-items-modal-toolbar { + position: sticky; + top: 0; + z-index: 1; + background: var(--modal-bg); +} + +.store-available-items-search { + width: 100%; + padding: var(--input-padding-y) var(--input-padding-x); + border: var(--border-width-thin) solid var(--input-border-color); + border-radius: var(--input-border-radius); + background: var(--color-bg-surface); + color: var(--color-text-primary); } .store-available-items-notice { @@ -38,45 +81,58 @@ color: var(--color-text-secondary); } -.store-available-items-toolbar { - display: flex; - gap: var(--spacing-sm); - align-items: center; - flex-wrap: wrap; +.store-items-modal-body { + min-height: 0; + overflow-y: auto; } -.store-available-items-search { - flex: 1 1 240px; - padding: var(--input-padding-y) var(--input-padding-x); - border: var(--border-width-thin) solid var(--input-border-color); - border-radius: var(--input-border-radius); - background: var(--color-bg-surface); - color: var(--color-text-primary); -} - -.store-available-items-toolbar-actions { - display: flex; - gap: var(--spacing-sm); - flex-wrap: wrap; -} - -.store-available-items-list { +.store-items-table { display: flex; flex-direction: column; gap: var(--spacing-sm); } -.store-available-items-card { - display: flex; - justify-content: space-between; - gap: var(--spacing-sm); +.store-items-table-head, +.store-items-table-row { + display: grid; + grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr); + gap: var(--spacing-md); align-items: center; +} + +.store-items-table-head { + position: sticky; + top: 0; + padding: 0 var(--spacing-sm) var(--spacing-xs); + background: var(--modal-bg); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.store-items-table-body { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.store-items-table-row { padding: var(--spacing-sm); border: var(--border-width-thin) solid var(--color-border-light); border-radius: var(--border-radius-md); background: var(--color-bg-surface); } +.store-items-table-cell { + min-width: 0; +} + +.store-items-table-item { + min-width: 0; +} + .store-available-items-summary { display: flex; align-items: center; @@ -110,34 +166,68 @@ .store-available-items-copy strong { color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.store-available-items-copy span { +.store-items-defaults-text { color: var(--color-text-secondary); font-size: var(--font-size-sm); } +.store-items-table-actions { + justify-self: end; +} + .store-available-items-actions { display: flex; gap: var(--spacing-xs); flex-wrap: wrap; + justify-content: flex-end; } -@media (max-width: 640px) { - .store-available-items-header, - .store-available-items-card, - .store-available-items-toolbar { +.store-items-mobile-label { + display: none; +} + +@media (max-width: 720px) { + .store-items-modal { + max-height: min(88vh, 900px); + padding: var(--spacing-md); + } + + .store-items-table-head { + display: none; + } + + .store-items-table-row { + display: flex; flex-direction: column; align-items: stretch; + gap: var(--spacing-sm); } - .store-available-items-actions, - .store-available-items-toolbar-actions { + .store-items-mobile-label { + display: block; + margin-bottom: 4px; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .store-items-table-actions { + justify-self: stretch; + } + + .store-available-items-actions { width: 100%; + justify-content: stretch; } - .store-available-items-actions button, - .store-available-items-toolbar-actions button { - flex: 1; + .store-available-items-actions button { + flex: 1 1 0; } } diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts index 97a4ccc..6d26dad 100644 --- a/frontend/tests/available-items-catalog.spec.ts +++ b/frontend/tests/available-items-catalog.spec.ts @@ -45,7 +45,7 @@ async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page) }); } -test("manage stores lets admins edit settings for existing household/store items", async ({ page }) => { +test("manage stores opens a modal to edit and delete household store items", async ({ page }) => { await seedAuthStorage(page); await mockConfig(page); await mockHouseholdAndStoreShell(page); @@ -128,21 +128,11 @@ test("manage stores lets admins edit settings for existing household/store items await page.route("**/households/1/stores/10/available-items/501", async (route) => { if (route.request().method() === "DELETE") { - availableItems = availableItems.map((item) => - item.item_id === 501 - ? { - ...item, - item_type: null, - item_group: null, - zone: null, - has_managed_settings: false, - } - : item - ); + availableItems = availableItems.filter((item) => item.item_id !== 501); await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ message: "Store item settings cleared" }), + body: JSON.stringify({ message: "Store item deleted" }), }); return; } @@ -154,14 +144,16 @@ test("manage stores lets admins edit settings for existing household/store items const storeCard = page.locator(".store-card").filter({ hasText: "Costco" }); await expect(storeCard).toBeVisible(); - await expect(storeCard.getByText("Store Item Catalog")).toBeVisible(); + await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible(); - await expect(storeCard.getByText("milk")).toBeVisible(); - await expect(storeCard.getByText("apples")).toBeVisible(); - await expect(storeCard.getByRole("button", { name: "Add Item" })).toHaveCount(0); - await expect(storeCard.getByRole("button", { name: "Import Current List" })).toHaveCount(0); + await storeCard.getByRole("button", { name: "Manage Items" }).click(); - await storeCard.locator(".store-available-items-card").filter({ hasText: "apples" }).getByRole("button", { name: "Edit" }).click(); + const managerModal = page.locator(".store-items-modal"); + await expect(managerModal).toBeVisible(); + await expect(managerModal.getByText("milk")).toBeVisible(); + await expect(managerModal.getByText("apples")).toBeVisible(); + + await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click(); const editorModal = page.locator(".available-item-editor-modal"); await expect(editorModal).toBeVisible(); await expect(editorModal.getByLabel("Item Name")).toBeDisabled(); @@ -171,13 +163,29 @@ test("manage stores lets admins edit settings for existing household/store items await editorModal.getByRole("button", { name: "Save Changes" }).click(); await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item"); - await expect(storeCard.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible(); + await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible(); - page.once("dialog", (dialog) => dialog.accept()); - await storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" }).click(); - await expect(page.locator(".action-toast.action-toast-success")).toContainText("Cleared store item settings"); - await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByText("No store defaults set")).toBeVisible(); - await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" })).toHaveCount(0); + await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click(); + const confirmModal = page.locator(".confirm-slide-modal"); + await expect(confirmModal).toBeVisible(); + await expect(confirmModal.getByText("Delete milk?")).toBeVisible(); + + const slider = confirmModal.locator(".confirm-slide-handle"); + const track = confirmModal.locator(".confirm-slide-track"); + const sliderBox = await slider.boundingBox(); + const trackBox = await track.boundingBox(); + + if (!sliderBox || !trackBox) { + throw new Error("Confirm slide control was not measurable"); + } + + await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2); + await page.mouse.down(); + await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 }); + await page.mouse.up(); + + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item"); + await expect(managerModal.getByText("milk")).toHaveCount(0); }); test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {