fix(ui): restore classification detail modal flow
This commit is contained in:
parent
033dd5dc33
commit
104519668a
@ -63,14 +63,40 @@ export const setClassification = (householdId, storeId, itemName, classification
|
|||||||
classification
|
classification
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizeClassificationPayload(classification) {
|
||||||
|
if (!classification) return null;
|
||||||
|
if (typeof classification === "string") {
|
||||||
|
return classification.trim() || null;
|
||||||
|
}
|
||||||
|
if (typeof classification !== "object" || Array.isArray(classification)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
item_type: typeof classification.item_type === "string" && classification.item_type.trim()
|
||||||
|
? classification.item_type.trim()
|
||||||
|
: null,
|
||||||
|
item_group: typeof classification.item_group === "string" && classification.item_group.trim()
|
||||||
|
? classification.item_group.trim()
|
||||||
|
: null,
|
||||||
|
zone: typeof classification.zone === "string" && classification.zone.trim()
|
||||||
|
? classification.zone.trim()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return payload.item_type || payload.item_group || payload.zone ? payload : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update item with classification (legacy method - split into separate calls)
|
* Update item with optional classification details.
|
||||||
*/
|
*/
|
||||||
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
||||||
// This is now two operations: update item + set classification
|
const normalizedClassification = normalizeClassificationPayload(classification);
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
updateItem(householdId, storeId, itemName, quantity),
|
updateItem(householdId, storeId, itemName, quantity),
|
||||||
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
|
normalizedClassification
|
||||||
|
? setClassification(householdId, storeId, itemName, normalizedClassification)
|
||||||
|
: Promise.resolve()
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "../../styles/components/AddItemWithDetailsModal.css";
|
import "../../styles/components/AddItemWithDetailsModal.css";
|
||||||
import ClassificationSection from "../forms/ClassificationSection";
|
import ClassificationSection from "../forms/ClassificationSection";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import ImageUploadSection from "../forms/ImageUploadSection";
|
import ImageUploadSection from "../forms/ImageUploadSection";
|
||||||
|
|
||||||
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||||
|
const toast = useActionToast();
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
@ -30,15 +32,15 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
// Validate classification if provided
|
|
||||||
if (itemType && !itemGroup) {
|
if (itemType && !itemGroup) {
|
||||||
alert("Please select an item group");
|
toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = itemType ? {
|
const hasClassificationDetails = Boolean(itemType || itemGroup || zone);
|
||||||
|
const classification = hasClassificationDetails ? {
|
||||||
item_type: itemType,
|
item_type: itemType,
|
||||||
item_group: itemGroup,
|
item_group: itemGroup || null,
|
||||||
zone: zone || null
|
zone: zone || null
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
|
||||||
import "../../styles/components/EditItemModal.css";
|
import "../../styles/components/EditItemModal.css";
|
||||||
import AddImageModal from "./AddImageModal";
|
import AddImageModal from "./AddImageModal";
|
||||||
|
|
||||||
@ -15,50 +14,54 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showImageModal, setShowImageModal] = useState(false);
|
const [showImageModal, setShowImageModal] = useState(false);
|
||||||
|
|
||||||
// Load existing classification
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item.classification) {
|
if (item.classification) {
|
||||||
setItemType(item.classification.item_type || "");
|
setItemType(item.classification.item_type || "");
|
||||||
setItemGroup(item.classification.item_group || "");
|
setItemGroup(item.classification.item_group || "");
|
||||||
setZone(item.classification.zone || "");
|
setZone(item.classification.zone || "");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setItemType("");
|
||||||
|
setItemGroup("");
|
||||||
|
setZone("");
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const handleItemTypeChange = (newType) => {
|
const handleItemTypeChange = (newType) => {
|
||||||
setItemType(newType);
|
setItemType(newType);
|
||||||
setItemGroup(""); // Reset group when type changes
|
setItemGroup("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!itemName.trim()) {
|
if (!itemName.trim()) {
|
||||||
alert("Item name is required");
|
toast.error("Save item failed", "Save item failed: Item name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity < 1) {
|
if (quantity < 1) {
|
||||||
alert("Quantity must be at least 1");
|
toast.error("Save item failed", "Save item failed: Quantity must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If classification fields are filled, validate them
|
|
||||||
if (itemType && !itemGroup) {
|
if (itemType && !itemGroup) {
|
||||||
alert("Please select an item group");
|
toast.error("Save item failed", `Save item failed: Select an item group for ${itemName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const classification = itemType ? {
|
const hasClassificationDetails = Boolean(itemType || itemGroup || zone);
|
||||||
item_type: itemType,
|
const classification = hasClassificationDetails
|
||||||
item_group: itemGroup,
|
? {
|
||||||
zone: zone || null
|
item_type: itemType,
|
||||||
} : null;
|
item_group: itemGroup || null,
|
||||||
|
zone: zone || null
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
await onSave(item.id, itemName, quantity, classification);
|
await onSave(item.id, itemName, quantity, classification);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save:", error);
|
console.error("Failed to save:", error);
|
||||||
const message = getApiErrorMessage(error, "Failed to save changes");
|
|
||||||
toast.error("Save item failed", `Save item failed: ${message}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -71,18 +74,18 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
setShowImageModal(false);
|
setShowImageModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload image:", error);
|
console.error("Failed to upload image:", error);
|
||||||
const message = getApiErrorMessage(error, "Failed to upload image");
|
const message = error?.response?.data?.error?.message || error?.response?.data?.message || "Failed to upload image";
|
||||||
toast.error("Upload image failed", `Upload image failed: ${message}`);
|
toast.error("Upload image failed", `Upload image failed: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const incrementQuantity = () => {
|
const incrementQuantity = () => {
|
||||||
setQuantity(prev => prev + 1);
|
setQuantity((prev) => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const decrementQuantity = () => {
|
const decrementQuantity = () => {
|
||||||
setQuantity(prev => Math.max(1, prev - 1));
|
setQuantity((prev) => Math.max(1, prev - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
||||||
@ -92,7 +95,6 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="edit-modal-title">Edit Item</h2>
|
<h2 className="edit-modal-title">Edit Item</h2>
|
||||||
|
|
||||||
{/* Item Name - no label */}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={itemName}
|
value={itemName}
|
||||||
@ -101,7 +103,6 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
placeholder="Item name"
|
placeholder="Item name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Quantity Control - like AddItemForm */}
|
|
||||||
<div className="edit-modal-quantity-control">
|
<div className="edit-modal-quantity-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -109,7 +110,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
onClick={decrementQuantity}
|
onClick={decrementQuantity}
|
||||||
disabled={quantity <= 1}
|
disabled={quantity <= 1}
|
||||||
>
|
>
|
||||||
−
|
-
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -129,7 +130,6 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
{/* Inline Classification Fields */}
|
|
||||||
<div className="edit-modal-inline-field">
|
<div className="edit-modal-inline-field">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select
|
<select
|
||||||
@ -172,9 +172,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
className="edit-modal-select"
|
className="edit-modal-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">-- Select Zone --</option>
|
||||||
{getZoneValues().map((z) => (
|
{getZoneValues().map((candidateZone) => (
|
||||||
<option key={z} value={z}>
|
<option key={candidateZone} value={candidateZone}>
|
||||||
{z}
|
{candidateZone}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -188,7 +188,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{item.item_image ? "🖼️ Change Image" : "📷 Set Image"}
|
{item.item_image ? "Change Image" : "Set Image"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="edit-modal-actions">
|
<div className="edit-modal-actions">
|
||||||
|
|||||||
@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
.add-item-details-image-options {
|
.add-item-details-image-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.8em;
|
gap: var(--spacing-sm);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +69,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
font-size: 0.95em;
|
font-size: var(--font-size-base);
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
@ -101,97 +101,99 @@
|
|||||||
|
|
||||||
.add-item-details-remove-image {
|
.add-item-details-remove-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5em;
|
top: var(--spacing-sm);
|
||||||
right: 0.5em;
|
right: var(--spacing-sm);
|
||||||
background: rgba(220, 53, 69, 0.9);
|
background: var(--color-danger);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--border-radius-md);
|
||||||
padding: 0.4em 0.8em;
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-semibold);
|
||||||
font-size: 0.9em;
|
font-size: var(--font-size-sm);
|
||||||
transition: background 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-remove-image:hover {
|
.add-item-details-remove-image:hover {
|
||||||
background: rgba(220, 53, 69, 1);
|
background: var(--color-danger-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Classification Section */
|
/* Classification Section */
|
||||||
.add-item-details-field {
|
.add-item-details-field {
|
||||||
margin-bottom: 1em;
|
margin-bottom: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4em;
|
margin-bottom: var(--spacing-sm);
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-semibold);
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
font-size: 0.9em;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select {
|
.add-item-details-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6em;
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
border: 1px solid #ccc;
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
border-radius: 6px;
|
border-radius: var(--input-border-radius);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: border-color 0.2s;
|
transition: var(--transition-base);
|
||||||
background: white;
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select:focus {
|
.add-item-details-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: var(--input-focus-border-color);
|
||||||
|
box-shadow: var(--input-focus-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
.add-item-details-actions {
|
.add-item-details-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6em;
|
gap: var(--spacing-sm);
|
||||||
margin-top: 1.5em;
|
margin-top: var(--spacing-lg);
|
||||||
padding-top: 1em;
|
padding-top: var(--spacing-md);
|
||||||
border-top: 1px solid #e0e0e0;
|
border-top: var(--border-width-thin) solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn {
|
.add-item-details-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.7em;
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--button-border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: var(--button-font-weight);
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.cancel {
|
.add-item-details-btn.cancel {
|
||||||
background: #6c757d;
|
background: var(--color-secondary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.cancel:hover {
|
.add-item-details-btn.cancel:hover {
|
||||||
background: #5a6268;
|
background: var(--color-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.skip {
|
.add-item-details-btn.skip {
|
||||||
background: #ffc107;
|
background: var(--color-warning);
|
||||||
color: #333;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.skip:hover {
|
.add-item-details-btn.skip:hover {
|
||||||
background: #e0a800;
|
background: var(--color-warning-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.confirm {
|
.add-item-details-btn.confirm {
|
||||||
background: #007bff;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: var(--color-text-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.confirm:hover {
|
.add-item-details-btn.confirm:hover {
|
||||||
background: #0056b3;
|
background: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
/* Mobile responsiveness */
|
||||||
@ -207,7 +209,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: 1.3em;
|
font-size: var(--font-size-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select {
|
.add-item-details-select {
|
||||||
@ -236,20 +238,20 @@
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
padding: 1rem;
|
padding: var(--spacing-md);
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: 1.15em;
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-subtitle {
|
.add-item-details-subtitle {
|
||||||
font-size: 0.85em;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section-title {
|
.add-item-details-section-title {
|
||||||
font-size: 1em;
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-options {
|
.add-item-details-image-options {
|
||||||
@ -258,10 +260,10 @@
|
|||||||
|
|
||||||
.add-item-details-image-btn {
|
.add-item-details-image-btn {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
font-size: 0.9em;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
font-size: 0.85em;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
318
frontend/tests/classification-details.spec.ts
Normal file
318
frontend/tests/classification-details.spec.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
function seedAuthStorage(page: import("@playwright/test").Page) {
|
||||||
|
return page.addInitScript(() => {
|
||||||
|
localStorage.setItem("token", "test-token");
|
||||||
|
localStorage.setItem("userId", "1");
|
||||||
|
localStorage.setItem("role", "admin");
|
||||||
|
localStorage.setItem("username", "classification-user");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockConfig(page: import("@playwright/test").Page) {
|
||||||
|
await page.route("**/config", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
maxFileSizeMB: 20,
|
||||||
|
maxImageDimension: 800,
|
||||||
|
imageQuality: 85,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
||||||
|
let currentItem: {
|
||||||
|
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;
|
||||||
|
} | null = null;
|
||||||
|
let currentClassification: {
|
||||||
|
item_type: string | null;
|
||||||
|
item_group: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
} | null = null;
|
||||||
|
let classificationRequestMode: "success" | "error" = "success";
|
||||||
|
|
||||||
|
await page.route("**/households", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ id: 1, name: "Classification House", role: "admin", invite_code: "ABCD1234" },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/stores/household/1", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ 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/stores/10/list/recent", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
|
||||||
|
if (request.method() === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ classification: currentClassification }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.postDataJSON() as {
|
||||||
|
classification?: string | { item_type?: string | null; item_group?: string | null; zone?: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (classificationRequestMode === "error") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 400,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: { message: "Invalid zone" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = typeof body.classification === "string"
|
||||||
|
? { item_type: body.classification, item_group: null, zone: null }
|
||||||
|
: {
|
||||||
|
item_type: body.classification?.item_type ?? null,
|
||||||
|
item_group: body.classification?.item_group ?? null,
|
||||||
|
zone: body.classification?.zone ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentClassification = payload;
|
||||||
|
if (currentItem) {
|
||||||
|
currentItem = {
|
||||||
|
...currentItem,
|
||||||
|
item_type: payload.item_type,
|
||||||
|
item_group: payload.item_group,
|
||||||
|
zone: payload.zone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Classification set",
|
||||||
|
classification: payload,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
|
||||||
|
if (request.method() === "PUT") {
|
||||||
|
const body = request.postDataJSON() as { item_name?: string; quantity?: number };
|
||||||
|
if (currentItem) {
|
||||||
|
currentItem = {
|
||||||
|
...currentItem,
|
||||||
|
item_name: String(body.item_name || currentItem.item_name).toLowerCase(),
|
||||||
|
quantity: Number(body.quantity || currentItem.quantity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Item updated",
|
||||||
|
item: {
|
||||||
|
id: currentItem?.id || 201,
|
||||||
|
item_name: currentItem?.item_name || "yogurt",
|
||||||
|
quantity: currentItem?.quantity || 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
||||||
|
const itemMatches = currentItem && currentItem.item_name === itemName;
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: itemMatches ? 200 : 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(itemMatches ? currentItem : { message: "Item not found" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list/add", async (route) => {
|
||||||
|
currentItem = {
|
||||||
|
id: 201,
|
||||||
|
item_id: 501,
|
||||||
|
item_name: "yogurt",
|
||||||
|
quantity: 1,
|
||||||
|
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: currentClassification?.item_type ?? null,
|
||||||
|
item_group: currentClassification?.item_group ?? null,
|
||||||
|
zone: currentClassification?.zone ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Item added",
|
||||||
|
item: {
|
||||||
|
id: 201,
|
||||||
|
item_name: "yogurt",
|
||||||
|
quantity: 1,
|
||||||
|
bought: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/households/1/stores/10/list", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: currentItem ? [currentItem] : [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
setClassificationRequestMode(mode: "success" | "error") {
|
||||||
|
classificationRequestMode = mode;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditModal(itemRow: ReturnType<import("@playwright/test").Page["locator"]>, page: import("@playwright/test").Page) {
|
||||||
|
await itemRow.dispatchEvent("mousedown");
|
||||||
|
await page.waitForTimeout(650);
|
||||||
|
await itemRow.dispatchEvent("mouseup");
|
||||||
|
await expect(page.locator(".edit-modal-content")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("add-details modal validates with toasts and persists 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");
|
||||||
|
await page.getByRole("button", { name: "Create + Add" }).click();
|
||||||
|
|
||||||
|
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.getByRole("button", { name: "Add Item" }).click();
|
||||||
|
|
||||||
|
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
||||||
|
await expect(yogurtRow).toBeVisible();
|
||||||
|
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
|
||||||
|
await seedAuthStorage(page);
|
||||||
|
await mockConfig(page);
|
||||||
|
const routes = await setupGroceryListRoutes(page);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
||||||
|
await expect(yogurtRow).toBeVisible();
|
||||||
|
|
||||||
|
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");
|
||||||
|
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item");
|
||||||
|
await expect(editModal).toBeHidden();
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
routes.setClassificationRequestMode("error");
|
||||||
|
await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery");
|
||||||
|
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user