fix(ui): keep available item management out of grocery flow
This commit is contained in:
parent
084ffe7099
commit
41d08f1286
@ -1,5 +1,4 @@
|
|||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
const AvailableItems = require("../models/available-item.model");
|
|
||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
@ -120,18 +119,8 @@ exports.addItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get processed image if uploaded
|
// Get processed image if uploaded
|
||||||
let imageBuffer = req.processedImage?.buffer || null;
|
const imageBuffer = req.processedImage?.buffer || null;
|
||||||
let mimeType = req.processedImage?.mimeType || null;
|
const mimeType = req.processedImage?.mimeType || null;
|
||||||
|
|
||||||
if (!imageBuffer) {
|
|
||||||
const catalogItem = await AvailableItems.getAvailableItemImageByName(
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
item_name
|
|
||||||
);
|
|
||||||
imageBuffer = catalogItem?.custom_image || null;
|
|
||||||
mimeType = catalogItem?.custom_image_mime_type || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(
|
const result = await List.addOrUpdateItem(
|
||||||
householdId,
|
householdId,
|
||||||
|
|||||||
@ -269,32 +269,6 @@ exports.addHistoryRecord = async (listId, quantity, userId) => {
|
|||||||
* @returns {Promise<Array>} Suggestions
|
* @returns {Promise<Array>} Suggestions
|
||||||
*/
|
*/
|
||||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||||
const hasCatalogResult = await pool.query(
|
|
||||||
`SELECT 1
|
|
||||||
FROM household_store_available_items
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
LIMIT 1`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasCatalogResult.rowCount > 0) {
|
|
||||||
const catalogSuggestions = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
i.name as item_name,
|
|
||||||
0 as sort_order
|
|
||||||
FROM household_store_available_items hsai
|
|
||||||
JOIN items i ON i.id = hsai.item_id
|
|
||||||
WHERE hsai.household_id = $2
|
|
||||||
AND hsai.store_id = $3
|
|
||||||
AND i.name ILIKE $1
|
|
||||||
ORDER BY i.name
|
|
||||||
LIMIT 10`,
|
|
||||||
[`%${query}%`, householdId, storeId]
|
|
||||||
);
|
|
||||||
return catalogSuggestions.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get items from both master catalog and household history
|
// Get items from both master catalog and household history
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT
|
`SELECT DISTINCT
|
||||||
|
|||||||
@ -132,45 +132,3 @@ describe("list.model.v2 classification helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("list.model.v2 suggestions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pool.query.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns catalog suggestions when a household-store catalog exists", async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ "?column?": 1 }] })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [{ item_name: "milk", sort_order: 0 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await List.getSuggestions("mi", 1, 2);
|
|
||||||
|
|
||||||
expect(result).toEqual([{ item_name: "milk", sort_order: 0 }]);
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.stringContaining("FROM household_store_available_items"),
|
|
||||||
[1, 2]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to legacy suggestions when catalog is empty", async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [{ item_name: "milk", sort_order: 1 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await List.getSuggestions("mi", 1, 2);
|
|
||||||
|
|
||||||
expect(result).toEqual([{ item_name: "milk", sort_order: 1 }]);
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.stringContaining("LEFT JOIN household_lists"),
|
|
||||||
["%mi%", 1, 2]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -9,16 +9,11 @@ jest.mock("../models/household.model", () => ({
|
|||||||
isHouseholdMember: jest.fn(),
|
isHouseholdMember: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("../models/available-item.model", () => ({
|
|
||||||
getAvailableItemImageByName: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../utils/logger", () => ({
|
jest.mock("../utils/logger", () => ({
|
||||||
logError: jest.fn(),
|
logError: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const List = require("../models/list.model.v2");
|
const List = require("../models/list.model.v2");
|
||||||
const AvailableItems = require("../models/available-item.model");
|
|
||||||
const householdModel = require("../models/household.model");
|
const householdModel = require("../models/household.model");
|
||||||
const controller = require("../controllers/lists.controller.v2");
|
const controller = require("../controllers/lists.controller.v2");
|
||||||
|
|
||||||
@ -41,7 +36,6 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
List.upsertClassification.mockResolvedValue(undefined);
|
||||||
AvailableItems.getAvailableItemImageByName.mockResolvedValue(null);
|
|
||||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -180,7 +174,6 @@ describe("lists.controller.v2 setClassification", () => {
|
|||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
isNew: true,
|
isNew: true,
|
||||||
});
|
});
|
||||||
AvailableItems.getAvailableItemImageByName.mockResolvedValue(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts object classification with type, group, and zone", async () => {
|
test("accepts object classification with type, group, and zone", async () => {
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import SuggestionList from "../items/SuggestionList";
|
|||||||
|
|
||||||
export default function AddItemForm({
|
export default function AddItemForm({
|
||||||
onAdd,
|
onAdd,
|
||||||
onOpenCatalog,
|
|
||||||
onSuggest,
|
onSuggest,
|
||||||
suggestions,
|
suggestions,
|
||||||
buttonText = "Add",
|
buttonText = "Add",
|
||||||
@ -19,7 +18,6 @@ export default function AddItemForm({
|
|||||||
const [assignmentMode, setAssignmentMode] = useState("me");
|
const [assignmentMode, setAssignmentMode] = useState("me");
|
||||||
const [assignedUserId, setAssignedUserId] = useState(null);
|
const [assignedUserId, setAssignedUserId] = useState(null);
|
||||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState(null);
|
|
||||||
|
|
||||||
const numericCurrentUserId =
|
const numericCurrentUserId =
|
||||||
currentUserId == null ? null : Number.parseInt(String(currentUserId), 10);
|
currentUserId == null ? null : Number.parseInt(String(currentUserId), 10);
|
||||||
@ -35,31 +33,24 @@ export default function AddItemForm({
|
|||||||
return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : "";
|
return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : "";
|
||||||
}, [assignmentMode, assignedUserId, otherMembers]);
|
}, [assignmentMode, assignedUserId, otherMembers]);
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setItemName("");
|
|
||||||
setQuantity(1);
|
|
||||||
setAssignmentMode("me");
|
|
||||||
setAssignedUserId(null);
|
|
||||||
setShowAssignModal(false);
|
|
||||||
setPendingAction(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
|
|
||||||
if (assignmentMode === "others" && assignedUserId == null) {
|
if (assignmentMode === "others" && assignedUserId == null) {
|
||||||
if (otherMembers.length > 0) {
|
if (otherMembers.length > 0) {
|
||||||
setPendingAction("submit");
|
|
||||||
setShowAssignModal(true);
|
setShowAssignModal(true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingAction(null);
|
|
||||||
const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null;
|
const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null;
|
||||||
onAdd(itemName, quantity, targetUserId);
|
onAdd(itemName, quantity, targetUserId);
|
||||||
resetForm();
|
setItemName("");
|
||||||
|
setQuantity(1);
|
||||||
|
setAssignmentMode("me");
|
||||||
|
setAssignedUserId(null);
|
||||||
|
setShowAssignModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (text) => {
|
const handleInputChange = (text) => {
|
||||||
@ -103,48 +94,12 @@ export default function AddItemForm({
|
|||||||
setShowAssignModal(false);
|
setShowAssignModal(false);
|
||||||
setAssignmentMode("me");
|
setAssignmentMode("me");
|
||||||
setAssignedUserId(null);
|
setAssignedUserId(null);
|
||||||
setPendingAction(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssignConfirm = (memberId) => {
|
const handleAssignConfirm = (memberId) => {
|
||||||
setShowAssignModal(false);
|
setShowAssignModal(false);
|
||||||
setAssignmentMode("others");
|
setAssignmentMode("others");
|
||||||
const parsedMemberId = Number(memberId);
|
setAssignedUserId(Number(memberId));
|
||||||
setAssignedUserId(parsedMemberId);
|
|
||||||
|
|
||||||
if (pendingAction === "submit" && itemName.trim()) {
|
|
||||||
onAdd(itemName, quantity, parsedMemberId);
|
|
||||||
resetForm();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingAction === "catalog" && onOpenCatalog) {
|
|
||||||
onOpenCatalog({
|
|
||||||
quantity,
|
|
||||||
addedForUserId: parsedMemberId,
|
|
||||||
resetForm,
|
|
||||||
});
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCatalogOpen = () => {
|
|
||||||
if (!onOpenCatalog) return;
|
|
||||||
|
|
||||||
if (assignmentMode === "others" && assignedUserId == null) {
|
|
||||||
if (otherMembers.length > 0) {
|
|
||||||
setPendingAction("catalog");
|
|
||||||
setShowAssignModal(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingAction(null);
|
|
||||||
onOpenCatalog({
|
|
||||||
quantity,
|
|
||||||
addedForUserId: assignmentMode === "others" ? Number(assignedUserId) : null,
|
|
||||||
resetForm,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDisabled = !itemName.trim();
|
const isDisabled = !itemName.trim();
|
||||||
@ -172,16 +127,6 @@ export default function AddItemForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onOpenCatalog ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="add-item-form-catalog-btn"
|
|
||||||
onClick={handleCatalogOpen}
|
|
||||||
>
|
|
||||||
Store Items
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={assignmentMode}
|
value={assignmentMode}
|
||||||
ariaLabel="Item assignment mode"
|
ariaLabel="Item assignment mode"
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
import "../../styles/components/AvailableItemsPickerModal.css";
|
|
||||||
|
|
||||||
function itemImageSource(item) {
|
|
||||||
if (!item?.item_image) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = item.image_mime_type || "image/jpeg";
|
|
||||||
return `data:${mimeType};base64,${item.item_image}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AvailableItemsPickerModal({
|
|
||||||
isOpen,
|
|
||||||
items,
|
|
||||||
loading,
|
|
||||||
query,
|
|
||||||
onClose,
|
|
||||||
onQueryChange,
|
|
||||||
onSelect,
|
|
||||||
}) {
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="available-items-picker-overlay" onClick={onClose}>
|
|
||||||
<div className="available-items-picker-modal" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<div className="available-items-picker-header">
|
|
||||||
<div>
|
|
||||||
<h2 className="available-items-picker-title">Store Items</h2>
|
|
||||||
<p className="available-items-picker-subtitle">
|
|
||||||
Pick from your household's available items for this store.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="available-items-picker-close"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close store items picker"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
className="available-items-picker-search"
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => onQueryChange(event.target.value)}
|
|
||||||
placeholder="Search available items"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="available-items-picker-list">
|
|
||||||
{loading ? (
|
|
||||||
<p className="available-items-picker-empty">Loading store items...</p>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="available-items-picker-empty">No matching store items found.</p>
|
|
||||||
) : (
|
|
||||||
items.map((item) => {
|
|
||||||
const imageSrc = itemImageSource(item);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={item.item_id}
|
|
||||||
className="available-items-picker-item"
|
|
||||||
onClick={() => onSelect(item)}
|
|
||||||
>
|
|
||||||
{imageSrc ? (
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
alt=""
|
|
||||||
className="available-items-picker-thumb"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="available-items-picker-thumb available-items-picker-thumb-placeholder">
|
|
||||||
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="available-items-picker-copy">
|
|
||||||
<span className="available-items-picker-name">{item.item_name}</span>
|
|
||||||
<span className="available-items-picker-meta">
|
|
||||||
{[item.item_type, item.item_group, item.zone].filter(Boolean).join(" | ") || "No store defaults"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -10,13 +10,11 @@ import {
|
|||||||
markBought,
|
markBought,
|
||||||
updateItemWithClassification
|
updateItemWithClassification
|
||||||
} from "../api/list";
|
} from "../api/list";
|
||||||
import { getAvailableItems } from "../api/availableItems";
|
|
||||||
import { getHouseholdMembers } from "../api/households";
|
import { getHouseholdMembers } from "../api/households";
|
||||||
import SortDropdown from "../components/common/SortDropdown";
|
import SortDropdown from "../components/common/SortDropdown";
|
||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
import AvailableItemsPickerModal from "../components/modals/AvailableItemsPickerModal";
|
|
||||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||||
import EditItemModal from "../components/modals/EditItemModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
@ -64,11 +62,6 @@ export default function GroceryList() {
|
|||||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [showAvailableItemsPicker, setShowAvailableItemsPicker] = useState(false);
|
|
||||||
const [availableItemsQuery, setAvailableItemsQuery] = useState("");
|
|
||||||
const [availableItems, setAvailableItems] = useState([]);
|
|
||||||
const [availableItemsLoading, setAvailableItemsLoading] = useState(false);
|
|
||||||
const [availableItemsContext, setAvailableItemsContext] = useState(null);
|
|
||||||
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
||||||
const [collapsedZones, setCollapsedZones] = useState({});
|
const [collapsedZones, setCollapsedZones] = useState({});
|
||||||
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
||||||
@ -132,34 +125,6 @@ export default function GroceryList() {
|
|||||||
loadHouseholdMembers();
|
loadHouseholdMembers();
|
||||||
}, [activeHousehold?.id]);
|
}, [activeHousehold?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadAvailableStoreItems = async () => {
|
|
||||||
if (!showAvailableItemsPicker) return;
|
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
|
||||||
|
|
||||||
setAvailableItemsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getAvailableItems(activeHousehold.id, activeStore.id, availableItemsQuery);
|
|
||||||
setAvailableItems(response.data.items || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load available store items:", error);
|
|
||||||
const message = getApiErrorMessage(error, "Failed to load store items");
|
|
||||||
toast.error("Load store items failed", `Load store items failed: ${message}`);
|
|
||||||
setAvailableItems([]);
|
|
||||||
} finally {
|
|
||||||
setAvailableItemsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadAvailableStoreItems();
|
|
||||||
}, [
|
|
||||||
activeHousehold?.id,
|
|
||||||
activeStore?.id,
|
|
||||||
availableItemsQuery,
|
|
||||||
showAvailableItemsPicker,
|
|
||||||
toast,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUploadSuccess = async (event) => {
|
const handleUploadSuccess = async (event) => {
|
||||||
const detail = event?.detail || {};
|
const detail = event?.detail || {};
|
||||||
@ -333,25 +298,6 @@ export default function GroceryList() {
|
|||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
||||||
|
|
||||||
const handleOpenAvailableItemsPicker = useCallback((context) => {
|
|
||||||
setAvailableItemsContext(context);
|
|
||||||
setAvailableItemsQuery("");
|
|
||||||
setAvailableItems([]);
|
|
||||||
setShowAvailableItemsPicker(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAvailableItemSelect = useCallback(async (item) => {
|
|
||||||
setShowAvailableItemsPicker(false);
|
|
||||||
setAvailableItems([]);
|
|
||||||
setAvailableItemsQuery("");
|
|
||||||
|
|
||||||
const context = availableItemsContext || {};
|
|
||||||
context.resetForm?.();
|
|
||||||
setAvailableItemsContext(null);
|
|
||||||
|
|
||||||
await handleAdd(item.item_name, context.quantity || 1, context.addedForUserId || null);
|
|
||||||
}, [availableItemsContext, handleAdd]);
|
|
||||||
|
|
||||||
|
|
||||||
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
|
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
@ -811,7 +757,6 @@ export default function GroceryList() {
|
|||||||
{householdRole && householdRole !== 'viewer' && (
|
{householdRole && householdRole !== 'viewer' && (
|
||||||
<AddItemForm
|
<AddItemForm
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onOpenCatalog={handleOpenAvailableItemsPicker}
|
|
||||||
onSuggest={handleSuggest}
|
onSuggest={handleSuggest}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
buttonText={buttonText}
|
buttonText={buttonText}
|
||||||
@ -976,21 +921,6 @@ export default function GroceryList() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AvailableItemsPickerModal
|
|
||||||
isOpen={showAvailableItemsPicker}
|
|
||||||
items={availableItems}
|
|
||||||
loading={availableItemsLoading}
|
|
||||||
query={availableItemsQuery}
|
|
||||||
onQueryChange={setAvailableItemsQuery}
|
|
||||||
onClose={() => {
|
|
||||||
setShowAvailableItemsPicker(false);
|
|
||||||
setAvailableItemsContext(null);
|
|
||||||
setAvailableItemsQuery("");
|
|
||||||
setAvailableItems([]);
|
|
||||||
}}
|
|
||||||
onSelect={handleAvailableItemSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,23 +37,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-catalog-btn {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 108px;
|
|
||||||
border: var(--border-width-thin) solid var(--color-primary);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-radius: var(--button-border-radius);
|
|
||||||
padding: 0 var(--spacing-sm);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-form-catalog-btn:hover {
|
|
||||||
background: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-form-assignee-hint {
|
.add-item-form-assignee-hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
@ -221,11 +204,6 @@
|
|||||||
width: 100px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-catalog-btn {
|
|
||||||
min-width: 96px;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-item-form-quantity-control {
|
.add-item-form-quantity-control {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
.available-items-picker-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: var(--modal-backdrop-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-modal {
|
|
||||||
width: min(680px, 100%);
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--modal-bg);
|
|
||||||
border-radius: var(--border-radius-xl);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-subtitle {
|
|
||||||
margin: var(--spacing-xs) 0 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-close {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-search {
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--input-focus-border-color);
|
|
||||||
box-shadow: var(--input-focus-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-list {
|
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-lg);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-item:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-thumb {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--color-bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-thumb-placeholder {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-name {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-meta {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-empty {
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--spacing-lg) 0;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.available-items-picker-modal {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-items-picker-item {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -179,57 +179,18 @@ test("manage stores lets admins import and curate available items", async ({ pag
|
|||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Removed store item");
|
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Removed store item");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("grocery picker uses available items and preserves quantity and assignee", async ({ page }) => {
|
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page);
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
await mockHouseholdAndStoreShell(page);
|
await mockHouseholdAndStoreShell(page);
|
||||||
|
|
||||||
const members = [
|
|
||||||
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
|
||||||
{ id: 2, username: "casey", name: "Casey Client", display_name: "Casey Client", role: "member" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let lastAddBody = "";
|
|
||||||
let currentItems: Array<{
|
|
||||||
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;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
await page.route("**/households/1/members", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(members),
|
body: JSON.stringify([
|
||||||
});
|
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
||||||
});
|
]),
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/available-items*", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
item_id: 600,
|
|
||||||
item_name: "bananas",
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
item_type: "produce",
|
|
||||||
item_group: "Fresh Fruit",
|
|
||||||
zone: "Fresh Produce",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -245,59 +206,15 @@ test("grocery picker uses available items and preserves quantity and assignee",
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify([{ item_name: "bananas" }]),
|
body: JSON.stringify([]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||||
const request = route.request();
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
|
||||||
const item = currentItems.find((candidate) => candidate.item_name === itemName);
|
|
||||||
|
|
||||||
if (request.method() === "GET") {
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: item ? 200 : 404,
|
status: 404,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(item || { message: "Item not found" }),
|
body: JSON.stringify({ message: "Item not found" }),
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({ status: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/add", async (route) => {
|
|
||||||
lastAddBody = route.request().postData() || "";
|
|
||||||
currentItems = [
|
|
||||||
{
|
|
||||||
id: 201,
|
|
||||||
item_id: 600,
|
|
||||||
item_name: "bananas",
|
|
||||||
quantity: 3,
|
|
||||||
bought: false,
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
added_by_users: ["Casey Client"],
|
|
||||||
last_added_on: "2026-03-28T12:00:00.000Z",
|
|
||||||
item_type: "produce",
|
|
||||||
item_group: "Fresh Fruit",
|
|
||||||
zone: "Fresh Produce",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Item added",
|
|
||||||
item: {
|
|
||||||
id: 201,
|
|
||||||
item_name: "bananas",
|
|
||||||
quantity: 3,
|
|
||||||
bought: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -305,31 +222,12 @@ test("grocery picker uses available items and preserves quantity and assignee",
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ items: currentItems }),
|
body: JSON.stringify({ items: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Others" }).click();
|
await expect(page.getByRole("button", { name: "Store Items" })).toHaveCount(0);
|
||||||
const assignModal = page.locator(".assign-item-for-modal");
|
await expect(page.locator(".available-items-picker-modal")).toHaveCount(0);
|
||||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
|
||||||
await page.locator("body > .assign-item-for-dropdown-menu").getByRole("option", { name: "Casey Client" }).click();
|
|
||||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "+" }).click();
|
|
||||||
await page.getByRole("button", { name: "+" }).click();
|
|
||||||
await expect(page.locator(".add-item-form-quantity-input")).toHaveValue("3");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Store Items" }).click();
|
|
||||||
const pickerModal = page.locator(".available-items-picker-modal");
|
|
||||||
await expect(pickerModal).toBeVisible();
|
|
||||||
await pickerModal.getByRole("button", { name: /bananas/i }).click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Skip All" }).click();
|
|
||||||
await expect(page.locator(".glist-li").filter({ hasText: "bananas" })).toContainText("Casey Client");
|
|
||||||
expect(lastAddBody).toContain('name="quantity"');
|
|
||||||
expect(lastAddBody).toContain("3");
|
|
||||||
expect(lastAddBody).toContain('name="added_for_user_id"');
|
|
||||||
expect(lastAddBody).toContain("2");
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user