Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/components/ProposalPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type ProposalPageHeaderProps = {
chamber: string;
proposer: string;
stageLinks?: Partial<Record<ProposalStage, string>>;
actions?: ReactNode;
children?: ReactNode;
};

Expand All @@ -33,6 +34,7 @@ export function ProposalPageHeader({
chamber,
proposer,
stageLinks,
actions,
children,
}: ProposalPageHeaderProps) {
const [status, setStatus] = useState<ProposalStatusDto | null>(null);
Expand Down Expand Up @@ -80,9 +82,12 @@ export function ProposalPageHeader({
return (
<section className="space-y-4">
<h1 className="text-center text-2xl font-semibold text-text">{title}</h1>
{proposalId ? (
<div className="flex justify-end">
<CourtReportButton target={{ type: "proposal", id: proposalId }} />
{proposalId || actions ? (
<div className="flex flex-wrap justify-end gap-2">
{actions}
{proposalId ? (
<CourtReportButton target={{ type: "proposal", id: proposalId }} />
) : null}
</div>
) : null}
{status?.initiative ? (
Expand Down
17 changes: 17 additions & 0 deletions src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
9 changes: 6 additions & 3 deletions src/pages/proposals/ProposalFinished.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,15 @@ const ProposalFinished: React.FC = () => {
<Button asChild size="sm">
<Link
to={
proposal.reconsiderationDraftId
proposal.draftReturn?.route ??
(proposal.reconsiderationDraftId
? editDraftRoute(proposal.reconsiderationDraftId)
: reconsiderProposalRoute(proposal.decisionRootProposalId)
: reconsiderProposalRoute(proposal.decisionRootProposalId))
}
>
Resubmit for reconsideration
{proposal.draftReturn
? "Continue editing"
: "Resubmit for reconsideration"}
</Link>
</Button>
</div>
Expand Down
9 changes: 9 additions & 0 deletions src/pages/proposals/ProposalPP.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -122,6 +123,14 @@ const ProposalPP: React.FC = () => {
showFormationStage={proposal.formationEligible}
chamber={proposal.chamber}
proposer={proposal.proposer}
actions={
viewerIsProposer && id ? (
<ReturnProposalToDraftAction
proposalId={id}
onStageChanged={syncProposalStage}
/>
) : undefined
}
>
<div className="flex flex-wrap items-center justify-center gap-4">
<VoteButton
Expand Down
19 changes: 15 additions & 4 deletions src/pages/proposals/draft/OwnerDraftCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import type {
ProposalDraftListItemDto,
} from "@/types/api";
import { DraftPublicationActions } from "./DraftPublicationActions";
import { editDraftRoute, ownerDraftRoute } from "./draftUi";
import {
editDraftRoute,
ownerDraftRoute,
proposalDraftReturnSourceLabels,
} from "./draftUi";

type OwnerDraftCardProps = {
draft: ProposalDraftListItemDto;
Expand All @@ -27,9 +31,16 @@ export function OwnerDraftCard({
return (
<GlassyCard as="article" className="flex h-full min-h-64 flex-col p-5">
<div className="flex min-w-0 items-start justify-between gap-3">
<Chip className="min-h-7 max-w-[65%] bg-[var(--control-glass-bg)] text-muted [&>span]:whitespace-normal">
{draft.chamber}
</Chip>
<div className="flex max-w-[70%] min-w-0 flex-wrap gap-2">
<Chip className="min-h-7 bg-[var(--control-glass-bg)] text-muted [&>span]:whitespace-normal">
{draft.chamber}
</Chip>
{draft.returnSource ? (
<Chip className="min-h-7 bg-[var(--primary-dim)] text-text [&>span]:whitespace-normal">
{proposalDraftReturnSourceLabels[draft.returnSource]}
</Chip>
) : null}
</div>
<DraftPublicationActions
variant="visibility-toggle"
draftId={draft.id}
Expand Down
4 changes: 4 additions & 0 deletions src/pages/proposals/draft/ProposalDraftDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TierLabel } from "@/components/TierLabel";
import type { ProposalDraftDetailDto } from "@/types/api";
import { parseRatioPair } from "@/lib/dtoParsers";
import { ProposalDetailsSections } from "../shared/ProposalDetailsSections";
import { proposalDraftReturnSourceLabels } from "./draftUi";

type ProposalDraftDetailsCardProps = {
draft: ProposalDraftDetailDto;
Expand Down Expand Up @@ -38,6 +39,9 @@ export const ProposalDraftDetailsCard: React.FC<
<Chip>
<TierLabel tier={draft.tier} />
</Chip>
{draft.returnSource ? (
<Chip>{proposalDraftReturnSourceLabels[draft.returnSource]}</Chip>
) : null}
</div>
</ProposalPageHeader>

Expand Down
9 changes: 9 additions & 0 deletions src/pages/proposals/draft/draftUi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
DraftPublicationSummaryDto,
ProposalDraftReturnSourceDto,
PublicProposalDraftKindDto,
} from "@/types/api";

Expand Down Expand Up @@ -59,6 +60,14 @@ export const publicDraftKindLabels: Record<PublicProposalDraftKindDto, string> =
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]
Expand Down
3 changes: 3 additions & 0 deletions src/pages/proposals/hooks/useProposalStageSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
131 changes: 131 additions & 0 deletions src/pages/proposals/pool/ReturnProposalToDraftAction.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
};

export function ReturnProposalToDraftAction({
proposalId,
onStageChanged,
}: ReturnProposalToDraftActionProps) {
const navigate = useNavigate();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<>
<Button
ref={triggerRef}
type="button"
size="sm"
variant="outline"
onClick={() => {
setError(null);
setOpen(true);
}}
>
Return to drafts
</Button>

<Modal
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) setOpen(true);
else close();
}}
ariaLabel="Return proposal to drafts"
contentClassName="max-w-xl"
>
<Surface
variant="panel"
radius="2xl"
shadow="popover"
className="space-y-5 p-6"
>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-text">
Return to drafts?
</h2>
<p className="text-sm leading-6 text-muted">
This Proposal Pool entry will close and a private editable draft
will be added to My Drafts.
</p>
</div>
<ul className="space-y-2 text-sm leading-6 text-muted">
<li>Existing votes and proposal history remain visible.</li>
<li>The returned draft stays private until you publish it.</li>
<li>A revised submission starts again in Proposal Pool.</li>
</ul>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={submitting}
onClick={close}
>
Keep in pool
</Button>
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => void submit()}
>
{submitting ? "Returning..." : "Return to drafts"}
</Button>
</div>
</Surface>
</Modal>
</>
);
}
16 changes: 16 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -761,6 +774,7 @@ export type ProposalDraftListItemDto = {
tier: string;
summary: string;
updated: string;
returnSource: ProposalDraftReturnSourceDto | null;
publication: DraftPublicationSummaryDto;
};
export type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] };
Expand Down Expand Up @@ -858,6 +872,7 @@ export type ProposalDraftDetailDto = {
attachments: { title: string; href?: string }[];
authoring: ProposalAuthoringDetailsDto;
publication: DraftPublicationSummaryDto;
returnSource?: ProposalDraftReturnSourceDto | null;
initiative?: InitiativeReferenceDto;
editableForm?: ProposalDraftEditableFormDto;
};
Expand Down Expand Up @@ -1160,6 +1175,7 @@ export type ProposalFinishedPageDto = {
decisionRootProposalId: string;
canReconsider: boolean;
reconsiderationDraftId: string | null;
draftReturn: ProposalDraftReturnDto | null;
formationEligible: boolean;
budget: string;
timeLeft: string;
Expand Down
Loading
Loading