From 8321e21f52112d40095809cd4e820b12c9b33ef4 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Fri, 26 Jun 2026 13:21:49 -0400 Subject: [PATCH] feat(web): add Join/Leave project buttons (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectDetail declared "Join Project" / "Leave project" actions in the spec (project-detail.md) and the API endpoints existed, but the SPA never rendered the buttons. Add them to the sidebar: - Join Project — signed-in users who aren't members (POST .../members/join) - Leave project — members, except a sole maintainer who must transfer the role first (POST .../members/leave); shows an explanatory hint instead. Membership is computed client-side from the members list + the signed-in user (the project response carries no per-viewer flag). Added api client join/leave methods and ProjectDetail tests for both states. First slice of the #113 UI-gaps umbrella. The soft-delete banner (the other half of the ProjectDetail pairing) is deferred — it needs the project response to expose `deletedAt`, a small API/serializer change tracked under #113. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/api.ts | 4 ++ apps/web/src/screens/ProjectDetail.tsx | 63 ++++++++++++++++++++++++ apps/web/tests/ProjectDetail.test.tsx | 66 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3a7bcb9..aad8868 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -591,6 +591,10 @@ export const api = { request(`/api/projects/${encodeURIComponent(slug)}`, { method: 'DELETE' }), restore: (slug: string): Promise> => request(`/api/projects/${encodeURIComponent(slug)}/restore`, { method: 'POST' }), + join: (slug: string): Promise => + request(`/api/projects/${encodeURIComponent(slug)}/members/join`, { method: 'POST' }), + leave: (slug: string): Promise => + request(`/api/projects/${encodeURIComponent(slug)}/members/leave`, { method: 'POST' }), changeMaintainer: (slug: string, personSlug: string): Promise> => request(`/api/projects/${encodeURIComponent(slug)}/change-maintainer`, { method: 'POST', diff --git a/apps/web/src/screens/ProjectDetail.tsx b/apps/web/src/screens/ProjectDetail.tsx index 7a27e7f..2441aa6 100644 --- a/apps/web/src/screens/ProjectDetail.tsx +++ b/apps/web/src/screens/ProjectDetail.tsx @@ -51,6 +51,8 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const [interestRole, setInterestRole] = useState(null); const [fillRole, setFillRole] = useState(null); const [stageInfoOpen, setStageInfoOpen] = useState(false); + const [memberBusy, setMemberBusy] = useState(false); + const [memberError, setMemberError] = useState(null); // 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. @@ -122,6 +124,32 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { const helpWantedRoles = helpWantedQ.data?.data ?? []; const perms = project.permissions; + // #113 — Join / Leave the project. The endpoints exist; the UI was missing. + // The project response carries no per-viewer membership flag, so membership is + // derived from the members list + the signed-in user. + const myMembership = person + ? project.memberships.find((m) => m.person.slug === person.slug) + : undefined; + const isMember = myMembership !== undefined; + const maintainerCount = project.memberships.filter((m) => m.isMaintainer).length; + // A sole maintainer must transfer the role before leaving (project-detail.md authz). + const isSoleMaintainer = (myMembership?.isMaintainer ?? false) && maintainerCount === 1; + const canJoin = isSignedIn && !isMember; + const canLeave = isMember && !isSoleMaintainer; + + const runMembership = async (fn: () => Promise): Promise => { + setMemberBusy(true); + setMemberError(null); + try { + await fn(); + await projectQ.refetch(); + } catch (err) { + setMemberError(err instanceof ApiError ? err.message : 'Something went wrong. Please try again.'); + } finally { + setMemberBusy(false); + } + }; + const allTags = [...project.tags.tech, ...project.tags.topic, ...project.tags.event]; return ( @@ -297,6 +325,41 @@ export function ProjectDetail({ anchor }: ProjectDetailProps = {}) { {/* Sidebar */}