From ec7b40354677d848f6f09f3a5f69aabfbef5df22 Mon Sep 17 00:00:00 2001 From: Nico Date: Sun, 31 May 2026 18:06:06 -0700 Subject: [PATCH] feat: move member actions into modal --- .../src/components/manage/ManageHousehold.jsx | 134 ++++++++++++++---- .../components/manage/ManageHousehold.css | 115 ++++++++++++--- frontend/tests/invite-link-management.spec.ts | 28 +++- 3 files changed, 221 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx index e75ed0e..173bac8 100644 --- a/frontend/src/components/manage/ManageHousehold.jsx +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -71,6 +71,7 @@ export default function ManageHousehold() { const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [pendingRoleChange, setPendingRoleChange] = useState(null); const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isOwner = activeHousehold?.role === "owner"; @@ -85,6 +86,19 @@ export default function ManageHousehold() { } }, [activeHousehold?.id, isManager]); + useEffect(() => { + if (!selectedMember) return undefined; + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + setSelectedMember(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedMember]); + const loadMembers = async () => { if (!activeHousehold?.id) return; setLoading(true); @@ -360,6 +374,28 @@ export default function ManageHousehold() { const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length; const memberCount = members.filter((member) => member.role === "member").length; + const selectedRoleMeta = selectedMember + ? ROLE_METADATA[selectedMember.role] || { icon: "👤", label: selectedMember.role } + : null; + const selectedMemberIsSelf = selectedMember?.id === parseInt(userId, 10); + const canManageSelectedMember = + Boolean(selectedMember) && + isManager && + !selectedMemberIsSelf && + selectedMember.role !== "owner"; + const selectedMemberNextRole = selectedMember?.role === "admin" ? "member" : "admin"; + + const openMemberRoleChange = (nextRole) => { + if (!selectedMember) return; + handleUpdateRole(selectedMember.id, nextRole, selectedMember.username); + setSelectedMember(null); + }; + + const openMemberRemoval = () => { + if (!selectedMember) return; + handleRemoveMember(selectedMember.id, selectedMember.username); + setSelectedMember(null); + }; return (
@@ -560,7 +596,13 @@ export default function ManageHousehold() { const isSelf = member.id === parseInt(userId, 10); return ( -
+ - )} - - -
- )} -
+ ); })} )} + {selectedMember && ( +
setSelectedMember(null)}> +
event.stopPropagation()} + > +
+
+

Member

+

{selectedMember.username}

+ + {selectedRoleMeta.icon} {selectedRoleMeta.label} + +
+ +
+ + {canManageSelectedMember ? ( +
+ {isOwner && ( + + )} + + +
+ ) : ( +

No actions available for this member.

+ )} +
+
+ )} + {(isManager || isMemberOnly) && (
diff --git a/frontend/src/styles/components/manage/ManageHousehold.css b/frontend/src/styles/components/manage/ManageHousehold.css index 07840f2..5aa787a 100644 --- a/frontend/src/styles/components/manage/ManageHousehold.css +++ b/frontend/src/styles/components/manage/ManageHousehold.css @@ -384,13 +384,22 @@ body.dark-mode .invite-status-badge.is-used { display: flex; flex-direction: column; gap: 0.7rem; + width: 100%; padding: 0.85rem 1rem; background: rgba(255, 255, 255, 1); border: 1px solid var(--border); border-radius: var(--border-radius-lg); + color: inherit; + font: inherit; + text-align: left; transition: all 0.2s; } +.member-card-button { + appearance: none; + cursor: pointer; +} + [data-theme="dark"] .member-card, body.dark-mode .member-card { background: rgba(12, 19, 30, 0.9); @@ -408,6 +417,11 @@ body.dark-mode .member-card:hover { background: rgba(20, 32, 48, 0.98); } +.member-card-button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; +} + .member-main { min-width: 0; } @@ -466,16 +480,6 @@ body.dark-mode .member-card:hover { font-weight: 700; } -.member-actions { - display: flex; - gap: 0.55rem; - flex-wrap: wrap; - justify-content: flex-start; - align-items: center; - padding-top: 0.65rem; - border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent); -} - .member-owner-action { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); } @@ -506,11 +510,6 @@ body.dark-mode .member-role-action:hover:not(:disabled) { color: #f3f9ff; } -[data-theme="dark"] .member-actions, -body.dark-mode .member-actions { - border-top-color: color-mix(in srgb, var(--color-border-medium) 88%, transparent); -} - /* Danger Zone */ .danger-zone { border-color: color-mix(in srgb, var(--danger) 30%, transparent); @@ -559,6 +558,86 @@ body.dark-mode .danger-zone { cursor: not-allowed; } +.member-actions-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); +} + +.member-actions-modal { + width: min(420px, calc(100vw - (2 * var(--spacing-md)))); + max-height: calc(100vh - (2 * var(--spacing-md))); + overflow: auto; + padding: 1.2rem; + border: 1px solid var(--color-border-light); + border-radius: var(--border-radius-lg); + background: var(--modal-bg); + box-shadow: var(--shadow-xl); +} + +.member-actions-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.member-actions-modal-copy { + min-width: 0; +} + +.member-actions-modal-copy h3 { + margin: 0.15rem 0 0.45rem; + color: var(--text-primary); + font-size: 1.25rem; + overflow-wrap: anywhere; +} + +.member-actions-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 40px; + height: 40px; + border: 1px solid var(--border); + border-radius: var(--border-radius-full); + background: var(--button-ghost-bg); + color: var(--text-primary); + cursor: pointer; + font-size: 1.3rem; + line-height: 1; +} + +.member-actions-modal-close:hover, +.member-actions-modal-close:focus-visible { + border-color: var(--primary); + color: var(--primary); + outline: none; +} + +.member-actions-modal-actions { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-top: 1.1rem; +} + +.member-actions-modal-actions button { + width: 100%; +} + +.member-actions-modal-empty { + margin: 1rem 0 0; + color: var(--text-secondary); + font-size: 0.92rem; +} + /* Responsive */ @media (max-width: 900px) { .invite-controls { @@ -616,12 +695,6 @@ body.dark-mode .danger-zone { grid-template-columns: 1fr; } - .member-actions { - width: 100%; - justify-content: flex-start; - } - - .member-actions button, .pending-request-actions button { flex: 1 1 100%; } diff --git a/frontend/tests/invite-link-management.spec.ts b/frontend/tests/invite-link-management.spec.ts index 441cc49..06fc821 100644 --- a/frontend/tests/invite-link-management.spec.ts +++ b/frontend/tests/invite-link-management.spec.ts @@ -231,7 +231,11 @@ test("household member removal opens slide confirmation instead of browser dialo await page.goto("/manage?tab=household"); const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" }); - await memberCard.getByRole("button", { name: "Remove" }).click(); + await expect(memberCard.getByRole("button", { name: "Remove" })).toHaveCount(0); + await memberCard.click(); + const memberActionsDialog = page.getByRole("dialog", { name: "remove-me" }); + await expect(memberActionsDialog).toBeVisible(); + await memberActionsDialog.getByRole("button", { name: "Remove" }).click(); await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible(); await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible(); @@ -266,11 +270,21 @@ test("household owner can transfer ownership from household settings", async ({ }); }); - await page.route("**/stores/household/1", async (route) => { + await page.route("**/households/1/stores", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]), + body: JSON.stringify([ + { id: 10, household_store_id: 100, name: "Costco", location: "Warehouse", is_default: true }, + ]), + }); + }); + + await page.route("**/households/1/locations/10/zones", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ zones: [] }), }); }); @@ -346,9 +360,13 @@ test("household owner can transfer ownership from household settings", async ({ await page.goto("/manage?tab=household"); - await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0); - await page.getByRole("button", { name: "Make Owner" }).click(); + const memberCard = page.locator(".member-card").filter({ hasText: "nico-admin" }); + await memberCard.click(); + const memberActionsDialog = page.getByRole("dialog", { name: "nico-admin" }); + await expect(memberActionsDialog).toBeVisible(); + await memberActionsDialog.getByRole("button", { name: "Make Owner" }).click(); await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible(); await confirmSlide(page);