From 67a57b9b066d6a42061659598449927ebb8f09fc Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:03:52 +0400 Subject: [PATCH] Add proposal return-to-draft flows --- src/components/ProposalPageHeader.tsx | 11 +- src/lib/apiClient.ts | 17 + src/pages/proposals/ProposalFinished.tsx | 9 +- src/pages/proposals/ProposalPP.tsx | 9 + src/pages/proposals/draft/OwnerDraftCard.tsx | 19 +- .../draft/ProposalDraftDetailsCard.tsx | 4 + src/pages/proposals/draft/draftUi.ts | 9 + .../proposals/hooks/useProposalStageSync.ts | 3 + .../pool/ReturnProposalToDraftAction.tsx | 131 ++++++++ src/types/api.ts | 16 + tests/e2e/proposal-draft-returns.spec.ts | 310 ++++++++++++++++++ tests/e2e/public-drafts.spec.ts | 5 + tests/unit/proposal-draft-ui.test.ts | 10 + 13 files changed, 543 insertions(+), 10 deletions(-) create mode 100644 src/pages/proposals/pool/ReturnProposalToDraftAction.tsx create mode 100644 tests/e2e/proposal-draft-returns.spec.ts diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx index e6b0e99..315ef17 100644 --- a/src/components/ProposalPageHeader.tsx +++ b/src/components/ProposalPageHeader.tsx @@ -22,6 +22,7 @@ type ProposalPageHeaderProps = { chamber: string; proposer: string; stageLinks?: Partial>; + actions?: ReactNode; children?: ReactNode; }; @@ -33,6 +34,7 @@ export function ProposalPageHeader({ chamber, proposer, stageLinks, + actions, children, }: ProposalPageHeaderProps) { const [status, setStatus] = useState(null); @@ -80,9 +82,12 @@ export function ProposalPageHeader({ return (

{title}

- {proposalId ? ( -
- + {proposalId || actions ? ( +
+ {actions} + {proposalId ? ( + + ) : null}
) : null} {status?.initiative ? ( diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 4dabb06..d557889 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -947,3 +947,20 @@ export async function apiProposalSubmitToPool(input: { idempotencyKey: input.idempotencyKey, }); } + +export async function apiProposalReturnToDraft(input: { + proposalId: string; + idempotencyKey: string; +}): Promise<{ + ok: true; + type: "proposal.returnToDraft"; + proposalId: string; + draftId: string; + draftRoute: string; +}> { + return await apiCommand({ + type: "proposal.returnToDraft", + payload: { proposalId: input.proposalId }, + idempotencyKey: input.idempotencyKey, + }); +} diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx index 534107e..f91e6f3 100644 --- a/src/pages/proposals/ProposalFinished.tsx +++ b/src/pages/proposals/ProposalFinished.tsx @@ -61,12 +61,15 @@ const ProposalFinished: React.FC = () => {
diff --git a/src/pages/proposals/ProposalPP.tsx b/src/pages/proposals/ProposalPP.tsx index 0b19e3a..c7768d3 100644 --- a/src/pages/proposals/ProposalPP.tsx +++ b/src/pages/proposals/ProposalPP.tsx @@ -26,6 +26,7 @@ import { } from "./pool/ProposalPoolRulesModal"; import { ProposalPoolAttentionStats } from "./pool/ProposalPoolAttentionStats"; import { ProposalDetailsSections } from "./shared/ProposalDetailsSections"; +import { ReturnProposalToDraftAction } from "./pool/ReturnProposalToDraftAction"; const ProposalPP: React.FC = () => { const { id } = useParams(); @@ -122,6 +123,14 @@ const ProposalPP: React.FC = () => { showFormationStage={proposal.formationEligible} chamber={proposal.chamber} proposer={proposal.proposer} + actions={ + viewerIsProposer && id ? ( + + ) : undefined + } >
- - {draft.chamber} - +
+ + {draft.chamber} + + {draft.returnSource ? ( + + {proposalDraftReturnSourceLabels[draft.returnSource]} + + ) : null} +
+ {draft.returnSource ? ( + {proposalDraftReturnSourceLabels[draft.returnSource]} + ) : null}
diff --git a/src/pages/proposals/draft/draftUi.ts b/src/pages/proposals/draft/draftUi.ts index 96ada67..3d52c57 100644 --- a/src/pages/proposals/draft/draftUi.ts +++ b/src/pages/proposals/draft/draftUi.ts @@ -1,5 +1,6 @@ import type { DraftPublicationSummaryDto, + ProposalDraftReturnSourceDto, PublicProposalDraftKindDto, } from "@/types/api"; @@ -59,6 +60,14 @@ export const publicDraftKindLabels: Record = system: "System change", }; +export const proposalDraftReturnSourceLabels: Record< + ProposalDraftReturnSourceDto, + string +> = { + failed_chamber_vote: "Returned after chamber vote", + author_pool_withdrawal: "Returned from Proposal Pool", +}; + export const publicDraftKindOptions = ( Object.entries(publicDraftKindLabels) as Array< [PublicProposalDraftKindDto, string] diff --git a/src/pages/proposals/hooks/useProposalStageSync.ts b/src/pages/proposals/hooks/useProposalStageSync.ts index 014a0b7..d1bdd4f 100644 --- a/src/pages/proposals/hooks/useProposalStageSync.ts +++ b/src/pages/proposals/hooks/useProposalStageSync.ts @@ -84,6 +84,9 @@ export function formatProposalStageTransitionMessage( if (status.redirectReason === "veto_remanded") { return "Proposal was remanded for reconsideration."; } + if (status.redirectReason === "returned_to_draft") { + return "Proposal closed and its content returned to a private draft."; + } if (status.canonicalStage === "vote") return "Proposal moved to Chamber vote."; if (status.canonicalStage === "citizen_veto") diff --git a/src/pages/proposals/pool/ReturnProposalToDraftAction.tsx b/src/pages/proposals/pool/ReturnProposalToDraftAction.tsx new file mode 100644 index 0000000..9fa8bd2 --- /dev/null +++ b/src/pages/proposals/pool/ReturnProposalToDraftAction.tsx @@ -0,0 +1,131 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router"; + +import { Modal } from "@/components/Modal"; +import { Surface } from "@/components/Surface"; +import { Button } from "@/components/primitives/button"; +import { apiProposalReturnToDraft, getApiErrorPayload } from "@/lib/apiClient"; +import { formatProposalActionError } from "@/lib/proposalSubmitErrors"; + +type ReturnProposalToDraftActionProps = { + proposalId: string; + onStageChanged: () => Promise; +}; + +export function ReturnProposalToDraftAction({ + proposalId, + onStageChanged, +}: ReturnProposalToDraftActionProps) { + const navigate = useNavigate(); + const triggerRef = useRef(null); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const close = () => { + if (submitting) return; + setOpen(false); + window.requestAnimationFrame(() => triggerRef.current?.focus()); + }; + + const submit = async () => { + setSubmitting(true); + setError(null); + try { + const result = await apiProposalReturnToDraft({ + proposalId, + idempotencyKey: crypto.randomUUID(), + }); + navigate(result.draftRoute); + } catch (cause) { + if ( + getApiErrorPayload(cause)?.error?.code === + "proposal_return_stage_invalid" + ) { + const redirected = await onStageChanged(); + if (redirected) return; + } + setError( + formatProposalActionError( + cause, + "The proposal could not be returned to drafts.", + ), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + + { + if (nextOpen) setOpen(true); + else close(); + }} + ariaLabel="Return proposal to drafts" + contentClassName="max-w-xl" + > + +
+

+ Return to drafts? +

+

+ This Proposal Pool entry will close and a private editable draft + will be added to My Drafts. +

+
+
    +
  • Existing votes and proposal history remain visible.
  • +
  • The returned draft stays private until you publish it.
  • +
  • A revised submission starts again in Proposal Pool.
  • +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+
+ + ); +} diff --git a/src/types/api.ts b/src/types/api.ts index 4b08ae9..84d1295 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -15,6 +15,7 @@ export type FeedStageDto = FeedStage; export type ProposalResolutionKindDto = | "ordinary_failed_pool" | "ordinary_failed_vote" + | "author_withdrawn_to_draft" | "court_correction_remand" | "citizen_veto_remand" | "chamber_veto_remand" @@ -735,9 +736,21 @@ export type ProposalStatusDto = { route: string; revision: number; }; + draftReturn?: ProposalDraftReturnDto; updatedAt: string; }; +export type ProposalDraftReturnSourceDto = + | "failed_chamber_vote" + | "author_pool_withdrawal"; + +export type ProposalDraftReturnDto = { + reason: ProposalDraftReturnSourceDto; + available: boolean; + draftId?: string; + route?: string; +}; + export type DraftPublicationStatusDto = | "private" | "published" @@ -761,6 +774,7 @@ export type ProposalDraftListItemDto = { tier: string; summary: string; updated: string; + returnSource: ProposalDraftReturnSourceDto | null; publication: DraftPublicationSummaryDto; }; export type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] }; @@ -858,6 +872,7 @@ export type ProposalDraftDetailDto = { attachments: { title: string; href?: string }[]; authoring: ProposalAuthoringDetailsDto; publication: DraftPublicationSummaryDto; + returnSource?: ProposalDraftReturnSourceDto | null; initiative?: InitiativeReferenceDto; editableForm?: ProposalDraftEditableFormDto; }; @@ -1160,6 +1175,7 @@ export type ProposalFinishedPageDto = { decisionRootProposalId: string; canReconsider: boolean; reconsiderationDraftId: string | null; + draftReturn: ProposalDraftReturnDto | null; formationEligible: boolean; budget: string; timeLeft: string; diff --git a/tests/e2e/proposal-draft-returns.spec.ts b/tests/e2e/proposal-draft-returns.spec.ts new file mode 100644 index 0000000..6526388 --- /dev/null +++ b/tests/e2e/proposal-draft-returns.spec.ts @@ -0,0 +1,310 @@ +import { expect, test, type Page } from "@playwright/test"; + +import type { + PoolProposalPageDto, + ProposalFinishedPageDto, + ProposalStatusDto, +} from "../../src/types/api"; + +const proposalId = "revision-safe-policy"; +const owner = "hmr1GRb1SRdDfJZmFaYh5L1RNev3dFcTVLGS2Rqqmk3Fbgj2W"; +const other = "hmrNAGavT2UK35yZN9Txv7yucaH36cmztkseoqfgFTcPyfEU6"; +const returnedDraftId = `draft-reconsider-${proposalId}`; + +const authoring = { + kind: "project" as const, + presetId: "project.policy", + proposalType: "basic", + what: "Keep governance history while revision work continues privately.", + why: "Reviewers need an auditable outcome and proposers need an editable copy.", + how: "Close the live entry, create one linked draft, and resubmit through Proposal Pool.", + aboutMe: "Policy proposer", + outputs: [], + timeline: [], + budgetItems: [], + systemAction: null, +}; + +const poolPage = { + title: "Revision-safe policy", + proposer: owner, + proposerId: owner, + chamber: "General Chamber", + focus: "Basic", + tier: "Consul", + budget: "0 HMND", + cooldown: "Ready", + formationEligible: false, + timeLeft: "2d 4h", + teamSlots: "0 / 0", + milestones: "0", + upvotes: 2, + downvotes: 0, + attentionQuorum: 0.3, + activeGovernors: 5, + upvoteFloor: 1, + rules: ["Active Governors may vote once while the pool remains open."], + attachments: [], + teamLocked: [], + openSlotNeeds: [], + milestonesDetail: [], + summary: + "A safe way to revise proposals without erasing their governance record.", + overview: authoring.what, + executionPlan: [], + budgetScope: "No Formation budget", + authoring, +} satisfies PoolProposalPageDto; + +function statusFor(stage: "pool" | "failed"): ProposalStatusDto { + return { + proposalId, + canonicalStage: stage, + canonicalRoute: + stage === "pool" + ? `/app/proposals/${proposalId}/pp` + : `/app/proposals/${proposalId}/finished`, + ...(stage === "failed" + ? { + redirectReason: "returned_to_draft", + draftReturn: { + reason: "failed_chamber_vote" as const, + available: true, + draftId: returnedDraftId, + route: `/app/proposals/new?draftId=${returnedDraftId}`, + }, + } + : {}), + updatedAt: "2026-08-27T10:00:00.000Z", + }; +} + +const finishedPage = { + title: poolPage.title, + chamber: poolPage.chamber, + proposer: owner, + proposerId: owner, + terminalStage: "failed", + resolutionKind: "ordinary_failed_vote", + terminalLabel: "Returned after chamber vote", + terminalSummary: + "This proposal did not pass chamber vote. Its governance history remains here, and a private revision draft was returned to the proposer.", + decisionRootProposalId: proposalId, + canReconsider: true, + reconsiderationDraftId: returnedDraftId, + draftReturn: { + reason: "failed_chamber_vote", + available: true, + draftId: returnedDraftId, + route: `/app/proposals/new?draftId=${returnedDraftId}`, + }, + formationEligible: false, + budget: "0 HMND", + timeLeft: "Ended", + stageData: [ + { + title: "Outcome", + description: "Chamber vote did not pass", + value: "Returned to private draft", + }, + ], + stats: [ + { label: "Budget ask", value: "0 HMND" }, + { label: "Result", value: "Returned to draft" }, + ], + lockedTeam: [], + openSlots: [], + milestonesDetail: [], + attachments: [], + summary: poolPage.summary, + overview: poolPage.overview, + executionPlan: [], + budgetScope: poolPage.budgetScope, + authoring, +} satisfies ProposalFinishedPageDto; + +async function installProposalFixtures( + page: Page, + input: { + viewer: string; + stage?: "pool" | "failed"; + onCommand?: (body: Record) => void; + }, +) { + const stage = input.stage ?? "pool"; + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.pathname === "/api/me") { + await route.fulfill({ + json: { + authenticated: true, + address: input.viewer, + gate: { eligible: true, expiresAt: "2026-09-01T00:00:00.000Z" }, + }, + }); + return; + } + if (url.pathname.startsWith("/api/humans/")) { + await route.fulfill({ + json: { + id: input.viewer, + name: "Proposal reviewer", + humanNodeActive: true, + governor: true, + governorActive: true, + heroStats: [], + quickDetails: [], + proofSections: {}, + governanceActions: [], + delegation: { chambers: [] }, + delegationEligibleChambers: [], + projects: [], + activity: [], + history: [], + }, + }); + return; + } + if (url.pathname === `/api/proposals/${proposalId}/pool`) { + await route.fulfill({ json: poolPage }); + return; + } + if (url.pathname === `/api/proposals/${proposalId}/finished`) { + await route.fulfill({ json: finishedPage }); + return; + } + if (url.pathname === `/api/proposals/${proposalId}/status`) { + await route.fulfill({ json: statusFor(stage) }); + return; + } + if (url.pathname === `/api/proposals/${proposalId}/timeline`) { + await route.fulfill({ json: { items: [] } }); + return; + } + if (url.pathname === `/api/proposals/${proposalId}/threads`) { + await route.fulfill({ + json: { proposalId, permissions: { canCreate: true }, items: [] }, + }); + return; + } + if (url.pathname === "/api/command" && request.method() === "POST") { + const body = request.postDataJSON() as Record; + input.onCommand?.(body); + await route.fulfill({ + json: { + ok: true, + type: "proposal.returnToDraft", + proposalId, + draftId: returnedDraftId, + draftRoute: `/app/proposals/new?draftId=${returnedDraftId}`, + }, + }); + return; + } + await route.fulfill({ json: { items: [] } }); + }); +} + +test("the proposer returns a Proposal Pool entry through an explicit confirmation", async ({ + page, +}) => { + let command: Record | null = null; + await installProposalFixtures(page, { + viewer: owner, + onCommand: (body) => { + command = body; + }, + }); + + await page.goto(`/app/proposals/${proposalId}/pp`); + const trigger = page.getByRole("button", { name: "Return to drafts" }); + await expect(trigger).toBeVisible(); + await trigger.click(); + + const dialog = page.getByRole("dialog", { + name: "Return proposal to drafts", + }); + await expect( + dialog.getByRole("heading", { name: "Return to drafts?" }), + ).toBeVisible(); + await expect( + dialog.getByText("Existing votes and proposal history remain visible."), + ).toBeVisible(); + await expect( + dialog.getByText("The returned draft stays private until you publish it."), + ).toBeVisible(); + await dialog.getByRole("button", { name: "Return to drafts" }).click(); + + await expect(page).toHaveURL(`/app/proposals/new?draftId=${returnedDraftId}`); + expect(command).toMatchObject({ + type: "proposal.returnToDraft", + payload: { proposalId }, + }); + expect( + (command as { idempotencyKey?: string } | null)?.idempotencyKey, + ).toBeTruthy(); +}); + +test("a different viewer cannot see the Proposal Pool return action", async ({ + page, +}) => { + await installProposalFixtures(page, { viewer: other }); + await page.goto(`/app/proposals/${proposalId}/pp`); + await expect( + page.getByRole("button", { name: "Return to drafts" }), + ).toHaveCount(0); +}); + +test("only the proposer receives the returned-draft continuation on the public outcome", async ({ + page, +}) => { + await installProposalFixtures(page, { viewer: owner, stage: "failed" }); + await page.goto(`/app/proposals/${proposalId}/finished`); + await expect( + page.getByText("Returned after chamber vote", { exact: true }).first(), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Continue editing" }), + ).toHaveAttribute("href", `/app/proposals/new?draftId=${returnedDraftId}`); +}); + +for (const theme of ["sky", "light", "night", "fire"] as const) { + for (const width of [390, 768, 1024, 1440]) { + test(`return controls stay contained in ${theme} at ${width}px`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript((selectedTheme) => { + localStorage.setItem("vortex.theme", selectedTheme); + }, theme); + await installProposalFixtures(page, { viewer: owner }); + await page.goto(`/app/proposals/${proposalId}/pp`); + + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + const trigger = page.getByRole("button", { name: "Return to drafts" }); + await expect(trigger).toBeVisible(); + const triggerBox = await trigger.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(triggerBox!.x).toBeGreaterThanOrEqual(0); + expect(triggerBox!.x + triggerBox!.width).toBeLessThanOrEqual(width); + + await trigger.click(); + const dialog = page.getByRole("dialog", { + name: "Return proposal to drafts", + }); + await expect(dialog).toBeVisible(); + const dialogBox = await dialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.x).toBeGreaterThanOrEqual(0); + expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(width); + + await page.screenshot({ + path: testInfo.outputPath( + `proposal-draft-return-${theme}-${width}.png`, + ), + fullPage: true, + }); + }); + } +} diff --git a/tests/e2e/public-drafts.spec.ts b/tests/e2e/public-drafts.spec.ts index 7df2c75..08fca32 100644 --- a/tests/e2e/public-drafts.spec.ts +++ b/tests/e2e/public-drafts.spec.ts @@ -196,6 +196,7 @@ test("My Drafts keeps private and public drafts together and toggles visibility" tier: "Consul", summary: "Notes that are ready to become a public draft.", updated: "2026-07-24T13:00:00.000Z", + returnSource: "failed_chamber_vote", publication: { status: "private" }, }; const publicDraft = { @@ -205,6 +206,7 @@ test("My Drafts keeps private and public drafts together and toggles visibility" tier: "Consul", summary: "An owned draft that is already visible publicly.", updated: "2026-07-24T12:00:00.000Z", + returnSource: null, publication: { status: "published", revision: 2, @@ -302,6 +304,9 @@ test("My Drafts keeps private and public drafts together and toggles visibility" await expect( page.getByText(privateDraft.title, { exact: true }), ).toBeVisible(); + await expect( + page.getByText("Returned after chamber vote", { exact: true }), + ).toBeVisible(); await expect( page.getByText(publicDraft.title, { exact: true }), ).toBeVisible(); diff --git a/tests/unit/proposal-draft-ui.test.ts b/tests/unit/proposal-draft-ui.test.ts index c1ff7c7..f35b667 100644 --- a/tests/unit/proposal-draft-ui.test.ts +++ b/tests/unit/proposal-draft-ui.test.ts @@ -6,6 +6,7 @@ import { isPublicDraftVisible, ownerDraftRoute, publicationRoute, + proposalDraftReturnSourceLabels, publicDraftRoute, reconsiderProposalRoute, } from "../../src/pages/proposals/draft/draftUi"; @@ -43,4 +44,13 @@ describe("proposal draft UI contracts", () => { expect(isPublicDraftVisible({ status: "submitted" })).toBe(true); expect(isPublicDraftVisible({ status: "withdrawn" })).toBe(false); }); + + test("labels returned drafts by their governance source", () => { + expect(proposalDraftReturnSourceLabels.failed_chamber_vote).toBe( + "Returned after chamber vote", + ); + expect(proposalDraftReturnSourceLabels.author_pool_withdrawal).toBe( + "Returned from Proposal Pool", + ); + }); });