From 61120dc2702b5910c661b61cdc3c39de85fac35e Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 00:00:53 -0700 Subject: [PATCH] feat: improve grocery list interactions --- .../src/components/items/GroceryListItem.jsx | 204 +++++++++++---- .../modals/AddItemWithDetailsModal.jsx | 55 ++--- .../src/components/modals/ConfirmBuyModal.jsx | 6 +- .../src/components/modals/EditItemModal.jsx | 73 +++--- frontend/src/pages/GroceryList.jsx | 228 +++++++++++++++-- frontend/src/styles/ConfirmBuyModal.css | 48 ++-- .../src/styles/components/AddItemForm.css | 43 +++- .../components/AddItemWithDetailsModal.css | 18 +- .../src/styles/components/EditItemModal.css | 42 +++- frontend/src/styles/pages/GroceryList.css | 233 +++++++++++++++++- frontend/tests/classification-details.spec.ts | 78 +++--- frontend/tests/grocery-list-actions.spec.ts | 174 +++++++++++++ frontend/tests/helpers/e2e.ts | 9 +- 13 files changed, 1001 insertions(+), 210 deletions(-) create mode 100644 frontend/tests/grocery-list-actions.spec.ts diff --git a/frontend/src/components/items/GroceryListItem.jsx b/frontend/src/components/items/GroceryListItem.jsx index abdc152..f7ad3b7 100644 --- a/frontend/src/components/items/GroceryListItem.jsx +++ b/frontend/src/components/items/GroceryListItem.jsx @@ -7,39 +7,49 @@ function GroceryListItem({ onOpenBuyModal, onImageAdded, onLongPress, + onSkip, compact = false }) { const [showAddImageModal, setShowAddImageModal] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); const longPressTimer = useRef(null); const pressStartPos = useRef({ x: 0, y: 0 }); - const handleTouchStart = (e) => { - const touch = e.touches[0]; + const hasActions = Boolean(onClick || onLongPress || onImageAdded || onSkip); + + const clearLongPressTimer = () => { + clearTimeout(longPressTimer.current); + }; + + const handleTouchStart = (event) => { + const touch = event.touches[0]; pressStartPos.current = { x: touch.clientX, y: touch.clientY }; longPressTimer.current = setTimeout(() => { if (onLongPress) { onLongPress(item); } - }, 500); // 500ms for long press + }, 500); }; - const handleTouchMove = (e) => { - // Cancel long press if finger moves too much - const touch = e.touches[0]; - const moveDistance = Math.sqrt( - Math.pow(touch.clientX - pressStartPos.current.x, 2) + - Math.pow(touch.clientY - pressStartPos.current.y, 2) - ); + const handleTouchMove = (event) => { + const touch = event.touches[0]; + const deltaX = touch.clientX - pressStartPos.current.x; + const deltaY = touch.clientY - pressStartPos.current.y; + const moveDistance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)); if (moveDistance > 10) { - clearTimeout(longPressTimer.current); + clearLongPressTimer(); + } + + if (hasActions && Math.abs(deltaX) > 45 && Math.abs(deltaX) > Math.abs(deltaY) * 1.25) { + setActionsOpen(true); } }; const handleTouchEnd = () => { - clearTimeout(longPressTimer.current); + clearLongPressTimer(); }; const handleMouseDown = () => { @@ -51,24 +61,42 @@ function GroceryListItem({ }; const handleMouseUp = () => { - clearTimeout(longPressTimer.current); + clearLongPressTimer(); }; const handleMouseLeave = () => { - clearTimeout(longPressTimer.current); + clearLongPressTimer(); }; const handleItemClick = () => { + if (actionsOpen) { + setActionsOpen(false); + return; + } + if (onClick) { onClick(item); } }; - const handleImageClick = (e) => { - e.stopPropagation(); // Prevent triggering the bought action + const handleItemKeyDown = (event) => { + if (event.key === "Escape" && actionsOpen) { + setActionsOpen(false); + event.stopPropagation(); + return; + } + + if ((event.key === "Enter" || event.key === " ") && onClick) { + event.preventDefault(); + handleItemClick(); + } + }; + + const handleImageClick = (event) => { + event.stopPropagation(); if (item.item_image && onOpenBuyModal) { onOpenBuyModal(item); - } else { + } else if (onImageAdded) { setShowAddImageModal(true); } }; @@ -80,6 +108,52 @@ function GroceryListItem({ setShowAddImageModal(false); }; + const handleActionToggle = (event) => { + event.stopPropagation(); + clearLongPressTimer(); + setActionsOpen((prev) => !prev); + }; + + const handleBoughtAction = (event) => { + event.stopPropagation(); + clearLongPressTimer(); + setActionsOpen(false); + if (onClick) { + onClick(item); + } + }; + + const handleEditAction = (event) => { + event.stopPropagation(); + clearLongPressTimer(); + setActionsOpen(false); + if (onLongPress) { + onLongPress(item); + } + }; + + const handleImageAction = (event) => { + event.stopPropagation(); + clearLongPressTimer(); + setActionsOpen(false); + if (onImageAdded) { + setShowAddImageModal(true); + } + }; + + const handleSkipAction = (event) => { + event.stopPropagation(); + clearLongPressTimer(); + setActionsOpen(false); + if (onSkip) { + onSkip(item); + } + }; + + const stopActionEvent = (event) => { + event.stopPropagation(); + }; + const imageUrl = item.item_image && item.image_mime_type ? `data:${item.image_mime_type};base64,${item.item_image}` : null; @@ -99,37 +173,39 @@ function GroceryListItem({ if (diffDays < 7) { return `${diffDays}d ago`; - } else if (diffDays < 30) { + } + if (diffDays < 30) { const weeks = Math.floor(diffDays / 7); return `${weeks}w ago`; - } else { - const months = Math.floor(diffDays / 30); - return `${months}m ago`; } + const months = Math.floor(diffDays / 30); + return `${months}m ago`; }; return ( <>
  • {item.item_image ? ( {item.item_name} ) : ( - 📦 + Img )} x{item.quantity}
    @@ -139,12 +215,55 @@ function GroceryListItem({
    {addedByUsers.length > 0 && (
    - {item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `} + {item.last_added_on && `${getTimeAgo(item.last_added_on)} - `} {addedByUsers.join(" | ")}
    )} + {hasActions && ( + + )} + + {actionsOpen && ( +
    + {onClick && ( + + )} + {onLongPress && ( + + )} + {onImageAdded && ( + + )} + {onSkip && ( + + )} +
    + )}
  • {showAddImageModal && ( @@ -158,22 +277,19 @@ function GroceryListItem({ ); } -// Memoize component to prevent re-renders when props haven't changed -export default memo(GroceryListItem, (prevProps, nextProps) => { - // Only re-render if the item data or handlers have changed - return ( - prevProps.item.id === nextProps.item.id && - prevProps.item.item_name === nextProps.item.item_name && - prevProps.item.quantity === nextProps.item.quantity && - prevProps.item.item_image === nextProps.item.item_image && - prevProps.item.bought === nextProps.item.bought && - prevProps.item.last_added_on === nextProps.item.last_added_on && - prevProps.item.zone === nextProps.item.zone && - prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') && - prevProps.onClick === nextProps.onClick && - prevProps.onOpenBuyModal === nextProps.onOpenBuyModal && - prevProps.onImageAdded === nextProps.onImageAdded && - prevProps.onLongPress === nextProps.onLongPress && - prevProps.compact === nextProps.compact - ); -}); +export default memo(GroceryListItem, (prevProps, nextProps) => ( + prevProps.item.id === nextProps.item.id && + prevProps.item.item_name === nextProps.item.item_name && + prevProps.item.quantity === nextProps.item.quantity && + prevProps.item.item_image === nextProps.item.item_image && + prevProps.item.bought === nextProps.item.bought && + prevProps.item.last_added_on === nextProps.item.last_added_on && + prevProps.item.zone === nextProps.item.zone && + prevProps.item.added_by_users?.join(",") === nextProps.item.added_by_users?.join(",") && + prevProps.onClick === nextProps.onClick && + prevProps.onOpenBuyModal === nextProps.onOpenBuyModal && + prevProps.onImageAdded === nextProps.onImageAdded && + prevProps.onLongPress === nextProps.onLongPress && + prevProps.onSkip === nextProps.onSkip && + prevProps.compact === nextProps.compact +)); diff --git a/frontend/src/components/modals/AddItemWithDetailsModal.jsx b/frontend/src/components/modals/AddItemWithDetailsModal.jsx index 899b99c..8dd84b5 100644 --- a/frontend/src/components/modals/AddItemWithDetailsModal.jsx +++ b/frontend/src/components/modals/AddItemWithDetailsModal.jsx @@ -1,16 +1,15 @@ import { useState } from "react"; import "../../styles/components/AddItemWithDetailsModal.css"; -import ClassificationSection from "../forms/ClassificationSection"; -import useActionToast from "../../hooks/useActionToast"; +import { getZoneValues } from "../../constants/classifications"; import ImageUploadSection from "../forms/ImageUploadSection"; export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) { - const toast = useActionToast(); const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); - const [itemType, setItemType] = useState(""); - const [itemGroup, setItemGroup] = useState(""); const [zone, setZone] = useState(""); + const zoneOptions = Array.isArray(zones) && zones.length > 0 + ? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean) + : getZoneValues(); const handleImageChange = (file) => { setSelectedImage(file); @@ -26,22 +25,12 @@ export default function AddItemWithDetailsModal({ itemName, zones = [], onConfir setImagePreview(null); }; - const handleItemTypeChange = (newType) => { - setItemType(newType); - setItemGroup(""); // Reset group when type changes - }; - const handleConfirm = () => { - if (itemType && !itemGroup) { - toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`); - return; - } - - const hasClassificationDetails = Boolean(itemType || itemGroup || zone); + const hasClassificationDetails = Boolean(zone); const classification = hasClassificationDetails ? { - item_type: itemType, - item_group: itemGroup || null, - zone: zone || null + item_type: null, + item_group: null, + zone } : null; onConfirm(selectedImage, classification); @@ -68,18 +57,22 @@ export default function AddItemWithDetailsModal({ itemName, zones = [], onConfir {/* Classification Section */}
    - +
    + + +
    {/* Actions */} diff --git a/frontend/src/components/modals/ConfirmBuyModal.jsx b/frontend/src/components/modals/ConfirmBuyModal.jsx index 57d093a..fdb3cda 100644 --- a/frontend/src/components/modals/ConfirmBuyModal.jsx +++ b/frontend/src/components/modals/ConfirmBuyModal.jsx @@ -85,15 +85,16 @@ export default function ConfirmBuyModal({ onClick={handlePrev} style={{ visibility: hasPrev ? "visible" : "hidden" }} disabled={!hasPrev || isSubmitting} + aria-label="Previous item" > {"<"} -
    +
    {imageUrl ? ( {item.item_name} ) : ( -
    [ ]
    +
    No image
    )}
    @@ -102,6 +103,7 @@ export default function ConfirmBuyModal({ onClick={handleNext} style={{ visibility: hasNext ? "visible" : "hidden" }} disabled={!hasNext || isSubmitting} + aria-label="Next item" > {">"} diff --git a/frontend/src/components/modals/EditItemModal.jsx b/frontend/src/components/modals/EditItemModal.jsx index a2c6339..cd53bb4 100644 --- a/frontend/src/components/modals/EditItemModal.jsx +++ b/frontend/src/components/modals/EditItemModal.jsx @@ -133,40 +133,6 @@ export default function EditItemModal({ item, zones = [], onSave, onCancel, onIm
    -
    - - -
    - - {itemType && ( -
    - - -
    - )} -
    +
    + More classification options +
    + + +
    + + {itemType && ( +
    + + +
    + )} +
    +
    + + ))} + + + )} + + {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( <>

    ))} diff --git a/frontend/src/styles/ConfirmBuyModal.css b/frontend/src/styles/ConfirmBuyModal.css index 6a64977..c8fa95a 100644 --- a/frontend/src/styles/ConfirmBuyModal.css +++ b/frontend/src/styles/ConfirmBuyModal.css @@ -15,7 +15,7 @@ .confirm-buy-modal { background: var(--modal-bg); padding: var(--spacing-md); - border-radius: var(--border-radius-xl); + border-radius: var(--border-radius-lg); max-width: 450px; width: 90%; box-shadow: var(--shadow-xl); @@ -93,6 +93,13 @@ background: var(--color-gray-100); } +.confirm-buy-image-container.no-image { + width: 100%; + max-width: 280px; + height: 76px; + border-style: dashed; +} + .confirm-buy-image { max-width: 100%; max-height: 100%; @@ -100,8 +107,9 @@ } .confirm-buy-image-placeholder { - font-size: 4em; + font-size: var(--font-size-sm); color: var(--color-border-medium); + font-weight: var(--font-weight-semibold); } .confirm-buy-quantity-section { @@ -222,8 +230,17 @@ /* Mobile optimizations */ @media (max-width: 480px) { + .confirm-buy-modal-overlay { + align-items: flex-end; + } + .confirm-buy-modal { - padding: 0.8em; + width: 100%; + max-width: 100%; + padding: var(--spacing-md); + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + max-height: 92vh; + overflow-y: auto; } .confirm-buy-header { @@ -255,13 +272,18 @@ } .confirm-buy-image-container { - width: 220px; - height: 220px; + width: min(220px, 62vw); + height: min(220px, 62vw); + } + + .confirm-buy-image-container.no-image { + width: min(220px, 62vw); + height: 52px; } .confirm-buy-nav-btn { - width: 30px; - height: 30px; + width: 40px; + height: 40px; font-size: 1.6em; } @@ -284,7 +306,7 @@ @media (max-width: 360px) { .confirm-buy-modal { - padding: 0.7em; + padding: var(--spacing-sm); } .confirm-buy-cancel, @@ -294,13 +316,11 @@ } .confirm-buy-image-container { - width: 180px; - height: 180px; + width: min(180px, 58vw); + height: min(180px, 58vw); } - .confirm-buy-nav-btn { - width: 28px; - height: 28px; - font-size: 1.4em; + .confirm-buy-image-container.no-image { + height: 48px; } } diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css index 5c455e9..6896c22 100644 --- a/frontend/src/styles/components/AddItemForm.css +++ b/frontend/src/styles/components/AddItemForm.css @@ -3,8 +3,8 @@ background: var(--color-bg-surface); padding: var(--spacing-md); border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-md); - margin-bottom: var(--spacing-xs); + box-shadow: var(--shadow-sm); + margin-bottom: var(--spacing-sm); border: var(--border-width-thin) solid var(--color-border-light); } @@ -51,6 +51,7 @@ font-family: var(--font-family-base); transition: var(--transition-base); width: 100%; + min-height: 44px; background: var(--color-bg-surface); color: var(--color-text-primary); } @@ -73,6 +74,8 @@ right: 0; margin-top: var(--spacing-xs); z-index: var(--z-dropdown); + max-height: min(260px, 45vh); + box-shadow: var(--shadow-lg); } /* Actions Row */ @@ -81,7 +84,7 @@ align-items: center; justify-content: space-between; gap: var(--spacing-sm); - min-height: 40px; + min-height: 44px; } /* Quantity Control */ @@ -89,11 +92,11 @@ display: flex; align-items: center; gap: var(--spacing-xs); - height: 40px; + height: 44px; } .quantity-btn { - width: 40px; + width: 44px; height: 100%; border: var(--border-width-thin) solid var(--color-border-medium); background: var(--color-bg-surface); @@ -128,8 +131,8 @@ } .add-item-form-quantity-input { - width: 40px; - max-width: 40px; + width: 44px; + max-width: 44px; height: 100%; box-sizing: border-box; padding: var(--input-padding-y) var(--input-padding-x); @@ -159,7 +162,7 @@ /* Submit Button */ .add-item-form-submit { - height: 40px; + min-height: 44px; padding: 0 var(--spacing-lg); background: var(--color-primary); color: var(--color-text-inverse); @@ -197,25 +200,39 @@ /* Responsive */ @media (max-width: 480px) { .add-item-form-container { - padding: var(--spacing-md); + padding: var(--spacing-sm); + } + + .add-item-form-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; } .add-item-form-assignee-toggle { - width: 100px; + width: 104px; } .add-item-form-quantity-control { - height: 36px; + height: 44px; } .quantity-btn { - width: 36px; + width: 44px; height: 100%; font-size: var(--font-size-lg); } .add-item-form-quantity-input, .add-item-form-submit { - height: 36px; + min-height: 44px; + } + + .add-item-form-actions { + gap: var(--spacing-xs); + } + + .add-item-form-submit { + min-width: 0; + padding: 0 var(--spacing-sm); } } diff --git a/frontend/src/styles/components/AddItemWithDetailsModal.css b/frontend/src/styles/components/AddItemWithDetailsModal.css index 9bd4565..cd4f2ed 100644 --- a/frontend/src/styles/components/AddItemWithDetailsModal.css +++ b/frontend/src/styles/components/AddItemWithDetailsModal.css @@ -209,6 +209,10 @@ margin-bottom: var(--spacing-sm); } +.add-item-details-zone-field { + margin-bottom: 0; +} + .add-item-details-field label { display: block; margin: 0; @@ -328,9 +332,17 @@ } @media (max-width: 480px) { + .add-item-details-overlay { + align-items: flex-end; + padding: 0; + } + .add-item-details-modal { padding: var(--spacing-md); - border-radius: var(--border-radius-lg); + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + width: 100%; + max-width: 100%; + max-height: 92vh; } .add-item-details-title { @@ -357,4 +369,8 @@ .add-item-details-field label { font-size: var(--font-size-sm); } + + .add-item-details-field { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/styles/components/EditItemModal.css b/frontend/src/styles/components/EditItemModal.css index 1e3973c..35cb099 100644 --- a/frontend/src/styles/components/EditItemModal.css +++ b/frontend/src/styles/components/EditItemModal.css @@ -118,6 +118,36 @@ margin-bottom: 0; } +.edit-modal-advanced { + margin-top: var(--spacing-sm); + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-md); + background: var(--color-bg-surface); +} + +.edit-modal-advanced summary { + min-height: 40px; + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + display: flex; + align-items: center; +} + +.edit-modal-advanced[open] { + padding-bottom: var(--spacing-sm); +} + +.edit-modal-advanced[open] summary { + margin-bottom: var(--spacing-xs); +} + +.edit-modal-advanced .edit-modal-inline-field { + padding: 0 var(--spacing-md); +} + .edit-modal-divider { height: 1px; background: var(--color-border-light); @@ -235,6 +265,10 @@ width: 100%; } + .edit-modal-advanced .edit-modal-inline-field { + padding: 0 var(--spacing-sm); + } + .edit-modal-actions { flex-direction: column; gap: var(--spacing-xs); @@ -247,10 +281,16 @@ } @media (max-width: 480px) { + .edit-modal-overlay { + align-items: flex-end; + padding: 0; + } + .edit-modal-content { width: 100%; padding: 1rem; - border-radius: 8px; + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; + max-height: 92vh; } .edit-modal-title { diff --git a/frontend/src/styles/pages/GroceryList.css b/frontend/src/styles/pages/GroceryList.css index 7dffa2a..2caee2e 100644 --- a/frontend/src/styles/pages/GroceryList.css +++ b/frontend/src/styles/pages/GroceryList.css @@ -3,6 +3,7 @@ font-family: var(--font-family-base); padding: var(--spacing-sm); background: var(--color-bg-body); + min-height: 100vh; } .glist-container { @@ -14,6 +15,31 @@ box-shadow: var(--shadow-card); } +.glist-offline-banner { + margin: var(--spacing-sm) 0; + padding: var(--spacing-sm) var(--spacing-md); + border: var(--border-width-thin) solid var(--color-warning, #b7791f); + border-radius: var(--border-radius-sm); + background: var(--color-warning-light, #fff8dc); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); +} + +.glist-container .store-tabs { + margin-bottom: var(--spacing-sm); +} + +.glist-container .store-tabs-container { + align-items: stretch; + gap: var(--spacing-xs); +} + +.glist-container .store-selector-trigger, +.glist-container .store-map-button { + min-height: 40px; +} + .glist-section-title { text-align: center; font-size: var(--font-size-xl); @@ -239,7 +265,7 @@ .glist-li { background: var(--color-bg-surface); border: var(--border-width-thin) solid var(--color-border-light); - border-radius: var(--border-radius-lg); + border-radius: var(--border-radius-md); margin-bottom: var(--spacing-sm); cursor: pointer; transition: box-shadow var(--transition-base), transform var(--transition-base); @@ -251,6 +277,17 @@ transform: translateY(-2px); } +.glist-li:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +.glist-li.actions-open { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); +} + .glist-classification-group .glist-li { margin-bottom: var(--spacing-xs); } @@ -261,9 +298,10 @@ .glist-item-layout { display: flex; - gap: 1em; - padding: 0em; + gap: var(--spacing-sm); + padding: var(--spacing-xs); align-items: center; + min-height: 56px; } .glist-item-image { @@ -282,6 +320,13 @@ position: relative; } +.glist-item-image-placeholder { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; +} + .glist-item-image.has-image { border-color: var(--color-primary); background: var(--color-bg-surface); @@ -310,8 +355,9 @@ .glist-item-name { font-weight: 800; - font-size: 0.8em; + font-size: var(--font-size-base); color: var(--color-text-primary); + overflow-wrap: anywhere; } .glist-item-quantity { @@ -333,6 +379,140 @@ font-size: 0.7em; color: var(--color-text-secondary); font-style: italic; + overflow-wrap: anywhere; +} + +.glist-item-action-toggle { + flex: 0 0 auto; + width: 40px; + min-width: 40px; + height: 40px; + border: var(--border-width-thin) solid var(--color-border-light); + border-radius: var(--border-radius-sm); + background: var(--color-bg-hover); + color: var(--color-text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + line-height: 1; + cursor: pointer; +} + +.glist-item-action-toggle:hover, +.glist-item-action-toggle:focus-visible, +.glist-li.actions-open .glist-item-action-toggle { + border-color: var(--color-primary); + color: var(--color-primary); + outline: none; +} + +.glist-item-action-tray { + display: flex; + gap: var(--spacing-xs); + padding: 0 var(--spacing-xs) var(--spacing-xs); + flex-wrap: wrap; +} + +.glist-item-action { + flex: 1 1 5rem; + min-height: 40px; + border: var(--border-width-thin) solid var(--color-border-medium); + border-radius: var(--border-radius-sm); + background: var(--color-bg-surface); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--button-font-weight); + cursor: pointer; +} + +.glist-item-action:hover, +.glist-item-action:focus-visible { + border-color: var(--color-primary); + color: var(--color-primary); + outline: none; +} + +.glist-item-action.secondary:hover, +.glist-item-action.secondary:focus-visible { + border-color: var(--color-warning, #b7791f); + color: var(--color-warning, #b7791f); +} + +.glist-skipped-section { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-md); + border-top: var(--border-width-medium) solid var(--color-border-light); +} + +.glist-skipped-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.glist-skipped-title { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-lg); +} + +.glist-skipped-note { + margin: 0.2rem 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); +} + +.glist-skipped-ul { + margin-top: var(--spacing-xs); +} + +.glist-skipped-li { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + cursor: default; + background: var(--color-bg-hover); + border-style: dashed; +} + +.glist-skipped-li:hover { + transform: none; +} + +.glist-skipped-item-main { + display: flex; + align-items: baseline; + gap: var(--spacing-xs); + min-width: 0; +} + +.glist-skipped-quantity { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); +} + +.glist-restore-btn { + flex: 0 0 auto; + min-height: 40px; + padding: 0 var(--spacing-md); + border: var(--border-width-thin) solid var(--color-primary); + border-radius: var(--border-radius-sm); + background: var(--color-bg-surface); + color: var(--color-primary); + font-size: var(--font-size-sm); + font-weight: var(--button-font-weight); + cursor: pointer; +} + +.glist-restore-btn:hover, +.glist-restore-btn:focus-visible { + background: var(--color-primary); + color: var(--color-text-inverse); + outline: none; } /* Compact View */ @@ -514,8 +694,51 @@ /* Mobile tweaks */ @media (max-width: 480px) { + .glist-body { + padding: var(--spacing-xs); + } + .glist-container { - padding: 1em 0.8em; + padding: var(--spacing-sm); + border-radius: var(--border-radius-md); + } + + .glist-classification-header, + .glist-section-title.clickable { + min-height: 44px; + } + + .glist-container .store-tabs-container { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .glist-container .store-map-button { + grid-column: 1 / -1; + } + + .glist-item-layout { + min-height: 60px; + gap: var(--spacing-xs); + } + + .glist-item-image { + width: 48px; + height: 48px; + min-width: 48px; + } + + .glist-item-name { + font-size: var(--font-size-sm); + } + + .glist-item-action-tray { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .glist-skipped-li { + align-items: stretch; } .glist-fab { diff --git a/frontend/tests/classification-details.spec.ts b/frontend/tests/classification-details.spec.ts index 3f49a7c..e45b7e2 100644 --- a/frontend/tests/classification-details.spec.ts +++ b/frontend/tests/classification-details.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Route } from "@playwright/test"; function seedAuthStorage(page: import("@playwright/test").Page) { return page.addInitScript(() => { @@ -55,13 +55,32 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/stores/household/1", async (route) => { + const stores = [ + { id: 10, name: "Costco", location: "Warehouse", is_default: true }, + ]; + + const fulfillStores = async (route: Route) => { await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify([ - { id: 10, name: "Costco", location: "Warehouse", is_default: true }, - ]), + body: JSON.stringify(stores), + }); + }; + + await page.route("**/stores/household/1", fulfillStores); + await page.route("**/households/1/stores", fulfillStores); + + await page.route("**/households/1/locations/10/zones", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + zones: [ + { id: 1, name: "Dairy & Refrigerated" }, + { id: 2, name: "Checkout Area" }, + { id: 3, name: "Bakery" }, + ], + }), }); }); @@ -75,7 +94,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/households/1/stores/10/list/recent", async (route) => { + await page.route("**/households/1/locations/10/list/recent", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", @@ -83,7 +102,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/households/1/stores/10/list/suggestions**", async (route) => { + await page.route("**/households/1/locations/10/list/suggestions**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", @@ -91,7 +110,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/households/1/stores/10/list/classification**", async (route) => { + await page.route("**/households/1/locations/10/list/classification**", async (route) => { const request = route.request(); if (request.method() === "GET") { @@ -146,7 +165,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/households/1/stores/10/list/item**", async (route) => { + await page.route("**/households/1/locations/10/list/item**", async (route) => { const request = route.request(); if (request.method() === "PUT") { @@ -185,7 +204,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/households/1/stores/10/list/add", async (route) => { + await page.route("**/households/1/locations/10/list/add", async (route) => { currentItem = { id: 201, item_id: 501, @@ -216,7 +235,7 @@ async function setupGroceryListRoutes(page: import("@playwright/test").Page) { }); }); - await page.route("**/households/1/stores/10/list", async (route) => { + await page.route("**/households/1/locations/10/list", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", @@ -240,17 +259,11 @@ async function openEditModal(itemRow: ReturnType { +test("add-details modal persists zone-first classification details", async ({ page }) => { await seedAuthStorage(page); await mockConfig(page); await setupGroceryListRoutes(page); - let dialogSeen = false; - page.on("dialog", async (dialog) => { - dialogSeen = true; - await dialog.dismiss(); - }); - await page.goto("/"); await page.getByPlaceholder("Enter item name").fill("yogurt"); @@ -259,14 +272,7 @@ test("add-details modal validates with toasts and persists classification detail const addDetailsModal = page.locator(".add-item-details-modal"); await expect(addDetailsModal).toBeVisible(); - await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy"); - await addDetailsModal.getByRole("button", { name: "Add Item" }).click(); - - await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group"); - expect(dialogSeen).toBe(false); - - await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk"); - await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated"); + await addDetailsModal.locator(".add-item-details-select").selectOption("Dairy & Refrigerated"); await addDetailsModal.getByRole("button", { name: "Add Item" }).click(); const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" }); @@ -278,9 +284,10 @@ test("add-details modal validates with toasts and persists classification detail await openEditModal(yogurtRow, page); const editModal = page.locator(".edit-modal-content"); - await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy"); - await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk"); - await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated"); + await expect( + editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select") + ).toHaveValue("Dairy & Refrigerated"); + await expect(editModal.locator(".edit-modal-advanced")).toBeVisible(); }); test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => { @@ -292,7 +299,7 @@ test("edit modal supports zone-only updates and shows API error toasts", async ( await page.getByPlaceholder("Enter item name").fill("yogurt"); await page.getByRole("button", { name: "Create + Add" }).click(); - await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click(); + await page.locator(".add-item-details-modal").getByRole("button", { name: "Add Item" }).click(); const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" }); await expect(yogurtRow).toBeVisible(); @@ -300,8 +307,8 @@ test("edit modal supports zone-only updates and shows API error toasts", async ( await openEditModal(yogurtRow, page); let editModal = page.locator(".edit-modal-content"); - await editModal.locator(".edit-modal-select").nth(0).selectOption(""); - await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area"); + const zoneSelect = editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select"); + await zoneSelect.selectOption("Checkout Area"); await editModal.getByRole("button", { name: "Save Changes" }).click(); await expect( @@ -311,11 +318,12 @@ test("edit modal supports zone-only updates and shows API error toasts", async ( await openEditModal(yogurtRow, page); editModal = page.locator(".edit-modal-content"); - await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue(""); - await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area"); + await expect( + editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select") + ).toHaveValue("Checkout Area"); routes.setClassificationRequestMode("error"); - await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery"); + await editModal.locator(".edit-modal-inline-field", { hasText: "Zone" }).locator("select").selectOption("Bakery"); await editModal.getByRole("button", { name: "Save Changes" }).click(); await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone"); diff --git a/frontend/tests/grocery-list-actions.spec.ts b/frontend/tests/grocery-list-actions.spec.ts new file mode 100644 index 0000000..c7e27da --- /dev/null +++ b/frontend/tests/grocery-list-actions.spec.ts @@ -0,0 +1,174 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + mockConfig, + mockHouseholdAndStoreShell, + seedAuthStorage, +} from "./helpers/e2e"; + +type MockItem = { + id: number; + item_id: number; + item_name: string; + quantity: number; + bought: boolean; + item_image: string | null; + image_mime_type: string | null; + added_by_users: string[]; + last_added_on: string; + item_type: string | null; + item_group: string | null; + zone: string | null; +}; + +function makeItem(id: number, itemName: string, quantity = 1): MockItem { + return { + id, + item_id: id + 500, + item_name: itemName, + quantity, + bought: false, + item_image: null, + image_mime_type: null, + added_by_users: ["Owner User"], + last_added_on: "2026-03-28T12:00:00.000Z", + item_type: null, + item_group: null, + zone: "Produce", + }; +} + +async function setupGroceryRoutes( + page: Page, + options: { + householdRole?: string; + items?: MockItem[]; + } = {} +) { + const items = options.items || [ + makeItem(1, "milk", 2), + makeItem(2, "bread", 1), + ]; + + await mockConfig(page); + await mockHouseholdAndStoreShell(page, { + household: { + name: "Grocery Actions House", + role: options.householdRole || "admin", + }, + stores: [ + { + id: 10, + name: "Costco", + location: "Warehouse", + is_default: true, + }, + ], + }); + + await page.route("**/households/1/members", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" }, + ]), + }); + }); + + await page.route("**/households/1/locations/10/zones", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ zones: [{ id: 1, name: "Produce" }] }), + }); + }); + + await page.route("**/households/1/locations/10/list/recent", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/locations/10/list/suggestions**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/households/1/locations/10/list/item**", async (route) => { + const url = new URL(route.request().url()); + const itemName = (url.searchParams.get("item_name") || "").toLowerCase(); + const item = items.find((candidate) => candidate.item_name === itemName); + + await route.fulfill({ + status: item ? 200 : 404, + contentType: "application/json", + body: JSON.stringify(item || { message: "Item not found" }), + }); + }); + + await page.route("**/households/1/locations/10/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items }), + }); + }); +} + +test("item actions expose bought flow and local skipped restore", async ({ page }) => { + await seedAuthStorage(page, { username: "grocery-actions-user" }); + await setupGroceryRoutes(page); + + await page.goto("/"); + + const milkRow = page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" }); + await expect(milkRow).toBeVisible(); + + await milkRow.getByRole("button", { name: "Actions for milk" }).click(); + await milkRow.getByRole("button", { name: "Bought" }).click(); + await expect(page.locator(".confirm-buy-modal")).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + + await milkRow.getByRole("button", { name: "Actions for milk" }).click(); + await milkRow.getByRole("button", { name: "Skip" }).click(); + + await expect(milkRow).toHaveCount(0); + await expect(page.locator(".glist-skipped-section")).toContainText("milk"); + + await page.reload(); + await expect(page.locator(".glist-skipped-section")).toContainText("milk"); + + await page.locator(".glist-skipped-section").getByRole("button", { name: "Restore" }).click(); + await expect(page.locator(".glist-classification-group .glist-li").filter({ hasText: "milk" })).toBeVisible(); +}); + +test("viewer role cannot see mutation controls", async ({ page }) => { + await seedAuthStorage(page, { username: "grocery-viewer-user" }); + await setupGroceryRoutes(page, { householdRole: "viewer" }); + + await page.goto("/"); + + await expect(page.getByPlaceholder("Enter item name")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Actions for milk" })).toHaveCount(0); + await expect(page.getByLabel("Search list")).toBeVisible(); +}); + +test("offline banner blocks server-backed add flow with a clear toast", async ({ page }) => { + await seedAuthStorage(page, { username: "grocery-offline-user" }); + await setupGroceryRoutes(page); + + await page.goto("/"); + await page.evaluate(() => window.dispatchEvent(new Event("offline"))); + + await expect(page.locator(".glist-offline-banner")).toContainText("Offline"); + await page.getByPlaceholder("Enter item name").fill("bananas"); + await page.getByRole("button", { name: "Create + Add" }).click(); + + await expect(page.locator(".add-item-details-modal")).toHaveCount(0); + await expect(page.locator(".action-toast.action-toast-error")).toContainText("Adding items needs a connection"); +}); diff --git a/frontend/tests/helpers/e2e.ts b/frontend/tests/helpers/e2e.ts index 1f4cbc2..fb3833f 100644 --- a/frontend/tests/helpers/e2e.ts +++ b/frontend/tests/helpers/e2e.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Page, type Route } from "@playwright/test"; type AuthSeed = { token?: string; @@ -69,13 +69,16 @@ export async function mockHouseholdAndStoreShell( }); }); - await page.route(`**/stores/household/${household.id}`, async (route) => { + const fulfillStores = async (route: Route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(stores), }); - }); + }; + + await page.route(`**/stores/household/${household.id}`, fulfillStores); + await page.route(`**/households/${household.id}/stores`, fulfillStores); } export async function confirmSlide(page: Page) {