From 786d8f3d426277e1f8a673d586926931d4820227 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 27 Jun 2026 05:38:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20consolidate=20ProjectDetail=20head?= =?UTF-8?q?er=20actions=20into=20"More=20=E2=96=BE"=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per project-detail.md, the header keeps "Edit Project" as the primary button and moves the secondary actions into a "More ▾" dropdown: Add Member, Log Buzz, Post Update, Post Help-Wanted Role, Manage Members, and (admin) Delete Project behind a confirm dialog. The previously-contextual section buttons (Post Update, Log Buzz, Post new role) are removed now that they live in the header dropdown — single source per the spec. Delete soft-deletes via the existing endpoint and refetches, so the soft-delete banner (from the prior PR) appears for staff immediately. Tests: dropdown trigger present for management perms, absent for anonymous. Completes the #113 UI-gaps umbrella. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/screens/ProjectDetail.tsx | 133 +++++++++++++++++++------ apps/web/tests/ProjectDetail.test.tsx | 28 ++++++ 2 files changed, 131 insertions(+), 30 deletions(-) diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 2a4c5b9..35d592e 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -2,6 +2,21 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; import { MarkdownView } from '@/components/MarkdownView'; import { StageProgressBar, StageBadge } from '@/components/StageBadge'; import { StageInfoDialog } from '@/components/StageInfoDialog'; @@ -53,6 +68,7 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const [stageInfoOpen, setStageInfoOpen] = useState(false); const [memberBusy, setMemberBusy] = useState(false); const [memberError, setMemberError] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); // Allow ?openModal=help-wanted (from /help-wanted "Post a role" picker). // Use the state-sync pattern so we don't trigger a cascading re-render. @@ -154,6 +170,20 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { } }; + const doDelete = async (): Promise => { + setMemberBusy(true); + setMemberError(null); + try { + await api.projects.delete(slug); + await projectQ.refetch(); + setDeleteOpen(false); + } catch (err) { + setMemberError(err instanceof ApiError ? err.message : 'Could not delete the project.'); + } finally { + setMemberBusy(false); + } + }; + const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( @@ -186,15 +216,54 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { Edit Project )} - {perms.canManageMembers && ( - - )} - {perms.canManageMembers && ( - + {(perms.canManageMembers || + perms.canPostUpdate || + perms.canLogBuzz || + perms.canPostHelpWanted || + perms.canDelete) && ( + + + + + + {perms.canManageMembers && ( + setAddMemberOpen(true)}> + Add Member + + )} + {perms.canLogBuzz && ( + + Log Buzz + + )} + {perms.canPostUpdate && ( + setUpdateModalOpen(true)}> + Post Update + + )} + {perms.canPostHelpWanted && ( + setHelpWantedModalOpen(true)}> + Post Help-Wanted Role + + )} + {perms.canManageMembers && ( + setManageMembersOpen(true)}> + Manage Members + + )} + {perms.canDelete && ( + <> + + setDeleteOpen(true)} + > + Delete Project + + + )} + + )} {!isSignedIn && ( - )} {helpWantedRoles.length === 0 ? (

No open roles right now.

@@ -303,22 +367,6 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) {

Project Activity

-
- {perms.canPostUpdate && ( - - )} - {isSignedIn && ( - - )} -
{updatesQ.isLoading || buzzQ.isLoading ? ( @@ -604,6 +652,31 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { roleTitle={fillRole.title} /> )} + + + + + Delete this project? + + “{project.title}” will be soft-deleted and hidden from public lists. Staff can + restore it afterward. + + + {memberError && ( +

+ {memberError} +

+ )} + + + + +
+
); } diff --git a/apps/web/tests/ProjectDetail.test.tsx b/apps/web/tests/ProjectDetail.test.tsx index baf4206..ff4c486 100644 --- a/apps/web/tests/ProjectDetail.test.tsx +++ b/apps/web/tests/ProjectDetail.test.tsx @@ -276,4 +276,32 @@ describe('ProjectDetail', () => { }); expect(screen.queryByText(/this project is deleted/i)).not.toBeInTheDocument(); }); + + it('shows the "More" actions dropdown for users with management permissions', async () => { + const asAdmin = { + ...PROJECT, + permissions: { ...PROJECT.permissions, canManageMembers: true, canPostUpdate: true, canDelete: true }, + } as unknown as typeof PROJECT; + mockSignedIn(asAdmin, 'administrator'); + renderDetail(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /more/i })).toBeInTheDocument(); + }); + }); + + it('shows no "More" dropdown for anonymous viewers', async () => { + // default beforeEach mock is anonymous with no permissions + renderScreen( + + + } /> + + , + { initialEntries: ['/projects/sample-project'] }, + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Sample Project', level: 1 })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument(); + }); });