Skip to content

Commit 3413769

Browse files
Formation v1 (#26)
* feat(phase-67): ship factions v1 flows and governance surface polish * feat(formation): wire Phase 71 milestone vote + proposer finish flow - add API client methods for formation.milestone.vote and formation.project.finish - extend formation DTO with projectState, pendingMilestoneIndex, nextMilestoneIndex - update ProposalFormation UI action gating by lifecycle state - add/update DTO parser test coverage for new formation fields
1 parent d92f4bd commit 3413769

File tree

4 files changed

+147
-20
lines changed

4 files changed

+147
-20
lines changed

src/lib/apiClient.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,74 @@ export async function apiFormationMilestoneRequestUnlock(input: {
533533
);
534534
}
535535

536+
export async function apiFormationMilestoneVote(input: {
537+
proposalId: string;
538+
milestoneIndex: number;
539+
choice: "yes" | "no" | "abstain";
540+
score?: number;
541+
idempotencyKey?: string;
542+
}): Promise<{
543+
ok: true;
544+
type: "formation.milestone.vote";
545+
proposalId: string;
546+
milestoneIndex: number;
547+
choice: "yes" | "no" | "abstain";
548+
counts: { yes: number; no: number; abstain: number };
549+
outcome: "pending" | "accepted" | "rejected";
550+
projectState:
551+
| "active"
552+
| "awaiting_milestone_vote"
553+
| "suspended"
554+
| "ready_to_finish"
555+
| "completed";
556+
pendingMilestoneIndex: number | null;
557+
milestones: { completed: number; total: number };
558+
}> {
559+
return await apiPost(
560+
"/api/command",
561+
{
562+
type: "formation.milestone.vote",
563+
payload: {
564+
proposalId: input.proposalId,
565+
milestoneIndex: input.milestoneIndex,
566+
choice: input.choice,
567+
...(input.choice === "yes" && typeof input.score === "number"
568+
? { score: input.score }
569+
: {}),
570+
},
571+
idempotencyKey: input.idempotencyKey,
572+
},
573+
input.idempotencyKey
574+
? { headers: { "idempotency-key": input.idempotencyKey } }
575+
: undefined,
576+
);
577+
}
578+
579+
export async function apiFormationProjectFinish(input: {
580+
proposalId: string;
581+
idempotencyKey?: string;
582+
}): Promise<{
583+
ok: true;
584+
type: "formation.project.finish";
585+
proposalId: string;
586+
projectState: "completed";
587+
milestones: { completed: number; total: number };
588+
}> {
589+
return await apiPost(
590+
"/api/command",
591+
{
592+
type: "formation.project.finish",
593+
payload: {
594+
proposalId: input.proposalId,
595+
},
596+
idempotencyKey: input.idempotencyKey,
597+
},
598+
input.idempotencyKey
599+
? { headers: { "idempotency-key": input.idempotencyKey } }
600+
: undefined,
601+
);
602+
}
603+
536604
export async function apiProposalChamberPage(
537605
id: string,
538606
): Promise<ChamberProposalPageDto> {

src/pages/proposals/ProposalFormation.tsx

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
} from "@/components/ProposalSections";
1313
import {
1414
apiFormationJoin,
15-
apiFormationMilestoneRequestUnlock,
1615
apiFormationMilestoneSubmit,
16+
apiFormationMilestoneVote,
17+
apiFormationProjectFinish,
1718
apiProposalFormationPage,
1819
apiProposalTimeline,
1920
} from "@/lib/apiClient";
@@ -100,8 +101,32 @@ const ProposalFormation: React.FC = () => {
100101
};
101102

102103
const milestones = parseRatio(project.milestones);
103-
const nextMilestone =
104-
milestones.total > 0 ? milestones.filled + 1 : undefined;
104+
const nextMilestone = project.nextMilestoneIndex ?? undefined;
105+
const pendingMilestone = project.pendingMilestoneIndex ?? undefined;
106+
const isProposer =
107+
Boolean(auth.address) &&
108+
auth.address?.trim().toLowerCase() ===
109+
project.proposer.trim().toLowerCase();
110+
const canSubmitMilestone =
111+
auth.authenticated &&
112+
auth.eligible &&
113+
!actionBusy &&
114+
project.projectState === "active" &&
115+
typeof nextMilestone === "number" &&
116+
nextMilestone > 0 &&
117+
nextMilestone <= milestones.total;
118+
const canVoteMilestone =
119+
auth.authenticated &&
120+
auth.eligible &&
121+
!actionBusy &&
122+
project.projectState === "awaiting_milestone_vote" &&
123+
typeof pendingMilestone === "number" &&
124+
pendingMilestone > 0;
125+
const canFinishProject =
126+
auth.authenticated &&
127+
isProposer &&
128+
!actionBusy &&
129+
project.projectState === "ready_to_finish";
105130

106131
const runAction = async (fn: () => Promise<void>) => {
107132
setActionError(null);
@@ -156,13 +181,7 @@ const ProposalFormation: React.FC = () => {
156181
type="button"
157182
size="lg"
158183
variant="outline"
159-
disabled={
160-
!auth.authenticated ||
161-
!auth.eligible ||
162-
actionBusy ||
163-
!nextMilestone ||
164-
nextMilestone > milestones.total
165-
}
184+
disabled={!canSubmitMilestone}
166185
onClick={() =>
167186
void runAction(async () => {
168187
if (!id || !nextMilestone) return;
@@ -180,24 +199,53 @@ const ProposalFormation: React.FC = () => {
180199
type="button"
181200
size="lg"
182201
variant="outline"
183-
disabled={
184-
!auth.authenticated ||
185-
!auth.eligible ||
186-
actionBusy ||
187-
!nextMilestone ||
188-
nextMilestone > milestones.total
202+
disabled={!canVoteMilestone}
203+
onClick={() =>
204+
void runAction(async () => {
205+
if (!id || !pendingMilestone) return;
206+
await apiFormationMilestoneVote({
207+
proposalId: id,
208+
milestoneIndex: pendingMilestone,
209+
choice: "yes",
210+
});
211+
})
189212
}
213+
>
214+
Vote Yes M{pendingMilestone ?? "—"}
215+
</Button>
216+
217+
<Button
218+
type="button"
219+
size="lg"
220+
variant="outline"
221+
disabled={!canVoteMilestone}
190222
onClick={() =>
191223
void runAction(async () => {
192-
if (!id || !nextMilestone) return;
193-
await apiFormationMilestoneRequestUnlock({
224+
if (!id || !pendingMilestone) return;
225+
await apiFormationMilestoneVote({
194226
proposalId: id,
195-
milestoneIndex: nextMilestone,
227+
milestoneIndex: pendingMilestone,
228+
choice: "no",
196229
});
197230
})
198231
}
199232
>
200-
Unlock M{nextMilestone ?? "—"}
233+
Vote No M{pendingMilestone ?? "—"}
234+
</Button>
235+
236+
<Button
237+
type="button"
238+
size="lg"
239+
variant="outline"
240+
disabled={!canFinishProject}
241+
onClick={() =>
242+
void runAction(async () => {
243+
if (!id) return;
244+
await apiFormationProjectFinish({ proposalId: id });
245+
})
246+
}
247+
>
248+
Finish Project
201249
</Button>
202250
</div>
203251

src/types/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,14 @@ export type FormationProposalPageDto = {
479479
chamber: string;
480480
proposer: string;
481481
proposerId: string;
482+
projectState:
483+
| "active"
484+
| "awaiting_milestone_vote"
485+
| "suspended"
486+
| "ready_to_finish"
487+
| "completed";
488+
pendingMilestoneIndex: number | null;
489+
nextMilestoneIndex: number | null;
482490
budget: string;
483491
timeLeft: string;
484492
teamSlots: string;

tests/unit/dto-parsers.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ test("formation progress mapping uses ratios", () => {
7070
chamber: "General chamber",
7171
proposer: "Alice",
7272
proposerId: "0xalice",
73+
projectState: "active",
74+
pendingMilestoneIndex: null,
75+
nextMilestoneIndex: 2,
7376
budget: "0 HMND",
7477
timeLeft: "—",
7578
teamSlots: "2/5",

0 commit comments

Comments
 (0)