From 084ffe70993759882cf2b89cdd643086d87ceb25 Mon Sep 17 00:00:00 2001 From: Nico Date: Sat, 28 Mar 2026 22:51:02 -0700 Subject: [PATCH] fix(ui): portal assign item dropdown --- .../components/modals/AssignItemForModal.jsx | 138 ++++++++--- .../styles/components/AssignItemForModal.css | 10 +- .../tests/grocery-list-assignment.spec.ts | 233 ++++++++++++++++++ 3 files changed, 343 insertions(+), 38 deletions(-) create mode 100644 frontend/tests/grocery-list-assignment.spec.ts diff --git a/frontend/src/components/modals/AssignItemForModal.jsx b/frontend/src/components/modals/AssignItemForModal.jsx index 5b16973..da60f6d 100644 --- a/frontend/src/components/modals/AssignItemForModal.jsx +++ b/frontend/src/components/modals/AssignItemForModal.jsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import "../../styles/components/AssignItemForModal.css"; function getMemberLabel(member) { @@ -19,7 +20,9 @@ export default function AssignItemForModal({ }) { const [selectedUserId, setSelectedUserId] = useState(""); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const dropdownRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); const hasMembers = members.length > 0; const selectedMember = useMemo( @@ -27,10 +30,39 @@ export default function AssignItemForModal({ [members, selectedUserId] ); + const updateDropdownPosition = useCallback(() => { + if (!triggerRef.current) return; + + const rect = triggerRef.current.getBoundingClientRect(); + const viewportPadding = 16; + const menuGap = 6; + const width = Math.min(rect.width, window.innerWidth - (2 * viewportPadding)); + const left = Math.min( + Math.max(viewportPadding, rect.left), + window.innerWidth - width - viewportPadding + ); + const availableBelow = window.innerHeight - rect.bottom - menuGap - viewportPadding; + const availableAbove = rect.top - menuGap - viewportPadding; + const shouldOpenAbove = availableBelow < 140 && availableAbove > availableBelow; + const maxHeight = Math.max( + 120, + Math.min(240, Math.floor(shouldOpenAbove ? availableAbove : availableBelow)) + ); + + setDropdownStyle({ + left: `${Math.round(left)}px`, + width: `${Math.round(width)}px`, + maxHeight: `${maxHeight}px`, + top: shouldOpenAbove ? "auto" : `${Math.round(rect.bottom + menuGap)}px`, + bottom: shouldOpenAbove ? `${Math.round(window.innerHeight - rect.top + menuGap)}px` : "auto", + }); + }, []); + useEffect(() => { if (!isOpen) return; setSelectedUserId(members[0] ? String(members[0].id) : ""); setIsDropdownOpen(false); + setDropdownStyle(null); }, [isOpen, members]); useEffect(() => { @@ -54,8 +86,10 @@ export default function AssignItemForModal({ if (!isOpen || !isDropdownOpen) return undefined; const handlePointerDown = (event) => { - if (!dropdownRef.current) return; - if (!dropdownRef.current.contains(event.target)) { + const clickedTrigger = triggerRef.current?.contains(event.target); + const clickedMenu = menuRef.current?.contains(event.target); + + if (!clickedTrigger && !clickedMenu) { setIsDropdownOpen(false); } }; @@ -64,6 +98,24 @@ export default function AssignItemForModal({ return () => window.removeEventListener("pointerdown", handlePointerDown); }, [isDropdownOpen, isOpen]); + useEffect(() => { + if (!isOpen || !isDropdownOpen) return undefined; + + updateDropdownPosition(); + + const handleViewportChange = () => { + updateDropdownPosition(); + }; + + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [isDropdownOpen, isOpen, updateDropdownPosition]); + if (!isOpen) return null; const handleConfirm = () => { @@ -71,6 +123,52 @@ export default function AssignItemForModal({ onConfirm(selectedMember.id); }; + const handleToggleDropdown = () => { + if (isDropdownOpen) { + setIsDropdownOpen(false); + return; + } + + updateDropdownPosition(); + setIsDropdownOpen(true); + }; + + const dropdownMenu = isDropdownOpen && dropdownStyle + ? createPortal( +
event.stopPropagation()} + > + {members.map((member) => { + const memberId = String(member.id); + const isSelected = memberId === String(selectedUserId); + + return ( + + ); + })} +
, + document.body + ) + : null; + return (
event.stopPropagation()}> @@ -81,13 +179,14 @@ export default function AssignItemForModal({ -
+
- - {isDropdownOpen ? ( -
- {members.map((member) => { - const memberId = String(member.id); - const isSelected = memberId === String(selectedUserId); - - return ( - - ); - })} -
- ) : null}
) : ( @@ -144,6 +217,7 @@ export default function AssignItemForModal({
+ {dropdownMenu} ); } diff --git a/frontend/src/styles/components/AssignItemForModal.css b/frontend/src/styles/components/AssignItemForModal.css index a3ae81b..6ad319a 100644 --- a/frontend/src/styles/components/AssignItemForModal.css +++ b/frontend/src/styles/components/AssignItemForModal.css @@ -2,6 +2,7 @@ width: min(420px, calc(100vw - (2 * var(--spacing-md)))); max-width: 420px; overflow-x: hidden; + overflow-y: visible; } .assign-item-for-modal-field { @@ -54,13 +55,10 @@ } .assign-item-for-dropdown-menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 3; - max-height: 180px; + position: fixed; + z-index: var(--z-tooltip); overflow-y: auto; + overscroll-behavior: contain; background: var(--color-bg-surface); border: var(--border-width-thin) solid var(--input-border-color); border-radius: var(--border-radius-md); diff --git a/frontend/tests/grocery-list-assignment.spec.ts b/frontend/tests/grocery-list-assignment.spec.ts new file mode 100644 index 0000000..51c7557 --- /dev/null +++ b/frontend/tests/grocery-list-assignment.spec.ts @@ -0,0 +1,233 @@ +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", "assignment-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, + }), + }); + }); +} + +test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => { + await seedAuthStorage(page); + await mockConfig(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" }, + { id: 3, username: "jordan", name: "Jordan Client", display_name: "Jordan Client", role: "member" }, + { id: 4, username: "alex", name: "Alex Member", display_name: "Alex Member", role: "member" }, + { id: 5, username: "morgan", name: "Morgan Member", display_name: "Morgan Member", role: "member" }, + { id: 6, username: "sam", name: "Sam Member", display_name: "Sam Member", role: "member" }, + { id: 7, username: "jamie", name: "Jamie Member", display_name: "Jamie Member", role: "member" }, + { id: 8, username: "pat", name: "Pat Member", display_name: "Pat Member", role: "member" }, + { id: 9, username: "drew", name: "Drew Member", display_name: "Drew Member", role: "member" }, + { id: 10, username: "kai", name: "Kai Member", display_name: "Kai Member", role: "member" }, + { id: 11, username: "blair", name: "Blair Member", display_name: "Blair Member", role: "member" }, + { id: 12, username: "quinn", name: "Quinn Member", display_name: "Quinn Member", role: "member" }, + { id: 13, username: "rowan", name: "Rowan Member", display_name: "Rowan Member", role: "member" }, + { id: 14, username: "sage", name: "Sage Member", display_name: "Sage Member", role: "member" }, + { id: 15, username: "taylor", name: "Taylor Member", display_name: "Taylor Member", role: "member" }, + { id: 16, username: "river", name: "River Member", display_name: "River Member", role: "member" }, + ]; + + let listItems: 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; + }> = []; + let addCallCount = 0; + + await page.route("**/households", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { id: 1, name: "Assignment 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(members), + }); + }); + + 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/item**", async (route) => { + const url = new URL(route.request().url()); + const itemName = (url.searchParams.get("item_name") || "").toLowerCase(); + const item = listItems.find((candidate) => candidate.item_name === itemName); + + await route.fulfill({ + status: item ? 200 : 404, + contentType: "application/json", + body: JSON.stringify(item || { message: "Item not found" }), + }); + }); + + await page.route("**/households/1/stores/10/list/add", async (route) => { + addCallCount += 1; + + if (addCallCount === 1) { + listItems = [ + { + id: 201, + item_id: 501, + item_name: "bananas", + quantity: 1, + 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: null, + item_group: null, + zone: null, + }, + ]; + } else { + listItems = [ + { + id: 201, + item_id: 501, + item_name: "bananas", + quantity: 2, + bought: false, + item_image: null, + image_mime_type: null, + added_by_users: ["Casey Client", "Jordan Client"], + last_added_on: "2026-03-28T12:05:00.000Z", + item_type: null, + item_group: null, + zone: null, + }, + ]; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + message: addCallCount === 1 ? "Item added" : "Item updated", + item: { + id: 201, + item_name: "bananas", + quantity: addCallCount === 1 ? 1 : 2, + bought: false, + }, + }), + }); + }); + + await page.route("**/households/1/stores/10/list", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: listItems }), + }); + }); + + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Grocery List" })).toBeVisible(); + await page.getByPlaceholder("Enter item name").fill("bananas"); + await page.getByRole("button", { name: "Others" }).click(); + + const assignModal = page.locator(".assign-item-for-modal"); + await expect(assignModal).toBeVisible(); + + await assignModal.getByRole("button", { name: "Select member" }).click(); + + const portalMenu = page.locator("body > .assign-item-for-dropdown-menu"); + await expect(portalMenu).toBeVisible(); + await expect(page.locator(".assign-item-for-modal .assign-item-for-dropdown-menu")).toHaveCount(0); + + const dropdownMetrics = await portalMenu.evaluate((element) => { + const menu = element as HTMLDivElement; + return { + position: window.getComputedStyle(menu).position, + scrollable: menu.scrollHeight > menu.clientHeight, + }; + }); + + expect(dropdownMetrics.position).toBe("fixed"); + expect(dropdownMetrics.scrollable).toBe(true); + + await portalMenu.getByRole("option", { name: "Casey Client" }).click(); + await assignModal.getByRole("button", { name: "Confirm" }).click(); + + await expect(page.getByText("Adding for: Casey Client")).toBeVisible(); + await page.getByRole("button", { name: "Create + Add" }).click(); + await page.getByRole("button", { name: "Skip All" }).click(); + + const bananasRow = page.locator(".glist-li").filter({ hasText: "bananas" }); + await expect(bananasRow).toContainText("Casey Client"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item"); + + await page.getByPlaceholder("Enter item name").fill("bananas"); + await page.getByRole("button", { name: "Others" }).click(); + await assignModal.getByRole("button", { name: "Select member" }).click(); + await portalMenu.getByRole("option", { name: "Jordan Client" }).click(); + await assignModal.getByRole("button", { name: "Confirm" }).click(); + + await expect(page.getByText("Adding for: Jordan Client")).toBeVisible(); + await page.getByRole("button", { name: "Create + Add" }).click(); + await page.getByRole("button", { name: "Update Quantity" }).click(); + + await expect(bananasRow).toContainText("Casey Client"); + await expect(bananasRow).toContainText("Jordan Client"); + await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item quantity"); +});