+ {messages.length} {messages.length === 1 ? "message" : "messages"} + + } + > + + + {messages.length === 0 ? ( + + No chat messages yet. + + ) : ( + + {messages.map((message) => ( + + + + {shortAddress(message.authorAddress)} + + + {formatDateTime(message.createdAt)} + + + + {message.body} + + + ))} + + )} + + + {canPost ? ( + + setBody(event.target.value)} + placeholder="Write a message" + aria-label="Initiative chat message" + rows={2} + className="max-h-40 min-h-20 resize-y" + /> + - - - {shortAddress(message.authorAddress)} - - - {formatDateTime(message.createdAt)} - - - - {message.body} - - - )) - )} + Send + + + ) : null} - - {canPost ? ( - - setBody(event.target.value)} - placeholder="Write a message" - aria-label="Initiative chat message" - /> - - Send - - - ) : null} {error ? {error} : null} ); diff --git a/src/pages/initiatives/components/InitiativeJoinRequestsSection.tsx b/src/pages/initiatives/components/InitiativeJoinRequestsSection.tsx new file mode 100644 index 0000000..1a66534 --- /dev/null +++ b/src/pages/initiatives/components/InitiativeJoinRequestsSection.tsx @@ -0,0 +1,71 @@ +import { AddressInline } from "@/components/AddressInline"; +import { GlassySection } from "@/components/GlassySection"; +import { Button } from "@/components/primitives/button"; +import { formatDateTime } from "@/lib/dateTime"; +import type { InitiativeJoinRequestDto } from "@/types/api"; + +type InitiativeJoinRequestsSectionProps = { + canModerate: boolean; + joinRequests: InitiativeJoinRequestDto[]; + mutating: boolean; + onApprove: (address: string) => void; + onDecline: (address: string) => void; +}; + +export function InitiativeJoinRequestsSection({ + canModerate, + joinRequests, + mutating, + onApprove, + onDecline, +}: InitiativeJoinRequestsSectionProps) { + const pending = joinRequests.filter( + (request) => request.status === "pending", + ); + if (!canModerate || pending.length === 0) return null; + + return ( + + + {pending.map((request) => ( + + + + + Requested {formatDateTime(request.requestedAt)} + + + + onApprove(request.address)} + > + Accept + + onDecline(request.address)} + > + Decline + + + + ))} + + + ); +} diff --git a/src/pages/initiatives/components/InitiativeSettingsSection.tsx b/src/pages/initiatives/components/InitiativeSettingsSection.tsx index 4c9b5c7..76dc31e 100644 --- a/src/pages/initiatives/components/InitiativeSettingsSection.tsx +++ b/src/pages/initiatives/components/InitiativeSettingsSection.tsx @@ -1,5 +1,5 @@ import type { FormEvent } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { GlassySection } from "@/components/GlassySection"; import { Textarea } from "@/components/Textarea"; @@ -8,35 +8,55 @@ import { Input } from "@/components/primitives/input"; import { Select } from "@/components/primitives/select"; import { apiInitiativeUpdate } from "@/lib/apiClient"; import { initiativeStatusLabel, parseInitiativeTags } from "@/lib/initiativeUi"; -import type { InitiativeDto, InitiativeStatusDto } from "@/types/api"; +import type { + InitiativeDto, + InitiativeStatusDto, + InitiativeVisibilityDto, +} from "@/types/api"; type InitiativeSettingsSectionProps = { initiative: InitiativeDto; onChanged: () => Promise | void; + onClose: () => void; }; export function InitiativeSettingsSection({ initiative, onChanged, + onClose, }: InitiativeSettingsSectionProps) { const [title, setTitle] = useState(initiative.title); const [summary, setSummary] = useState(initiative.summary); const [description, setDescription] = useState(initiative.description); const [tags, setTags] = useState(initiative.tags.join(", ")); const [status, setStatus] = useState(initiative.status); + const [visibility, setVisibility] = useState( + initiative.visibility, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const archivedReadOnly = initiative.status === "archived" && status !== "active"; - useEffect(() => { + const resetDraft = useCallback(() => { setTitle(initiative.title); setSummary(initiative.summary); setDescription(initiative.description); setTags(initiative.tags.join(", ")); setStatus(initiative.status); + setVisibility(initiative.visibility); }, [initiative]); + useEffect(() => { + resetDraft(); + }, [resetDraft]); + + function cancelEditing() { + resetDraft(); + setError(null); + onClose(); + } + async function updateInitiative(event: FormEvent) { event.preventDefault(); if (!title.trim() || !summary.trim()) return; @@ -50,8 +70,10 @@ export function InitiativeSettingsSection({ description: description.trim(), tags: parseInitiativeTags(tags), status, + visibility, }); await onChanged(); + onClose(); } catch (err) { setError((err as Error).message); } finally { @@ -60,7 +82,14 @@ export function InitiativeSettingsSection({ } return ( - + + Cancel + + } + > - + + + setVisibility(event.target.value as InitiativeVisibilityDto) + } + aria-label="Initiative visibility" + > + Public + Private + diff --git a/src/types/api.ts b/src/types/api.ts index c620bf4..442d0e5 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -211,6 +211,17 @@ export type InitiativeReferenceDto = { id: string; title: string; }; +export type InitiativeVisibilityDto = "public" | "private"; +export type InitiativeJoinRequestStatusDto = + | "pending" + | "accepted" + | "declined"; +export type InitiativeJoinRequestDto = { + address: string; + status: InitiativeJoinRequestStatusDto; + requestedAt: string; + respondedAt?: string | null; +}; export type InitiativeThreadDto = { id: string; title: string; @@ -234,6 +245,7 @@ export type InitiativeDto = { title: string; summary: string; description: string; + visibility: InitiativeVisibilityDto; status: InitiativeStatusDto; tags: string[]; createdByAddress: string; @@ -248,6 +260,10 @@ export type InitiativeDto = { viewerRole?: InitiativeRoleDto | null; viewerCanAdmin?: boolean; viewerCanSteward?: boolean; + viewerCanJoin?: boolean; + viewerCanLeave?: boolean; + viewerJoinRequest?: InitiativeJoinRequestDto | null; + joinRequests?: InitiativeJoinRequestDto[]; boardColumns?: InitiativeBoardColumnDto[]; boardCards?: InitiativeBoardCardDto[]; threads?: InitiativeThreadDto[]; diff --git a/tests/api/api-client-runtime.test.js b/tests/api/api-client-runtime.test.js index 6f4d9b8..875ef9e 100644 --- a/tests/api/api-client-runtime.test.js +++ b/tests/api/api-client-runtime.test.js @@ -6,6 +6,10 @@ import { apiInitiative, apiInitiativeBoardCardDelete, apiInitiativeBoardCardUpdate, + apiInitiativeJoin, + apiInitiativeJoinRequestApprove, + apiInitiativeJoinRequestDecline, + apiInitiativeLeave, apiInitiativeUpdate, apiPoolVote, apiProposals, @@ -159,3 +163,51 @@ test("initiative client preserves explicit empty edits and encodes detail ids", global.fetch = originalFetch; }); + +test("initiative membership commands preserve the shared API contract", async () => { + const originalFetch = global.fetch; + const calls = []; + + global.fetch = async (input, init) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await apiInitiativeJoin({ initiativeId: "initiative-1" }); + await apiInitiativeJoinRequestApprove({ + initiativeId: "initiative-1", + address: "member-1", + }); + await apiInitiativeJoinRequestDecline({ + initiativeId: "initiative-1", + address: "member-2", + }); + await apiInitiativeLeave({ initiativeId: "initiative-1" }); + + assert.deepEqual( + calls.map((call) => JSON.parse(call.init.body)), + [ + { + type: "initiative.join", + payload: { initiativeId: "initiative-1" }, + }, + { + type: "initiative.join.request.approve", + payload: { initiativeId: "initiative-1", address: "member-1" }, + }, + { + type: "initiative.join.request.decline", + payload: { initiativeId: "initiative-1", address: "member-2" }, + }, + { + type: "initiative.leave", + payload: { initiativeId: "initiative-1" }, + }, + ], + ); + + global.fetch = originalFetch; +}); diff --git a/tests/unit/initiative-chat-layout-contract.test.ts b/tests/unit/initiative-chat-layout-contract.test.ts new file mode 100644 index 0000000..6a78f84 --- /dev/null +++ b/tests/unit/initiative-chat-layout-contract.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "@rstest/core"; + +const chat = readFileSync( + join( + process.cwd(), + "src/pages/initiatives/components/InitiativeChatSection.tsx", + ), + "utf8", +); +const initiative = readFileSync( + join(process.cwd(), "src/pages/initiatives/Initiative.tsx"), + "utf8", +); + +test("Initiative chat is bounded, scroll-aware, and rendered last", () => { + assert.match(chat, /h-\[clamp\(18rem,52vh,30rem\)\]/); + assert.match(chat, /overflow-y-auto/); + assert.match(chat, /overscroll-contain/); + assert.match(chat, /messageViewportRef/); + assert.match(chat, /stickToBottomRef/); + assert.match(chat, / + initiative.lastIndexOf(" + initiative.lastIndexOf(" { + const page = source("src/pages/initiatives/Initiative.tsx"); + const queue = source( + "src/pages/initiatives/components/InitiativeJoinRequestsSection.tsx", + ); + const runner = source("src/hooks/useActionRunner.ts"); + + assert.ok( + page.indexOf(" { + const initiative = { + status: "active" as const, + viewerRole: "steward" as const, + viewerCanAdmin: false, + viewerCanSteward: true, + viewerCanJoin: false, + viewerCanLeave: true, + }; + + expect( + getInitiativeViewerCapabilities(initiative, { + enabled: true, + loading: false, + authenticated: true, + eligible: false, + }), + ).toEqual({ + canAdmin: false, + canJoin: false, + canLeave: false, + canManage: false, + canParticipate: false, + }); + + expect( + getInitiativeViewerCapabilities(initiative, { + enabled: true, + loading: false, + authenticated: true, + eligible: true, + }), + ).toEqual({ + canAdmin: false, + canJoin: false, + canLeave: true, + canManage: true, + canParticipate: true, + }); +}); + +test("initiative development mode can act without the auth gate", () => { + expect( + getInitiativeViewerCapabilities( + { + status: "active", + viewerRole: null, + viewerCanJoin: true, + }, + { + enabled: false, + loading: false, + authenticated: false, + eligible: false, + }, + ), + ).toMatchObject({ canJoin: true }); +}); + test("proposal drafts retain an Initiative selection that is no longer available", () => { expect( initiativeOptionsWithSelection( @@ -106,3 +168,34 @@ test("initiative tags are normalized and deduplicated", () => { parseInitiativeTags("research, governance, research, governance "), ).toEqual(["research", "governance"]); }); + +test("initiative descriptions omit empty and summary-equivalent content", () => { + expect( + initiativeDistinctDescription( + "Coordinate public work.", + " Coordinate public work. ", + ), + ).toBe(""); + expect(initiativeDistinctDescription("Coordinate public work.", " ")).toBe( + "", + ); + expect( + initiativeDistinctDescription( + "Coordinate public work.", + "Publish evidence and assign owners.", + ), + ).toBe("Publish evidence and assign owners."); +}); + +test("initiative descriptions preserve distinct paragraphs without blank rows", () => { + expect( + initiativeDescriptionParagraphs( + "Analyze voting patterns.\n\nCollect evidence.\n\nReport findings.", + ), + ).toEqual([ + "Analyze voting patterns.", + "Collect evidence.", + "Report findings.", + ]); + expect(initiativeDescriptionParagraphs("")).toEqual([]); +}); diff --git a/tests/unit/workspace-header-contract.test.ts b/tests/unit/workspace-header-contract.test.ts new file mode 100644 index 0000000..fbfc023 --- /dev/null +++ b/tests/unit/workspace-header-contract.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "@rstest/core"; + +function source(path: string) { + return readFileSync(join(process.cwd(), path), "utf8"); +} + +const header = source("src/components/WorkspaceHeader.tsx"); +const initiative = source("src/pages/initiatives/Initiative.tsx"); +const board = source( + "src/pages/initiatives/components/InitiativeBoardSection.tsx", +); +const faction = source("src/pages/factions/components/FactionHero.tsx"); + +test("Initiatives and factions share one workspace header grammar", () => { + assert.match(header, /
+ + + {shortAddress(message.authorAddress)} + + + {formatDateTime(message.createdAt)} + + + + {message.body} + +
+ + + + Requested {formatDateTime(request.requestedAt)} + + + + onApprove(request.address)} + > + Accept + + onDecline(request.address)} + > + Decline + + +