chore: harden reliability checks #2
@ -155,6 +155,7 @@ For `app/api/**/[param]/route.ts`:
|
|||||||
- Tap targets remain >= 40px on mobile.
|
- Tap targets remain >= 40px on mobile.
|
||||||
- Modal overlays must close on outside click/tap.
|
- 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).
|
- 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.
|
- 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.
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
|
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export default function ManageStores() {
|
|||||||
<section className="manage-section">
|
<section className="manage-section">
|
||||||
<h2>Your Stores ({householdStores.length})</h2>
|
<h2>Your Stores ({householdStores.length})</h2>
|
||||||
<p className="manage-stores-help">
|
<p className="manage-stores-help">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
{!isAdmin && (
|
{!isAdmin && (
|
||||||
<p className="manage-stores-note">
|
<p className="manage-stores-note">
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
|
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
|
||||||
|
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||||
|
|
||||||
function itemImageSource(item) {
|
function itemImageSource(item) {
|
||||||
if (!item?.item_image) {
|
if (!item?.item_image) {
|
||||||
@ -19,7 +20,7 @@ function itemImageSource(item) {
|
|||||||
|
|
||||||
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
|
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [catalogReady, setCatalogReady] = useState(true);
|
const [catalogReady, setCatalogReady] = useState(true);
|
||||||
const [catalogMessage, setCatalogMessage] = useState("");
|
const [catalogMessage, setCatalogMessage] = useState("");
|
||||||
@ -27,6 +28,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editorItem, setEditorItem] = useState(null);
|
const [editorItem, setEditorItem] = useState(null);
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
|
||||||
|
|
||||||
const loadItems = useCallback(async (search = query) => {
|
const loadItems = useCallback(async (search = query) => {
|
||||||
if (!householdId || !store?.id) {
|
if (!householdId || !store?.id) {
|
||||||
@ -41,10 +43,10 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
setCatalogReady(response.data.catalog_ready !== false);
|
setCatalogReady(response.data.catalog_ready !== false);
|
||||||
setCatalogMessage(response.data.message || "");
|
setCatalogMessage(response.data.message || "");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load available items:", error);
|
console.error("Failed to load store items:", error);
|
||||||
setCatalogReady(false);
|
setCatalogReady(false);
|
||||||
setCatalogMessage("Store item catalog is unavailable right now.");
|
setCatalogMessage("Store item management is unavailable right now.");
|
||||||
const message = getApiErrorMessage(error, "Failed to load available items");
|
const message = getApiErrorMessage(error, "Failed to load store items");
|
||||||
toast.error("Load store items failed", `Load store items failed: ${message}`);
|
toast.error("Load store items failed", `Load store items failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -52,18 +54,23 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
}, [householdId, query, store?.id, toast]);
|
}, [householdId, query, store?.id, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded) {
|
if (!isOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadItems(query);
|
loadItems(query);
|
||||||
}, [expanded, query, loadItems]);
|
}, [isOpen, query, loadItems]);
|
||||||
|
|
||||||
|
const closeManager = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setPendingDeleteItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdate = async (payload) => {
|
const handleUpdate = async (payload) => {
|
||||||
if (!catalogReady) {
|
if (!catalogReady) {
|
||||||
toast.info(
|
toast.info(
|
||||||
"Store item catalog unavailable",
|
"Store item management unavailable",
|
||||||
catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."
|
catalogMessage || "Store item management is unavailable until the latest database migration is applied."
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -75,32 +82,25 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
setEditorItem(null);
|
setEditorItem(null);
|
||||||
await loadItems(query);
|
await loadItems(query);
|
||||||
} catch (error) {
|
} 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}`);
|
toast.error("Update store item failed", `Update store item failed: ${message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (item) => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!catalogReady) {
|
if (!pendingDeleteItem) {
|
||||||
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?`)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAvailableItem(householdId, store.id, item.item_id);
|
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
||||||
toast.success("Cleared store item settings", `Cleared settings for ${item.item_name} in ${store.name}`);
|
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`);
|
||||||
|
setPendingDeleteItem(null);
|
||||||
await loadItems(query);
|
await loadItems(query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to clear store item settings");
|
const message = getApiErrorMessage(error, "Failed to delete store item");
|
||||||
toast.error("Clear store item settings failed", `Clear store item settings failed: ${message}`);
|
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -109,90 +109,123 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="store-available-items">
|
<>
|
||||||
<div className="store-available-items-header">
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<h4>Store Item Catalog</h4>
|
className="btn-secondary btn-small store-available-items-trigger"
|
||||||
<p>Manage settings for items already used in {store.name} for this household.</p>
|
onClick={() => setIsOpen(true)}
|
||||||
</div>
|
>
|
||||||
<button
|
Manage Items
|
||||||
type="button"
|
</button>
|
||||||
className="btn-secondary btn-small"
|
|
||||||
onClick={() => setExpanded((value) => !value)}
|
|
||||||
>
|
|
||||||
{expanded ? "Hide Items" : "Manage Items"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded ? (
|
{isOpen ? (
|
||||||
<div className="store-available-items-panel">
|
<div className="store-items-modal-overlay" onClick={closeManager}>
|
||||||
{!catalogReady ? (
|
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<p className="store-available-items-notice">
|
<div className="store-items-modal-header">
|
||||||
{catalogMessage || "Store item catalog is unavailable until the latest database migration is applied."}
|
<div>
|
||||||
</p>
|
<h3>{store.name} Items</h3>
|
||||||
) : null}
|
<p>Manage the household/store items used for suggestions and store defaults.</p>
|
||||||
<div className="store-available-items-toolbar">
|
</div>
|
||||||
<input
|
<button
|
||||||
className="store-available-items-search"
|
type="button"
|
||||||
value={query}
|
className="store-items-modal-close"
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onClick={closeManager}
|
||||||
placeholder="Search household/store items"
|
aria-label="Close manage items modal"
|
||||||
disabled={!catalogReady}
|
>
|
||||||
/>
|
x
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{!catalogReady ? (
|
|
||||||
<p className="empty-message">Run the latest database migrations to enable this catalog.</p>
|
|
||||||
) : loading ? (
|
|
||||||
<p className="empty-message">Loading store items...</p>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="empty-message">No household items found for this store yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="store-available-items-list">
|
|
||||||
{items.map((item) => {
|
|
||||||
const imageSrc = itemImageSource(item);
|
|
||||||
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
|
|
||||||
return (
|
|
||||||
<div key={item.item_id} className="store-available-items-card">
|
|
||||||
<div className="store-available-items-summary">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
|
||||||
) : (
|
|
||||||
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
|
|
||||||
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="store-available-items-copy">
|
|
||||||
<strong>{item.item_name}</strong>
|
|
||||||
<span>{details.join(" | ") || "No store defaults set"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="store-available-items-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary btn-small"
|
|
||||||
onClick={() => {
|
|
||||||
setEditorItem(item);
|
|
||||||
setShowEditor(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{item.has_managed_settings ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-danger btn-small"
|
|
||||||
onClick={() => handleDelete(item)}
|
|
||||||
>
|
|
||||||
Clear Settings
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{!catalogReady ? (
|
||||||
|
<p className="store-available-items-notice">
|
||||||
|
{catalogMessage || "Store item management is unavailable until the latest database migration is applied."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="store-items-modal-toolbar">
|
||||||
|
<input
|
||||||
|
className="store-available-items-search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search household/store items"
|
||||||
|
disabled={!catalogReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="store-items-modal-body">
|
||||||
|
{!catalogReady ? (
|
||||||
|
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
|
||||||
|
) : loading ? (
|
||||||
|
<p className="empty-message">Loading store items...</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="empty-message">No household items found for this store yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="store-items-table">
|
||||||
|
<div className="store-items-table-head" aria-hidden="true">
|
||||||
|
<span>Item</span>
|
||||||
|
<span>Store Defaults</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</div>
|
||||||
|
<div className="store-items-table-body">
|
||||||
|
{items.map((item) => {
|
||||||
|
const imageSrc = itemImageSource(item);
|
||||||
|
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.item_id} className="store-items-table-row">
|
||||||
|
<div className="store-items-table-cell store-items-table-item">
|
||||||
|
<span className="store-items-mobile-label">Item</span>
|
||||||
|
<div className="store-available-items-summary">
|
||||||
|
{imageSrc ? (
|
||||||
|
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
||||||
|
) : (
|
||||||
|
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
|
||||||
|
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="store-available-items-copy">
|
||||||
|
<strong>{item.item_name}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="store-items-table-cell">
|
||||||
|
<span className="store-items-mobile-label">Store Defaults</span>
|
||||||
|
<span className="store-items-defaults-text">
|
||||||
|
{details.join(" | ") || "No store defaults set"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="store-items-table-cell store-items-table-actions">
|
||||||
|
<span className="store-items-mobile-label">Actions</span>
|
||||||
|
<div className="store-available-items-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary btn-small"
|
||||||
|
onClick={() => {
|
||||||
|
setEditorItem(item);
|
||||||
|
setShowEditor(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger btn-small"
|
||||||
|
onClick={() => setPendingDeleteItem(item)}
|
||||||
|
>
|
||||||
|
Delete Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -205,6 +238,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
}}
|
}}
|
||||||
onSave={handleUpdate}
|
onSave={handleUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<ConfirmSlideModal
|
||||||
|
isOpen={Boolean(pendingDeleteItem)}
|
||||||
|
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
||||||
|
description={
|
||||||
|
pendingDeleteItem
|
||||||
|
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
confirmLabel="Delete Item"
|
||||||
|
onClose={() => setPendingDeleteItem(null)}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,10 +101,10 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
|
|||||||
<div className="available-item-editor-overlay" onClick={onCancel}>
|
<div className="available-item-editor-overlay" onClick={onCancel}>
|
||||||
<div className="available-item-editor-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="available-item-editor-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
<h2 className="available-item-editor-title">
|
<h2 className="available-item-editor-title">
|
||||||
{item ? `Edit ${item.item_name}` : "Add Available Item"}
|
{item ? `Edit ${item.item_name}` : "Edit Store Item"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="available-item-editor-subtitle">
|
<p className="available-item-editor-subtitle">
|
||||||
Save store-specific item defaults for this household.
|
Save store-specific defaults for this household/store item.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="available-item-editor-field">
|
<div className="available-item-editor-field">
|
||||||
|
|||||||
@ -1,32 +1,75 @@
|
|||||||
.store-available-items {
|
.store-available-items-trigger {
|
||||||
border-top: var(--border-width-thin) solid var(--color-border-light);
|
width: 100%;
|
||||||
padding-top: var(--spacing-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-header h4 {
|
.store-items-modal-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-primary);
|
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;
|
margin: var(--spacing-xs) 0 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-panel {
|
.store-items-modal-close {
|
||||||
margin-top: var(--spacing-md);
|
width: 40px;
|
||||||
display: flex;
|
height: 40px;
|
||||||
flex-direction: column;
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
gap: var(--spacing-md);
|
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 {
|
.store-available-items-notice {
|
||||||
@ -38,45 +81,58 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-toolbar {
|
.store-items-modal-body {
|
||||||
display: flex;
|
min-height: 0;
|
||||||
gap: var(--spacing-sm);
|
overflow-y: auto;
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-search {
|
.store-items-table {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-card {
|
.store-items-table-head,
|
||||||
display: flex;
|
.store-items-table-row {
|
||||||
justify-content: space-between;
|
display: grid;
|
||||||
gap: var(--spacing-sm);
|
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
|
||||||
|
gap: var(--spacing-md);
|
||||||
align-items: center;
|
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);
|
padding: var(--spacing-sm);
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-items-table-cell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-items-table-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.store-available-items-summary {
|
.store-available-items-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -110,34 +166,68 @@
|
|||||||
|
|
||||||
.store-available-items-copy strong {
|
.store-available-items-copy strong {
|
||||||
color: var(--color-text-primary);
|
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);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-items-table-actions {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
.store-available-items-actions {
|
.store-available-items-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.store-items-mobile-label {
|
||||||
.store-available-items-header,
|
display: none;
|
||||||
.store-available-items-card,
|
}
|
||||||
.store-available-items-toolbar {
|
|
||||||
|
@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;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-actions,
|
.store-items-mobile-label {
|
||||||
.store-available-items-toolbar-actions {
|
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%;
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-actions button,
|
.store-available-items-actions button {
|
||||||
.store-available-items-toolbar-actions button {
|
flex: 1 1 0;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 seedAuthStorage(page);
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await mockHouseholdAndStoreShell(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) => {
|
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
availableItems = availableItems.map((item) =>
|
availableItems = availableItems.filter((item) => item.item_id !== 501);
|
||||||
item.item_id === 501
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
has_managed_settings: false,
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
);
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ message: "Store item settings cleared" }),
|
body: JSON.stringify({ message: "Store item deleted" }),
|
||||||
});
|
});
|
||||||
return;
|
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" });
|
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
|
||||||
await expect(storeCard).toBeVisible();
|
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 storeCard.getByRole("button", { name: "Manage Items" }).click();
|
||||||
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.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");
|
const editorModal = page.locator(".available-item-editor-modal");
|
||||||
await expect(editorModal).toBeVisible();
|
await expect(editorModal).toBeVisible();
|
||||||
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
|
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
|
||||||
@ -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 editorModal.getByRole("button", { name: "Save Changes" }).click();
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
|
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
|
||||||
await expect(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 managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
|
||||||
await storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" }).click();
|
const confirmModal = page.locator(".confirm-slide-modal");
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Cleared store item settings");
|
await expect(confirmModal).toBeVisible();
|
||||||
await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByText("No store defaults set")).toBeVisible();
|
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
||||||
await expect(storeCard.locator(".store-available-items-card").filter({ hasText: "milk" }).getByRole("button", { name: "Clear Settings" })).toHaveCount(0);
|
|
||||||
|
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 }) => {
|
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user