From 9141ac4521c8b53d8865774090ba9fb65246de81 Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:16:56 +0400 Subject: [PATCH] Add public proposal draft experience Let authors publish, unpublish, share, and continue editing proposal drafts while exposing public discovery and read-only draft pages. Reuse shared publication controls, draft cards, routes, and governance identity statuses across the interface. --- src/app/AppRoutes.tsx | 4 + src/app/auth/AuthContext.tsx | 79 +++-- src/components/GovernanceStatusPills.tsx | 19 ++ src/components/ProposalPageHeader.tsx | 2 + src/components/ProposalStageBar.tsx | 4 + src/components/StageChip.css | 6 + src/components/StageChip.tsx | 1 + src/lib/apiClient.ts | 72 ++++ src/lib/humanNodesUi.ts | 43 ++- src/pages/feed/hooks/useFeedChamberFilters.ts | 12 +- src/pages/human-nodes/HumanNode.tsx | 7 + src/pages/human-nodes/HumanNodes.tsx | 3 +- .../human-nodes/components/HumanNodeHero.tsx | 21 +- .../components/HumanNodesResultsCard.tsx | 4 + src/pages/initiatives/Initiative.tsx | 4 + src/pages/profile/Profile.tsx | 5 + src/pages/profile/components/ProfileHero.tsx | 19 +- src/pages/proposals/ProposalCreation.tsx | 121 ++++++- src/pages/proposals/ProposalDraft.tsx | 47 +-- src/pages/proposals/ProposalDrafts.tsx | 61 ++-- src/pages/proposals/ProposalFinished.tsx | 5 +- src/pages/proposals/Proposals.tsx | 29 +- src/pages/proposals/PublicDraft.tsx | 125 +++++++ src/pages/proposals/PublicDrafts.tsx | 212 ++++++++++++ src/pages/proposals/draft/CopyLinkButton.tsx | 46 +++ .../draft/DraftPublicationActions.tsx | 135 ++++++++ src/pages/proposals/draft/OwnerDraftCard.tsx | 68 ++++ .../draft/ProposalDraftDetailsCard.tsx | 245 ++++--------- src/pages/proposals/draft/PublicDraftCard.tsx | 56 +++ .../proposals/draft/PublicDraftsSection.tsx | 24 ++ src/pages/proposals/draft/draftUi.ts | 66 ++++ .../draft/useDraftPublicationActions.ts | 107 ++++++ src/pages/proposals/draft/usePublicDrafts.ts | 43 +++ .../proposalCreation/ProposalWizardShell.tsx | 32 +- .../proposalCreation/publicationReadiness.ts | 15 + .../proposalCreation/submitErrorRouting.ts | 17 + .../useProposalDraftHydration.ts | 3 + .../proposals/proposalCreation/wizardModel.ts | 13 +- src/types/api.ts | 50 +++ src/types/stages.ts | 5 +- tests/e2e/proposal-wizard.spec.ts | 142 ++++++++ tests/e2e/public-drafts.spec.ts | 321 ++++++++++++++++++ tests/unit/human-nodes-ui.test.ts | 29 +- tests/unit/proposal-draft-ui.test.ts | 46 +++ tests/unit/proposal-stage-navigation.test.ts | 15 + tests/unit/proposal-wizard-model.test.ts | 43 +++ .../proposal-wizard-submit-routing.test.ts | 17 + 47 files changed, 2098 insertions(+), 345 deletions(-) create mode 100644 src/components/GovernanceStatusPills.tsx create mode 100644 src/pages/proposals/PublicDraft.tsx create mode 100644 src/pages/proposals/PublicDrafts.tsx create mode 100644 src/pages/proposals/draft/CopyLinkButton.tsx create mode 100644 src/pages/proposals/draft/DraftPublicationActions.tsx create mode 100644 src/pages/proposals/draft/OwnerDraftCard.tsx create mode 100644 src/pages/proposals/draft/PublicDraftCard.tsx create mode 100644 src/pages/proposals/draft/PublicDraftsSection.tsx create mode 100644 src/pages/proposals/draft/draftUi.ts create mode 100644 src/pages/proposals/draft/useDraftPublicationActions.ts create mode 100644 src/pages/proposals/draft/usePublicDrafts.ts create mode 100644 src/pages/proposals/proposalCreation/publicationReadiness.ts create mode 100644 tests/e2e/public-drafts.spec.ts create mode 100644 tests/unit/proposal-draft-ui.test.ts diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index bb32730..ec836b3 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -31,6 +31,8 @@ import ProposalCreation from "../pages/proposals/ProposalCreation"; import MyGovernance from "../pages/MyGovernance"; import ProposalDrafts from "../pages/proposals/ProposalDrafts"; import ProposalDraft from "../pages/proposals/ProposalDraft"; +import PublicDraft from "../pages/proposals/PublicDraft"; +import PublicDrafts from "../pages/proposals/PublicDrafts"; import FullHistory from "../pages/human-nodes/FullHistory"; import Landing from "../pages/Landing"; import Paper from "../pages/Paper"; @@ -92,6 +94,8 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/app/auth/AuthContext.tsx b/src/app/auth/AuthContext.tsx index dcd3c6f..96e350e 100644 --- a/src/app/auth/AuthContext.tsx +++ b/src/app/auth/AuthContext.tsx @@ -21,6 +21,7 @@ import { getPolkadotAccounts, signPolkadotMessage, } from "@/lib/polkadotExtension"; +import { governanceIdentityStatuses } from "@/lib/humanNodesUi"; type AuthState = { enabled: boolean; @@ -210,7 +211,8 @@ export function useAuth(): AuthContextValue { export function AuthSidebarPanel() { const auth = useAuth(); const [activityState, setActivityState] = useState<{ - governorActive: boolean; + governor: boolean; + activeGovernor: boolean; humanNodeActive: boolean; } | null>(null); @@ -227,12 +229,17 @@ export function AuthSidebarPanel() { const profile = await apiHuman(address); if (!active) return; setActivityState({ - governorActive: profile.governorActive, + governor: profile.governor, + activeGovernor: profile.governorActive, humanNodeActive: profile.humanNodeActive, }); } catch { if (!active) return; - setActivityState({ governorActive: false, humanNodeActive: false }); + setActivityState({ + governor: false, + activeGovernor: false, + humanNodeActive: false, + }); } }; @@ -255,9 +262,15 @@ export function AuthSidebarPanel() { const humanNodeActive = Boolean( auth.authenticated && activityState?.humanNodeActive, ); - const governorActive = Boolean( - auth.authenticated && activityState?.governorActive, + const governor = Boolean(auth.authenticated && activityState?.governor); + const activeGovernor = Boolean( + auth.authenticated && activityState?.activeGovernor, ); + const identityStatuses = governanceIdentityStatuses({ + governor, + activeGovernor, + humanNode: humanNodeActive, + }); const gateError = auth.authenticated && !auth.eligible @@ -274,32 +287,36 @@ export function AuthSidebarPanel() { Wallet {addressLabel} -
- Human node - - {humanNodeActive ? "Active" : "Not active"} - -
-
- Governor - - {governorActive ? "Active" : "Not active"} - -
+ {( + [ + ["humanNode", auth.gateReason], + [ + "governor", + "Governor status is earned through the Vortex tier system.", + ], + [ + "activeGovernor", + "Active Governor status reflects completed governing thresholds for the current era.", + ], + ] as const + ).map(([key, title]) => { + const status = identityStatuses[key]; + return ( +
+ {status.label} + + {status.value} + +
+ ); + })} {auth.lastError ? (
diff --git a/src/components/GovernanceStatusPills.tsx b/src/components/GovernanceStatusPills.tsx new file mode 100644 index 0000000..2c7fdc6 --- /dev/null +++ b/src/components/GovernanceStatusPills.tsx @@ -0,0 +1,19 @@ +import { governanceIdentityStatuses } from "@/lib/humanNodesUi"; +import { StatusPill } from "./StatusPill"; + +type GovernanceStatusPillsProps = { + governor: boolean; + activeGovernor: boolean; + humanNode: boolean; +}; + +export function GovernanceStatusPills(props: GovernanceStatusPillsProps) { + const statuses = governanceIdentityStatuses(props); + return ( +
+ {Object.values(statuses).map((status) => ( + + ))} +
+ ); +} diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx index 89fc078..e132c2b 100644 --- a/src/components/ProposalPageHeader.tsx +++ b/src/components/ProposalPageHeader.tsx @@ -61,6 +61,7 @@ export function ProposalPageHeader({ if (!proposalId) return stageLinks; return buildProposalStageLinks({ canonicalRoute: status?.canonicalRoute, + draftRoute: status?.draftHistory?.route ?? stageLinks?.draft, liveStage, proposalId, routeOverrides: stageLinks, @@ -72,6 +73,7 @@ export function ProposalPageHeader({ showFormationStage, stageLinks, status?.canonicalRoute, + status?.draftHistory?.route, ]); return ( diff --git a/src/components/ProposalStageBar.tsx b/src/components/ProposalStageBar.tsx index ffc5730..7061e9f 100644 --- a/src/components/ProposalStageBar.tsx +++ b/src/components/ProposalStageBar.tsx @@ -55,6 +55,7 @@ function withSnapshotStage(href: string, stage: ProposalStage): string { type BuildProposalStageLinksInput = { canonicalRoute?: string; + draftRoute?: string; liveStage: ProposalStage; proposalId: string; routeOverrides?: Partial>; @@ -63,6 +64,7 @@ type BuildProposalStageLinksInput = { export function buildProposalStageLinks({ canonicalRoute, + draftRoute, liveStage, proposalId, routeOverrides, @@ -71,6 +73,8 @@ export function buildProposalStageLinks({ const liveIndex = stageProgressIndex(liveStage); const links: Partial> = {}; + if (draftRoute) links.draft = draftRoute; + for (const stage of stageOrder) { if (stage === "draft") continue; if (stage === "build" && !showFormationStage && liveStage !== "build") { diff --git a/src/components/StageChip.css b/src/components/StageChip.css index d399cb6..2e0b7dd 100644 --- a/src/components/StageChip.css +++ b/src/components/StageChip.css @@ -15,6 +15,12 @@ white-space: nowrap; } +.stage-chip--draft { + --stage-chip-bg: var(--control-glass-bg); + --stage-chip-border: var(--surface-glass-border); + --stage-chip-text: var(--text); +} + .stage-chip--proposal-pool { --stage-chip-bg: #fff1dc; --stage-chip-border: rgba(176, 102, 25, 0.2); diff --git a/src/components/StageChip.tsx b/src/components/StageChip.tsx index 08b06f5..f52b2f6 100644 --- a/src/components/StageChip.tsx +++ b/src/components/StageChip.tsx @@ -12,6 +12,7 @@ import { import "./StageChip.css"; const chipClasses: Record = { + draft: "stage-chip--draft", proposal_pool: "stage-chip--proposal-pool", chamber_vote: "stage-chip--chamber-vote", citizen_veto: "stage-chip--citizen-veto", diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 2f26c32..0df378e 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -23,10 +23,13 @@ import type { GetInvisionResponse, GetMyGovernanceResponse, GetProposalDraftsResponse, + GetPublicProposalDraftsResponse, GetProposalsResponse, GetProposalTimelineResponse, HumanNodeProfileDto, ProposalDraftDetailDto, + PublicProposalDraftKindDto, + PublicProposalDraftSortDto, ProposalThreadDetailDto, ProposalThreadDto, ProposalThreadListDto, @@ -800,6 +803,39 @@ export async function apiProposalDraft( return await apiGet(`/api/proposals/drafts/${id}`); } +export async function apiPublicProposalDrafts(input?: { + q?: string; + chamber?: string; + author?: string; + initiative?: string; + proposalPath?: PublicProposalDraftKindDto; + sort?: PublicProposalDraftSortDto; + cursor?: string; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (input?.q) params.set("q", input.q); + if (input?.chamber) params.set("chamber", input.chamber); + if (input?.author) params.set("author", input.author); + if (input?.initiative) params.set("initiative", input.initiative); + if (input?.proposalPath) params.set("proposalPath", input.proposalPath); + if (input?.sort) params.set("sort", input.sort); + if (input?.cursor) params.set("cursor", input.cursor); + if (input?.limit) params.set("limit", String(input.limit)); + const qs = params.toString(); + return await apiGet( + `/api/proposals/public-drafts?${qs}`, + ); +} + +export async function apiPublicProposalDraft( + id: string, +): Promise { + return await apiGet( + `/api/proposals/public-drafts/${encodeURIComponent(id)}`, + ); +} + export type ProposalDraftFormPayload = { templateId?: "project" | "system"; presetId?: string; @@ -883,6 +919,42 @@ export async function apiProposalDraftDelete(input: { }); } +export async function apiProposalDraftPublish(input: { + draftId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "proposal.draft.publish"; + draftId: string; + revision: number; + publicUrl: string; + publishedAt: string; + updatedAt: string; +}> { + return await apiCommand({ + type: "proposal.draft.publish", + payload: { draftId: input.draftId }, + idempotencyKey: input.idempotencyKey, + }); +} + +export async function apiProposalDraftUnpublish(input: { + draftId: string; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "proposal.draft.unpublish"; + draftId: string; + unpublished: boolean; + updatedAt: string; +}> { + return await apiCommand({ + type: "proposal.draft.unpublish", + payload: { draftId: input.draftId }, + idempotencyKey: input.idempotencyKey, + }); +} + export async function apiProposalSubmitToPool(input: { draftId: string; idempotencyKey?: string; diff --git a/src/lib/humanNodesUi.ts b/src/lib/humanNodesUi.ts index 6b1f6c2..2d85eb5 100644 --- a/src/lib/humanNodesUi.ts +++ b/src/lib/humanNodesUi.ts @@ -15,7 +15,12 @@ export type HumanNodesTierFilter = | "legate" | "consul" | "citizen"; -export type HumanNodesStatusFilter = "all" | "governor" | "human" | "inactive"; +export type HumanNodesStatusFilter = + | "all" + | "governor" + | "active-governor" + | "human" + | "inactive"; export type HumanNodesCmRange = "all" | "0-50" | "50-200" | "200+"; export type HumanNodesFilters = { @@ -25,6 +30,32 @@ export type HumanNodesFilters = { tierFilter: HumanNodesTierFilter; }; +export type GovernanceIdentityState = { + governor: boolean; + activeGovernor: boolean; + humanNode: boolean; +}; + +export function governanceIdentityStatuses(state: GovernanceIdentityState) { + return { + governor: { + label: "Governor", + value: state.governor ? "Active" : "Not active", + active: state.governor, + }, + activeGovernor: { + label: "Active governor", + value: state.activeGovernor ? "Active" : "Not active", + active: state.activeGovernor, + }, + humanNode: { + label: "Human node", + value: state.humanNode ? "Active" : "Not active", + active: state.humanNode, + }, + } as const; +} + export const DEFAULT_HUMAN_NODES_FILTERS: HumanNodesFilters = { sortBy: "acm-desc", tierFilter: "all", @@ -70,10 +101,12 @@ export function filterHumanNodes(input: { statusFilter === "all" ? true : statusFilter === "governor" - ? node.active.governorActive - : statusFilter === "human" - ? node.active.humanNodeActive - : !node.active.governorActive && !node.active.humanNodeActive; + ? node.active.governor + : statusFilter === "active-governor" + ? node.active.governorActive + : statusFilter === "human" + ? node.active.humanNodeActive + : !node.active.governorActive && !node.active.humanNodeActive; const acmValue = node.cmTotals?.acm ?? node.acm ?? 0; const matchesRange = cmRange === "all" diff --git a/src/pages/feed/hooks/useFeedChamberFilters.ts b/src/pages/feed/hooks/useFeedChamberFilters.ts index 4a1e7b6..d057104 100644 --- a/src/pages/feed/hooks/useFeedChamberFilters.ts +++ b/src/pages/feed/hooks/useFeedChamberFilters.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { apiClock, apiHuman, apiMyGovernance } from "@/lib/apiClient"; +import { apiHuman, apiMyGovernance } from "@/lib/apiClient"; import type { FeedScope } from "@/lib/feedScopeRouting"; export function useFeedChamberFilters(input: { @@ -32,23 +32,17 @@ export function useFeedChamberFilters(input: { setChambersLoading(true); (async () => { try { - const [governance, profile, clock] = await Promise.all([ + const [governance, profile] = await Promise.all([ apiMyGovernance(), apiHuman(address), - apiClock(), ]); if (!active) return; - const tier = profile.tierProgress?.tier?.trim().toLowerCase() ?? ""; - const bootstrapGovernor = - clock.currentEra === 0 && tier !== "" && tier !== "nominee"; const chamberIds = governance.myChamberIds ?? []; const unique = Array.from( new Set(["general", ...chamberIds.map((id) => id.toLowerCase())]), ); setChamberFilters(unique); - setViewerGovernorActive( - Boolean(profile.governorActive) || bootstrapGovernor, - ); + setViewerGovernorActive(Boolean(profile.governorActive)); } catch (error) { if (!active) return; setChamberFilters([]); diff --git a/src/pages/human-nodes/HumanNode.tsx b/src/pages/human-nodes/HumanNode.tsx index 860014c..d279885 100644 --- a/src/pages/human-nodes/HumanNode.tsx +++ b/src/pages/human-nodes/HumanNode.tsx @@ -34,10 +34,13 @@ import { ProfileGovernanceActivitySection, } from "@/pages/profile/components/ProfileActivityProjectsSection"; import { ProfileTierProgressSection } from "@/pages/profile/components/ProfileTierProgressSection"; +import { PublicDraftsSection } from "@/pages/proposals/draft/PublicDraftsSection"; +import { usePublicDrafts } from "@/pages/proposals/draft/usePublicDrafts"; const HumanNode: React.FC = () => { const auth = useAuth(); const { id } = useParams(); + const publicDrafts = usePublicDrafts({ author: id }); const [activityFilter, setActivityFilter] = useState("all"); const [copied, setCopied] = useState(false); const { @@ -86,6 +89,7 @@ const HumanNode: React.FC = () => { } const { + governor, governorActive, humanNodeActive, heroStats, @@ -159,6 +163,7 @@ const HumanNode: React.FC = () => {
{ visibleDetails={visibleDetails} /> + +
{ label: "Status", options: [ { value: "all", label: "All statuses" }, - { value: "governor", label: "Governor active" }, + { value: "governor", label: "Governor" }, + { value: "active-governor", label: "Active governor" }, { value: "human", label: "Human node active" }, { value: "inactive", label: "Inactive" }, ], diff --git a/src/pages/human-nodes/components/HumanNodeHero.tsx b/src/pages/human-nodes/components/HumanNodeHero.tsx index f1379fa..e637836 100644 --- a/src/pages/human-nodes/components/HumanNodeHero.tsx +++ b/src/pages/human-nodes/components/HumanNodeHero.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/primitives/badge"; import { HintLabel } from "@/components/Hint"; import { Surface } from "@/components/Surface"; import { AvatarPlaceholder } from "@/components/AvatarPlaceholder"; -import { StatusPill } from "@/components/StatusPill"; +import { GovernanceStatusPills } from "@/components/GovernanceStatusPills"; import { Kicker } from "@/components/Kicker"; import type { HumanNodeProfileDto } from "@/types/api"; import { Check, Copy } from "lucide-react"; @@ -11,6 +11,7 @@ type HumanNodeHeroProps = { copied: boolean; headerTitle: string; humanNodeActive: boolean; + governor: boolean; governorActive: boolean; onCopyAddress: () => void; profile: HumanNodeProfileDto; @@ -23,6 +24,7 @@ export function HumanNodeHero({ copied, headerTitle, humanNodeActive, + governor, governorActive, onCopyAddress, profile, @@ -69,18 +71,11 @@ export function HumanNodeHero({
-
- - -
+
diff --git a/src/pages/human-nodes/components/HumanNodesResultsCard.tsx b/src/pages/human-nodes/components/HumanNodesResultsCard.tsx index 33319a4..48c198f 100644 --- a/src/pages/human-nodes/components/HumanNodesResultsCard.tsx +++ b/src/pages/human-nodes/components/HumanNodesResultsCard.tsx @@ -121,6 +121,10 @@ export function HumanNodesResultsCard({ { label: "Faction", value: factionName }, { label: "Governor", + value: node.active.governor ? "Yes" : "No", + }, + { + label: "Active governor", value: node.active.governorActive ? "Active" : "Not active", }, { diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx index 90fc0b1..19bfb9a 100644 --- a/src/pages/initiatives/Initiative.tsx +++ b/src/pages/initiatives/Initiative.tsx @@ -39,12 +39,15 @@ import { InitiativeProposalsSection } from "./components/InitiativeProposalsSect import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection"; import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection"; import { useInitiativePageData } from "./hooks/useInitiativePageData"; +import { PublicDraftsSection } from "@/pages/proposals/draft/PublicDraftsSection"; +import { usePublicDrafts } from "@/pages/proposals/draft/usePublicDrafts"; const Initiative: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const auth = useAuth(); const { initiative, loadError, reload } = useInitiativePageData(id); + const publicDrafts = usePublicDrafts({ initiative: id }); const [editing, setEditing] = useState(false); const { actionError, mutating, runAction } = useActionRunner({ reload }); @@ -245,6 +248,7 @@ const Initiative: React.FC = () => { threads={threads} /> + = ({ showHint = true }) => { const [activityFilter, setActivityFilter] = useState("all"); const [profile, setProfile] = useState(null); const [loadError, setLoadError] = useState(null); + const publicDrafts = usePublicDrafts({ author: auth.address ?? undefined }); useEffect(() => { if (auth.enabled && auth.loading) { @@ -191,6 +194,8 @@ const Profile: React.FC = ({ showHint = true }) => { visibleDetails={visibleDetails} /> + +
) : null}
-
- - -
+ diff --git a/src/pages/proposals/ProposalCreation.tsx b/src/pages/proposals/ProposalCreation.tsx index 69fb686..d33b754 100644 --- a/src/pages/proposals/ProposalCreation.tsx +++ b/src/pages/proposals/ProposalCreation.tsx @@ -4,11 +4,16 @@ import { useNavigate, useSearchParams } from "react-router"; import { useAuth } from "@/app/auth/AuthContext"; import { PageHint } from "@/components/PageHint"; import { SIM_AUTH_ENABLED } from "@/lib/featureFlags"; -import { apiProposalSubmitToPool } from "@/lib/apiClient"; +import { + apiProposalDraftPublish, + apiProposalSubmitToPool, + type ApiError, +} from "@/lib/apiClient"; import { toTimestampMs } from "@/lib/dateTime"; import { initiativeOptionsWithSelection } from "@/lib/initiativeUi"; import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors"; import { usePrefersReducedMotion } from "@/lib/usePrefersReducedMotion"; +import type { DraftPublicationSummaryDto } from "@/types/api"; import { ProposalCreationLineageMessage, ProposalCreationMessages, @@ -31,6 +36,8 @@ import { type ProposalWizardSessionV2, } from "./proposalCreation/sessionStorage"; import { proposalSubmitErrorStep } from "./proposalCreation/submitErrorRouting"; +import { isProposalDraftPublicationReady } from "./proposalCreation/publicationReadiness"; +import { proposalDraftRoutes } from "./draft/draftUi"; import { BudgetStep } from "./proposalCreation/steps/BudgetStep"; import { IntentStep } from "./proposalCreation/steps/IntentStep"; import { PlanStep } from "./proposalCreation/steps/PlanStep"; @@ -133,11 +140,19 @@ const ProposalCreation: React.FC = () => { ); const [saveError, setSaveError] = useState(null); const [submitError, setSubmitError] = useState(null); + const [publication, setPublication] = useState({ + status: "private", + }); + const [publishing, setPublishing] = useState(false); const [recoverableSessions, setRecoverableSessions] = useState(() => repository.listRecoverable(session.sessionId), ); const headingRef = useRef(null); const submitInFlight = useRef(false); + const submitIdempotencyKeyRef = useRef(null); + const submitRetryDraftIdRef = useRef(null); + const submitRetryDirectRef = useRef(false); + const publishIdempotencyKeyRef = useRef(null); const observedQueryRef = useRef(searchParams.toString()); const pendingInternalQueryRef = useRef(null); @@ -167,9 +182,10 @@ const ProposalCreation: React.FC = () => { tierProgress, }); + const publicationReady = isProposalDraftPublicationReady(draft, templateKind); const wizardContext = useMemo( - () => ({ draft, presetId, tierBlocked }), - [draft, presetId, tierBlocked], + () => ({ draft, presetId, publicationReady, tierBlocked }), + [draft, presetId, publicationReady, tierBlocked], ); const currentPathId = pathIdForDraft(draft, templateKind); const currentPath = pathDefinition(currentPathId); @@ -184,7 +200,6 @@ const ProposalCreation: React.FC = () => { (step) => validateWizardStep(step.id, wizardContext).valid, ); const submitDisabled = - !fullPathValid || !fullPathValid || !canAct || tierBlocked || @@ -220,6 +235,13 @@ const ProposalCreation: React.FC = () => { const textareaClassName = "w-full rounded-lg border border-[color:var(--surface-glass-border)] bg-[color:var(--control-glass-bg)] px-3 py-2 text-sm text-text shadow-[var(--shadow-control)] transition supports-[backdrop-filter]:backdrop-blur-md hover:border-[color:var(--surface-glass-hover-border)] hover:bg-[color:var(--control-glass-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--primary-dim)] focus-visible:ring-offset-2 focus-visible:ring-offset-panel"; + useEffect(() => { + submitIdempotencyKeyRef.current = null; + submitRetryDraftIdRef.current = null; + submitRetryDirectRef.current = false; + publishIdempotencyKeyRef.current = null; + }, [session.sessionId]); + const runEffects = useCallback( (effects: WizardEffect[]) => { const behavior = prefersReducedMotion ? "auto" : "smooth"; @@ -355,6 +377,8 @@ const ProposalCreation: React.FC = () => { ); setSaveError(null); setSubmitError(null); + setPublication({ status: "private" }); + setPublishing(false); setRecoverableSessions(repository.listRecoverable(nextSession.sessionId)); runEffects([{ type: "focus-step", stepId: resolvedStep }]); }, @@ -366,6 +390,7 @@ const ProposalCreation: React.FC = () => { draft: nextDraft, draftId, presetId: nextPresetId, + publication: nextPublication, templateKind: nextTemplateKind, }: ProposalDraftHydrationResult) => { const nextPathId = pathIdForDraft(nextDraft, nextTemplateKind); @@ -393,6 +418,7 @@ const ProposalCreation: React.FC = () => { serverSavedAt: new Date().toISOString(), }); activateSession(saved, resolvedStep); + setPublication(nextPublication); setSavedAt(Date.now()); }, [activateSession, repository, requestedStep], @@ -535,16 +561,32 @@ const ProposalCreation: React.FC = () => { submitInFlight.current = true; setSubmitError(null); send({ type: "SUBMIT_REQUESTED" }); + let commandAttempted = false; try { - const draftId = await saveDraftNow(); + const draftId = + submitRetryDirectRef.current && submitRetryDraftIdRef.current + ? submitRetryDraftIdRef.current + : await saveDraftNow(); if (!draftId) throw new Error("Draft could not be synchronized."); - const response = await apiProposalSubmitToPool({ draftId }); + submitRetryDraftIdRef.current = draftId; + submitIdempotencyKeyRef.current ??= `proposal-submit-${crypto.randomUUID()}`; + commandAttempted = true; + const response = await apiProposalSubmitToPool({ + draftId, + idempotencyKey: submitIdempotencyKeyRef.current, + }); if (sessionRef.current.sessionId !== submittingSession.sessionId) return; repository.remove(submittingSession.sessionId); + submitIdempotencyKeyRef.current = null; + submitRetryDraftIdRef.current = null; + submitRetryDirectRef.current = false; if (submittingSession.legacyRecovery) repository.clearLegacy(); navigate(`/app/proposals/${response.proposalId}/pp`, { replace: true }); } catch (error) { if (sessionRef.current.sessionId !== submittingSession.sessionId) return; + const status = (error as ApiError | null)?.status; + submitRetryDirectRef.current = + commandAttempted && (typeof status !== "number" || status >= 500); const message = formatProposalSubmitError(error); setSubmitError(message); const targetStep = proposalSubmitErrorStep( @@ -561,6 +603,54 @@ const ProposalCreation: React.FC = () => { } }; + const publishDraft = async () => { + if ( + !isReview || + !publicationReady || + !canAct || + tierBlocked || + publishing + ) { + return; + } + setPublishing(true); + setSaveError(null); + const publishingSessionId = sessionRef.current.sessionId; + try { + const draftId = await saveDraftNow(); + if (!draftId) throw new Error("Draft could not be synchronized."); + if (sessionRef.current.sessionId !== publishingSessionId) return; + publishIdempotencyKeyRef.current ??= `draft-publish-${crypto.randomUUID()}`; + const response = await apiProposalDraftPublish({ + draftId, + idempotencyKey: publishIdempotencyKeyRef.current, + }); + if (sessionRef.current.sessionId !== publishingSessionId) return; + setPublication({ + status: "published", + revision: response.revision, + publicUrl: response.publicUrl, + publishedAt: response.publishedAt, + publicUpdatedAt: response.updatedAt, + hasUnpublishedChanges: false, + }); + publishIdempotencyKeyRef.current = null; + } catch (error) { + if (sessionRef.current.sessionId !== publishingSessionId) return; + setSaveError(error instanceof Error ? error.message : "Publish failed."); + const targetStep = proposalSubmitErrorStep( + error, + currentPathId, + wizardContext, + ); + if (targetStep) send({ type: "STEP_REQUESTED", stepId: targetStep }); + } finally { + if (sessionRef.current.sessionId === publishingSessionId) { + setPublishing(false); + } + } + }; + const handleContinue = () => { if (isReview) { void submitProposal(); @@ -604,8 +694,8 @@ const ProposalCreation: React.FC = () => { if (sessionRef.current.sessionId !== savingSessionId) return; navigate( draftId || sessionRef.current.draftId - ? "/app/proposals/drafts" - : "/app/proposals", + ? proposalDraftRoutes.mine + : proposalDraftRoutes.proposals, ); }; @@ -768,7 +858,20 @@ const ProposalCreation: React.FC = () => { } onBack={() => send({ type: "BACK_REQUESTED" })} onContinue={handleContinue} - submitting={submitting} + onSecondaryAction={isReview ? () => void publishDraft() : undefined} + secondaryActionDisabled={ + !publicationReady || !canAct || tierBlocked || publishing + } + secondaryActionLabel={ + isReview + ? publishing + ? "Publishing" + : publication.status === "published" + ? "Update public draft" + : "Publish draft" + : undefined + } + submitting={submitting || publishing} /> diff --git a/src/pages/proposals/ProposalDraft.tsx b/src/pages/proposals/ProposalDraft.tsx index 5de10c9..e307a6a 100644 --- a/src/pages/proposals/ProposalDraft.tsx +++ b/src/pages/proposals/ProposalDraft.tsx @@ -5,7 +5,6 @@ import { Button } from "@/components/primitives/button"; import { Card } from "@/components/primitives/card"; import { PageHint } from "@/components/PageHint"; import { useAuth } from "@/app/auth/AuthContext"; -import { parseRatioPair } from "@/lib/dtoParsers"; import { apiProposalDraft, apiProposalDraftDelete, @@ -14,6 +13,8 @@ import { import { formatLoadError } from "@/lib/errorFormatting"; import type { ProposalDraftDetailDto } from "@/types/api"; import { ProposalDraftDetailsCard } from "./draft/ProposalDraftDetailsCard"; +import { DraftPublicationActions } from "./draft/DraftPublicationActions"; +import { editDraftRoute, proposalDraftRoutes } from "./draft/draftUi"; const ProposalDraft: React.FC = () => { const auth = useAuth(); @@ -24,10 +25,6 @@ const ProposalDraft: React.FC = () => { const [loadError, setLoadError] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); - const { left: filledSlots, right: totalSlots } = parseRatioPair( - draftDetails?.teamSlots ?? "0 / 0", - ); - const openSlots = Math.max((totalSlots || 0) - (filledSlots || 0), 0); const submittedDraft = Boolean(draftDetails?.submittedProposalId); useEffect(() => { @@ -65,19 +62,17 @@ const ProposalDraft: React.FC = () => {
{id ? ( ) : null}
@@ -101,16 +96,14 @@ const ProposalDraft: React.FC = () => {
{id && !submittedDraft ? ( <> ) : null} + {id ? ( + + setDraftDetails((current) => + current ? { ...current, publication } : current, + ) + } + /> + ) : null} {draftDetails.submittedProposalId ? (
); }; diff --git a/src/pages/proposals/ProposalDrafts.tsx b/src/pages/proposals/ProposalDrafts.tsx index c2b0f39..ced07d7 100644 --- a/src/pages/proposals/ProposalDrafts.tsx +++ b/src/pages/proposals/ProposalDrafts.tsx @@ -1,23 +1,20 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate } from "react-router"; +import { Link } from "react-router"; import { Card } from "@/components/primitives/card"; import { Button } from "@/components/primitives/button"; -import { Badge } from "@/components/primitives/badge"; import { SearchBar } from "@/components/SearchBar"; import { PageHint } from "@/components/PageHint"; import { SectionHeader } from "@/components/SectionHeader"; -import { Kicker } from "@/components/Kicker"; import { NoDataYetBar } from "@/components/NoDataYetBar"; import { apiProposalDrafts } from "@/lib/apiClient"; -import { formatDateTime } from "@/lib/dateTime"; import { formatLoadError } from "@/lib/errorFormatting"; -import { proposalSummaryPreview } from "@/lib/textPreview"; import type { ProposalDraftListItemDto } from "@/types/api"; import { useAuth } from "@/app/auth/AuthContext"; +import { OwnerDraftCard } from "./draft/OwnerDraftCard"; +import { proposalDraftRoutes } from "./draft/draftUi"; const ProposalDrafts: React.FC = () => { const auth = useAuth(); - const navigate = useNavigate(); const [drafts, setDrafts] = useState(null); const [loadError, setLoadError] = useState(null); const [query, setQuery] = useState(""); @@ -81,11 +78,11 @@ const ProposalDrafts: React.FC = () => {
@@ -137,40 +134,24 @@ const ProposalDrafts: React.FC = () => { {drafts !== null && drafts.length === 0 && !loadError ? ( ) : null} + {drafts !== null && drafts.length > 0 && filtered.length === 0 ? ( + + ) : null} -
+
{filtered.map((draft) => ( - -
-
- Draft · {formatDateTime(draft.updated)} -

- {draft.title} -

-

- {proposalSummaryPreview(draft.summary)} -

-
- {draft.chamber} -
-
- Tier: {draft.tier} -
- - -
-
-
+ + setDrafts( + (current) => + current?.map((item) => + item.id === draft.id ? { ...item, publication } : item, + ) ?? null, + ) + } + /> ))}
diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx index 75c5981..534107e 100644 --- a/src/pages/proposals/ProposalFinished.tsx +++ b/src/pages/proposals/ProposalFinished.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/primitives/button"; import { SectionHeader } from "@/components/SectionHeader"; import { Surface } from "@/components/Surface"; import { ProposalPageHeader } from "@/components/ProposalPageHeader"; +import { editDraftRoute, reconsiderProposalRoute } from "./draft/draftUi"; import { apiProposalFinishedPage } from "@/lib/apiClient"; import { useProposalStageSync, @@ -61,8 +62,8 @@ const ProposalFinished: React.FC = () => { Resubmit for reconsideration diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx index b31722d..1225dac 100644 --- a/src/pages/proposals/Proposals.tsx +++ b/src/pages/proposals/Proposals.tsx @@ -19,6 +19,7 @@ import { apiProposals } from "@/lib/apiClient"; import type { ProposalListItemDto } from "@/types/api"; import { ProposalListCard } from "./list/ProposalListCard"; import { useProposalStageDetails } from "./list/useProposalStageDetails"; +import { proposalDraftRoutes } from "./draft/draftUi"; const Proposals: React.FC = () => { const [proposalData, setProposalData] = useState< @@ -71,17 +72,27 @@ const Proposals: React.FC = () => {
- +
+ + +
diff --git a/src/pages/proposals/PublicDraft.tsx b/src/pages/proposals/PublicDraft.tsx new file mode 100644 index 0000000..7d0de8e --- /dev/null +++ b/src/pages/proposals/PublicDraft.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router"; + +import { useAuth } from "@/app/auth/AuthContext"; +import { PageHint } from "@/components/PageHint"; +import { Button } from "@/components/primitives/button"; +import { Surface } from "@/components/Surface"; +import { addressesReferToSameIdentity } from "@/lib/addressIdentity"; +import { apiProposalStatus, apiPublicProposalDraft } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; +import type { ProposalDraftDetailDto } from "@/types/api"; +import { DraftPublicationActions } from "./draft/DraftPublicationActions"; +import { CopyLinkButton } from "./draft/CopyLinkButton"; +import { + editDraftRoute, + proposalDraftRoutes, + publicationRoute, +} from "./draft/draftUi"; +import { ProposalDraftDetailsCard } from "./draft/ProposalDraftDetailsCard"; + +const PublicDraft: React.FC = () => { + const auth = useAuth(); + const navigate = useNavigate(); + const { id = "" } = useParams(); + const [draft, setDraft] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let active = true; + void apiPublicProposalDraft(id) + .then((response) => { + if (!active) return; + setDraft(response); + setLoadError(null); + }) + .catch((error) => { + if (!active) return; + setDraft(null); + setLoadError(formatLoadError(error, "Public draft unavailable.")); + }); + return () => { + active = false; + }; + }, [id]); + + if (!draft) { + return ( +
+ + + + {loadError ?? "Loading public draft…"} + +
+ ); + } + + const owner = addressesReferToSameIdentity(auth.address, draft.proposer); + + return ( +
+ +
+ +
+ {!owner && draft.id ? ( + + ) : null} + {owner && draft.id && draft.publication.status === "published" ? ( + + ) : null} + {owner && draft.id ? ( + { + if (publication.status === "withdrawn") { + navigate(proposalDraftRoutes.public, { replace: true }); + return; + } + setDraft((current) => + current ? { ...current, publication } : current, + ); + }} + /> + ) : null} + {draft.submittedProposalId ? ( + + ) : null} +
+
+ + +
+ ); +}; + +export default PublicDraft; diff --git a/src/pages/proposals/PublicDrafts.tsx b/src/pages/proposals/PublicDrafts.tsx new file mode 100644 index 0000000..dacb93a --- /dev/null +++ b/src/pages/proposals/PublicDrafts.tsx @@ -0,0 +1,212 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router"; + +import { NoDataYetBar } from "@/components/NoDataYetBar"; +import { PageHint } from "@/components/PageHint"; +import { Button } from "@/components/primitives/button"; +import { SearchBar } from "@/components/SearchBar"; +import { SectionHeader } from "@/components/SectionHeader"; +import { Surface } from "@/components/Surface"; +import { apiPublicProposalDrafts } from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; +import type { PublicProposalDraftListItemDto } from "@/types/api"; +import { PublicDraftCard } from "./draft/PublicDraftCard"; +import { proposalDraftRoutes, publicDraftKindOptions } from "./draft/draftUi"; +import type { + PublicProposalDraftKindDto, + PublicProposalDraftSortDto, +} from "@/types/api"; + +type DirectoryFilters = { + proposalPath: "" | PublicProposalDraftKindDto; + sort: PublicProposalDraftSortDto; +}; + +const initialFilters: DirectoryFilters = { + proposalPath: "", + sort: "updated", +}; + +const publicDraftSortOptions = [ + { value: "updated", label: "Recently updated" }, + { value: "published", label: "Recently published" }, +] satisfies Array<{ value: PublicProposalDraftSortDto; label: string }>; + +const PUBLIC_DRAFT_PAGE_SIZE = 20; + +const PublicDrafts: React.FC = () => { + const [items, setItems] = useState( + null, + ); + const [loadError, setLoadError] = useState(null); + const [search, setSearch] = useState(""); + const [filters, setFilters] = useState(initialFilters); + const [appliedFilters, setAppliedFilters] = + useState(initialFilters); + const [nextCursor, setNextCursor] = useState(); + const [loadingMore, setLoadingMore] = useState(false); + const query = useMemo( + () => ({ + q: search.trim() || undefined, + proposalPath: appliedFilters.proposalPath || undefined, + sort: appliedFilters.sort, + limit: PUBLIC_DRAFT_PAGE_SIZE, + }), + [appliedFilters.proposalPath, appliedFilters.sort, search], + ); + const queryKey = `${query.q ?? ""}|${query.proposalPath ?? ""}|${query.sort}`; + const hasActiveQuery = Boolean(search.trim() || appliedFilters.proposalPath); + const activeQueryKey = useRef(queryKey); + activeQueryKey.current = queryKey; + + useEffect(() => { + let active = true; + setNextCursor(undefined); + setLoadingMore(false); + const timer = window.setTimeout(() => { + setItems(null); + void apiPublicProposalDrafts(query) + .then((response) => { + if (!active) return; + setItems(response.items); + setNextCursor(response.nextCursor); + setLoadError(null); + }) + .catch((error) => { + if (!active) return; + setItems([]); + setNextCursor(undefined); + setLoadError(formatLoadError(error, "Could not load public drafts.")); + }); + }, 250); + return () => { + active = false; + window.clearTimeout(timer); + }; + }, [query]); + + const loadMore = async () => { + if (!nextCursor || loadingMore) return; + const requestedQueryKey = queryKey; + setLoadingMore(true); + try { + const response = await apiPublicProposalDrafts({ + ...query, + cursor: nextCursor, + }); + if (activeQueryKey.current !== requestedQueryKey) return; + setItems((current) => [...(current ?? []), ...response.items]); + setNextCursor(response.nextCursor); + setLoadError(null); + } catch (error) { + if (activeQueryKey.current !== requestedQueryKey) return; + setLoadError( + formatLoadError( + error instanceof Error ? error.message : String(error), + "Could not load more public drafts.", + ), + ); + } finally { + if (activeQueryKey.current === requestedQueryKey) setLoadingMore(false); + } + }; + + return ( +
+ +
+
+ + +
+ +
+ +
+ Public drafts + setSearch(event.target.value)} + placeholder="Search public drafts…" + ariaLabel="Search public drafts" + className="max-w-md" + filtersConfig={[ + { + key: "proposalPath", + label: "Proposal path", + options: [ + { value: "", label: "All paths" }, + ...publicDraftKindOptions, + ], + }, + { + key: "sort", + label: "Order", + options: publicDraftSortOptions, + }, + ]} + filtersState={filters} + onFiltersChange={setFilters} + onApplyFilters={() => setAppliedFilters(filters)} + /> +
+ + {items === null ? ( + + Loading public drafts… + + ) : null} + {loadError ? ( + + {loadError} + + ) : null} + {items !== null && items.length === 0 && !loadError ? ( + hasActiveQuery ? ( + + No public drafts match these filters. + + ) : ( + + ) + ) : null} +
+ {(items ?? []).map((draft) => ( + + ))} +
+ {nextCursor ? ( +
+ +
+ ) : null} +
+ ); +}; + +export default PublicDrafts; diff --git a/src/pages/proposals/draft/CopyLinkButton.tsx b/src/pages/proposals/draft/CopyLinkButton.tsx new file mode 100644 index 0000000..bf4691b --- /dev/null +++ b/src/pages/proposals/draft/CopyLinkButton.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/primitives/button"; + +type CopyLinkButtonProps = { + value: string; + onError?: (message: string) => void; +}; + +export function CopyLinkButton({ value, onError }: CopyLinkButtonProps) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef(null); + + useEffect(() => { + setCopied(false); + if (resetTimer.current !== null) window.clearTimeout(resetTimer.current); + return () => { + if (resetTimer.current !== null) window.clearTimeout(resetTimer.current); + }; + }, [value]); + + const copy = async () => { + try { + await navigator.clipboard.writeText( + new URL(value, window.location.origin).toString(), + ); + setCopied(true); + if (resetTimer.current !== null) window.clearTimeout(resetTimer.current); + resetTimer.current = window.setTimeout(() => setCopied(false), 2_000); + } catch { + onError?.("Could not copy the public draft link."); + } + }; + + return ( + + ); +} diff --git a/src/pages/proposals/draft/DraftPublicationActions.tsx b/src/pages/proposals/draft/DraftPublicationActions.tsx new file mode 100644 index 0000000..5cff966 --- /dev/null +++ b/src/pages/proposals/draft/DraftPublicationActions.tsx @@ -0,0 +1,135 @@ +import { Link } from "react-router"; + +import { Button } from "@/components/primitives/button"; +import { cn } from "@/lib/utils"; +import type { DraftPublicationSummaryDto } from "@/types/api"; +import { CopyLinkButton } from "./CopyLinkButton"; +import { isPublicDraftVisible } from "./draftUi"; +import { useDraftPublicationActions } from "./useDraftPublicationActions"; +import "@/components/StageChip.css"; + +type DraftPublicationActionsProps = { + draftId: string; + publication: DraftPublicationSummaryDto; + onChanged?: (publication: DraftPublicationSummaryDto) => void; + variant?: "actions" | "visibility-toggle"; +}; + +export function DraftPublicationActions({ + draftId, + publication, + onChanged, + variant = "actions", +}: DraftPublicationActionsProps) { + const { + canPublish, + error, + pending, + publicUrl, + publish, + reportError, + unpublish, + } = useDraftPublicationActions({ draftId, publication, onChanged }); + + if (variant === "visibility-toggle") { + const isPublic = publication.status === "published"; + const submitted = publication.status === "submitted"; + const label = submitted + ? "Submitted" + : pending + ? isPublic + ? "Making private" + : "Publishing" + : isPublic + ? "Public" + : "Private"; + return ( +
+ + {publication.hasUnpublishedChanges && isPublic ? ( + + Saved edits are private + + ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); + } + + return ( +
+
+ {canPublish ? ( + + ) : null} + {isPublicDraftVisible(publication) ? ( + <> + + + + ) : null} + {publication.status === "published" ? ( + + ) : null} +
+ {error ? ( +

{error}

+ ) : null} +
+ ); +} diff --git a/src/pages/proposals/draft/OwnerDraftCard.tsx b/src/pages/proposals/draft/OwnerDraftCard.tsx new file mode 100644 index 0000000..87abdc8 --- /dev/null +++ b/src/pages/proposals/draft/OwnerDraftCard.tsx @@ -0,0 +1,68 @@ +import { Link } from "react-router"; + +import { Chip } from "@/components/Chip"; +import { GlassyCard } from "@/components/GlassyCard"; +import { Button } from "@/components/primitives/button"; +import { formatDateTime } from "@/lib/dateTime"; +import { proposalSummaryPreview } from "@/lib/textPreview"; +import type { + DraftPublicationSummaryDto, + ProposalDraftListItemDto, +} from "@/types/api"; +import { DraftPublicationActions } from "./DraftPublicationActions"; +import { editDraftRoute, ownerDraftRoute } from "./draftUi"; + +type OwnerDraftCardProps = { + draft: ProposalDraftListItemDto; + onPublicationChanged: (publication: DraftPublicationSummaryDto) => void; +}; + +export function OwnerDraftCard({ + draft, + onPublicationChanged, +}: OwnerDraftCardProps) { + const detailUrl = ownerDraftRoute(draft.id); + const editUrl = editDraftRoute(draft.id); + + return ( + +
+ + {draft.chamber} + + +
+ +
+ + {draft.title} + +

+ {proposalSummaryPreview(draft.summary, 150)} +

+
+ +
+ + Updated {formatDateTime(draft.updated)} + +
+ + +
+
+
+ ); +} diff --git a/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx b/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx index d48a5e9..a58b265 100644 --- a/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx +++ b/src/pages/proposals/draft/ProposalDraftDetailsCard.tsx @@ -1,195 +1,78 @@ -import { AddressInline } from "@/components/AddressInline"; -import { AttachmentList } from "@/components/AttachmentList"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/primitives/card"; -import { ProposalStageBar } from "@/components/ProposalStageBar"; -import { StatTile } from "@/components/StatTile"; -import { Surface } from "@/components/Surface"; +import { Chip } from "@/components/Chip"; +import { ProposalPageHeader } from "@/components/ProposalPageHeader"; import { TierLabel } from "@/components/TierLabel"; -import { TitledSurface } from "@/components/TitledSurface"; import type { ProposalDraftDetailDto } from "@/types/api"; +import { parseRatioPair } from "@/lib/dtoParsers"; +import { ProposalDetailsSections } from "../shared/ProposalDetailsSections"; type ProposalDraftDetailsCardProps = { draft: ProposalDraftDetailDto; - openSlots: number; }; export const ProposalDraftDetailsCard: React.FC< ProposalDraftDetailsCardProps -> = ({ draft, openSlots }) => { +> = ({ draft }) => { + const { left: filledSlots, right: totalSlots } = parseRatioPair( + draft.teamSlots, + ); + const openSlots = Math.max(totalSlots - filledSlots, 0); return ( - - - - {draft.title} - - -
- - - } - radius="2xl" - className="px-4 py-4" - labelClassName="text-[0.8rem]" - valueClassName="text-lg" - /> - } - radius="2xl" - className="px-4 py-4" - labelClassName="text-[0.8rem]" - valueClassName="text-lg" - /> -
-
- -
- {[ - { label: "Budget ask", value: draft.budget }, - { - label: "Formation", - value: draft.formationEligible ? "Yes" : "No", - }, - { - label: "Team slots", - value: `${draft.teamSlots} (open: ${openSlots})`, - }, - { - label: "Milestones", - value: draft.milestonesPlanned, - }, - ].map((item) => ( - - ))} -
- - -
-

Summary

-

- {draft.summary} -

-
- -

- {draft.rationale} -

-
- -
    - {draft.checklist.map((item) => ( -
  • {item}
  • - ))} -
-
- -

{draft.budgetScope}

-
-
- -
- -

Team (locked)

-
    - {draft.teamLocked.map((member) => ( - - {member.name} - {member.role} - - ))} -
-
- -

Open slots (positions)

-
    - {draft.openSlotNeeds.map((slot) => ( - -

    {slot.title}

    -

    {slot.desc}

    -
    - ))} -
-
+
+ +
+ + {draft.publication.status === "submitted" + ? "Draft history" + : "Draft"} + + {draft.publication.revision ? ( + Public revision {draft.publication.revision} + ) : null} + + +
+
- -

Milestones

-
    - {draft.milestonesDetail.map((milestone) => ( - -

    {milestone.title}

    -

    {milestone.desc}

    -
    - ))} -
-
- - ({ - id: file.title, - title: file.title, - href: file.href, - }))} - /> - - + ({ + id: `draft-attachment-${index + 1}`, + title: attachment.title, + href: attachment.href, + }))} + authoring={draft.authoring} + budgetScope={draft.budgetScope} + executionPlan={draft.executionPlan} + milestonesDetail={ + draft.formationEligible ? draft.milestonesDetail : undefined + } + openSlots={draft.formationEligible ? draft.openSlotNeeds : undefined} + overview={draft.overview} + showBudgetScope={draft.formationEligible} + showExecutionPlan + stats={[ + { label: "Budget ask", value: draft.budget }, + { label: "Formation", value: draft.formationEligible ? "Yes" : "No" }, + ...(draft.formationEligible + ? [ + { + label: "Team slots", + value: `${draft.teamSlots} (${openSlots} available)`, + }, + { label: "Milestones", value: draft.milestonesPlanned }, + ] + : []), + ]} + summary={draft.summary} + teamLocked={draft.formationEligible ? draft.teamLocked : undefined} + /> +
); }; diff --git a/src/pages/proposals/draft/PublicDraftCard.tsx b/src/pages/proposals/draft/PublicDraftCard.tsx new file mode 100644 index 0000000..500787a --- /dev/null +++ b/src/pages/proposals/draft/PublicDraftCard.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { Link } from "react-router"; + +import { AddressInline } from "@/components/AddressInline"; +import { GlassyRecordCard } from "@/components/GlassyRecordCard"; +import { Button } from "@/components/primitives/button"; +import { StatTile } from "@/components/StatTile"; +import { formatDateTime } from "@/lib/dateTime"; +import type { PublicProposalDraftListItemDto } from "@/types/api"; +import { publicDraftKindLabels, publicDraftRoute } from "./draftUi"; + +type PublicDraftCardProps = { + draft: PublicProposalDraftListItemDto; +}; + +export function PublicDraftCard({ draft }: PublicDraftCardProps) { + const [expanded, setExpanded] = useState(false); + return ( + setExpanded((current) => !current)} + stage="draft" + summary={draft.summary} + title={draft.title} + > +
+
+ + + + } + /> +
+
+ +
+
+
+ ); +} diff --git a/src/pages/proposals/draft/PublicDraftsSection.tsx b/src/pages/proposals/draft/PublicDraftsSection.tsx new file mode 100644 index 0000000..101596e --- /dev/null +++ b/src/pages/proposals/draft/PublicDraftsSection.tsx @@ -0,0 +1,24 @@ +import { GlassySection } from "@/components/GlassySection"; +import type { PublicProposalDraftListItemDto } from "@/types/api"; +import { PublicDraftCard } from "./PublicDraftCard"; + +type PublicDraftsSectionProps = { + drafts: PublicProposalDraftListItemDto[]; + title?: string; +}; + +export function PublicDraftsSection({ + drafts, + title = "Public drafts", +}: PublicDraftsSectionProps) { + if (drafts.length === 0) return null; + return ( + +
+ {drafts.map((draft) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/proposals/draft/draftUi.ts b/src/pages/proposals/draft/draftUi.ts new file mode 100644 index 0000000..96ada67 --- /dev/null +++ b/src/pages/proposals/draft/draftUi.ts @@ -0,0 +1,66 @@ +import type { + DraftPublicationSummaryDto, + PublicProposalDraftKindDto, +} from "@/types/api"; + +export const proposalDraftRoutes = { + create: "/app/proposals/new", + mine: "/app/proposals/drafts", + proposals: "/app/proposals", + public: "/app/proposals/public-drafts", +} as const; + +export function ownerDraftRoute(draftId: string): string { + return `${proposalDraftRoutes.mine}/${encodeURIComponent(draftId)}`; +} + +export function editDraftRoute(draftId: string): string { + return `${proposalDraftRoutes.create}?draftId=${encodeURIComponent(draftId)}`; +} + +export function reconsiderProposalRoute(proposalId: string): string { + return `${proposalDraftRoutes.create}?resubmitsProposalId=${encodeURIComponent(proposalId)}`; +} + +export function publicDraftRoute(draftId: string): string { + return `${proposalDraftRoutes.public}/${encodeURIComponent(draftId)}`; +} + +export function publicationRoute( + draftId: string, + publication: DraftPublicationSummaryDto, +): string { + return publication.publicUrl ?? publicDraftRoute(draftId); +} + +export function canPublishDraft( + publication: DraftPublicationSummaryDto, +): boolean { + return ( + publication.status === "private" || + publication.status === "withdrawn" || + (publication.status === "published" && + Boolean(publication.hasUnpublishedChanges)) + ); +} + +export function isPublicDraftVisible( + publication: DraftPublicationSummaryDto, +): boolean { + return ( + publication.status === "published" || publication.status === "submitted" + ); +} + +export const publicDraftKindLabels: Record = + { + policy: "Policy", + formation: "Formation", + system: "System change", + }; + +export const publicDraftKindOptions = ( + Object.entries(publicDraftKindLabels) as Array< + [PublicProposalDraftKindDto, string] + > +).map(([value, label]) => ({ value, label })); diff --git a/src/pages/proposals/draft/useDraftPublicationActions.ts b/src/pages/proposals/draft/useDraftPublicationActions.ts new file mode 100644 index 0000000..a52cf36 --- /dev/null +++ b/src/pages/proposals/draft/useDraftPublicationActions.ts @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from "react"; + +import { + apiProposalDraftPublish, + apiProposalDraftUnpublish, +} from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; +import type { DraftPublicationSummaryDto } from "@/types/api"; +import { canPublishDraft, publicationRoute } from "./draftUi"; + +type PublicationAction = "publish" | "unpublish"; + +type UseDraftPublicationActionsInput = { + draftId: string; + publication: DraftPublicationSummaryDto; + onChanged?: (publication: DraftPublicationSummaryDto) => void; +}; + +export function useDraftPublicationActions({ + draftId, + publication, + onChanged, +}: UseDraftPublicationActionsInput) { + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + const currentDraftId = useRef(draftId); + const actionKeys = useRef>>({}); + currentDraftId.current = draftId; + + useEffect(() => { + setPending(null); + setError(null); + actionKeys.current = {}; + }, [draftId]); + + const publish = async () => { + if (pending) return; + setPending("publish"); + setError(null); + try { + actionKeys.current.publish ??= `draft-publish-${crypto.randomUUID()}`; + const response = await apiProposalDraftPublish({ + draftId, + idempotencyKey: actionKeys.current.publish, + }); + if (currentDraftId.current !== draftId) return; + onChanged?.({ + status: "published", + revision: response.revision, + publicUrl: response.publicUrl, + publishedAt: response.publishedAt, + publicUpdatedAt: response.updatedAt, + hasUnpublishedChanges: false, + }); + actionKeys.current.publish = undefined; + } catch (nextError) { + setError( + formatLoadError( + nextError instanceof Error ? nextError.message : null, + "Could not publish this draft.", + ), + ); + } finally { + if (currentDraftId.current === draftId) setPending(null); + } + }; + + const unpublish = async () => { + if (pending || publication.status !== "published") return; + if (!window.confirm("Remove this draft from public view?")) return; + setPending("unpublish"); + setError(null); + try { + actionKeys.current.unpublish ??= `draft-unpublish-${crypto.randomUUID()}`; + await apiProposalDraftUnpublish({ + draftId, + idempotencyKey: actionKeys.current.unpublish, + }); + if (currentDraftId.current !== draftId) return; + onChanged?.({ + ...publication, + status: "withdrawn", + hasUnpublishedChanges: false, + }); + actionKeys.current.unpublish = undefined; + } catch (nextError) { + setError( + formatLoadError( + nextError instanceof Error ? nextError.message : null, + "Could not unpublish this draft.", + ), + ); + } finally { + if (currentDraftId.current === draftId) setPending(null); + } + }; + + return { + canPublish: canPublishDraft(publication), + error, + pending, + publicUrl: publicationRoute(draftId, publication), + publish, + reportError: setError, + unpublish, + }; +} diff --git a/src/pages/proposals/draft/usePublicDrafts.ts b/src/pages/proposals/draft/usePublicDrafts.ts new file mode 100644 index 0000000..8d4b3bf --- /dev/null +++ b/src/pages/proposals/draft/usePublicDrafts.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +import { apiPublicProposalDrafts } from "@/lib/apiClient"; +import type { PublicProposalDraftListItemDto } from "@/types/api"; + +type PublicDraftFilters = { + author?: string; + initiative?: string; +}; + +export function usePublicDrafts(filters: PublicDraftFilters) { + const [drafts, setDrafts] = useState([]); + + useEffect(() => { + setDrafts([]); + if (!filters.author && !filters.initiative) { + return; + } + let active = true; + const loadAll = async () => { + const items: PublicProposalDraftListItemDto[] = []; + let cursor: string | undefined; + do { + const response = await apiPublicProposalDrafts({ + ...filters, + cursor, + limit: 100, + }); + items.push(...response.items); + cursor = response.nextCursor; + } while (cursor && active); + if (active) setDrafts(items); + }; + void loadAll().catch(() => { + if (active) setDrafts([]); + }); + return () => { + active = false; + }; + }, [filters.author, filters.initiative]); + + return drafts; +} diff --git a/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx b/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx index ed0ad54..fbe4098 100644 --- a/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx +++ b/src/pages/proposals/proposalCreation/ProposalWizardShell.tsx @@ -234,6 +234,9 @@ type WizardActionsProps = { continueDisabled?: boolean; onBack: () => void; onContinue: () => void; + onSecondaryAction?: () => void; + secondaryActionDisabled?: boolean; + secondaryActionLabel?: string; submitting: boolean; }; @@ -244,6 +247,9 @@ export function WizardActions({ continueLabel, onBack, onContinue, + onSecondaryAction, + secondaryActionDisabled = false, + secondaryActionLabel, submitting, }: WizardActionsProps) { return ( @@ -256,13 +262,25 @@ export function WizardActions({ > {backLabel} - +
+ {onSecondaryAction && secondaryActionLabel ? ( + + ) : null} + +
); } diff --git a/src/pages/proposals/proposalCreation/publicationReadiness.ts b/src/pages/proposals/proposalCreation/publicationReadiness.ts new file mode 100644 index 0000000..165debd --- /dev/null +++ b/src/pages/proposals/proposalCreation/publicationReadiness.ts @@ -0,0 +1,15 @@ +import type { ProposalDraftForm, ProposalTemplateId } from "./types"; +import { validateSystemDraftTarget } from "./wizardModel"; + +export function isProposalDraftPublicationReady( + draft: ProposalDraftForm, + templateId: ProposalTemplateId, +): boolean { + if (!draft.title.trim() || !draft.summary.trim() || !draft.chamberId.trim()) { + return false; + } + if (templateId === "system") { + return validateSystemDraftTarget(draft).valid; + } + return Boolean(draft.what.trim() && draft.why.trim()); +} diff --git a/src/pages/proposals/proposalCreation/submitErrorRouting.ts b/src/pages/proposals/proposalCreation/submitErrorRouting.ts index 2421c9b..f20e126 100644 --- a/src/pages/proposals/proposalCreation/submitErrorRouting.ts +++ b/src/pages/proposals/proposalCreation/submitErrorRouting.ts @@ -29,6 +29,14 @@ const scopeCodes = new Set([ "initiative_association_immutable", ]); +const publicationContentCodes = new Set([ + "draft_publication_title_required", + "draft_publication_summary_required", + "draft_publication_chamber_required", + "draft_publication_case_required", + "draft_publication_rationale_required", +]); + export function proposalSubmitErrorStep( error: unknown, pathId: WizardPathId, @@ -41,6 +49,15 @@ export function proposalSubmitErrorStep( if (scopeCodes.has(code)) { return pathId === "system-change" ? "system-change" : "essentials"; } + if (publicationContentCodes.has(code)) { + return pathId === "system-change" ? "system-change" : "essentials"; + } + if (code === "draft_publication_system_action_required") { + return "system-change"; + } + if (code === "draft_publication_invalid_url") { + return pathId === "system-change" ? "rationale" : "plan"; + } if (code === "draft_not_submittable") { return firstIncompleteWizardStep(pathId, context); } diff --git a/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts b/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts index 6755f34..96170c9 100644 --- a/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts +++ b/src/pages/proposals/proposalCreation/useProposalDraftHydration.ts @@ -4,6 +4,7 @@ import type { NavigateFunction } from "react-router"; import { apiProposalDraft, apiProposalStatus } from "@/lib/apiClient"; import { normalizeSessionDraft } from "./sessionStorage"; import { inferPresetIdFromDraft } from "./presets/registry"; +import type { DraftPublicationSummaryDto } from "@/types/api"; import type { ProposalDraftForm } from "./types"; type ProposalCreationTemplateKind = "project" | "system"; @@ -12,6 +13,7 @@ export type ProposalDraftHydrationResult = { draft: ProposalDraftForm; draftId: string; presetId: string; + publication: DraftPublicationSummaryDto; templateKind: ProposalCreationTemplateKind; }; @@ -71,6 +73,7 @@ export function useProposalDraftHydration({ draft: normalized, draftId: nextDraftId, presetId: nextPresetId, + publication: detail.publication, templateKind: nextTemplateKind, }); } catch (error) { diff --git a/src/pages/proposals/proposalCreation/wizardModel.ts b/src/pages/proposals/proposalCreation/wizardModel.ts index d7dcc5c..5194609 100644 --- a/src/pages/proposals/proposalCreation/wizardModel.ts +++ b/src/pages/proposals/proposalCreation/wizardModel.ts @@ -56,6 +56,7 @@ export type StepValidation = { export type WizardContext = { draft: ProposalDraftForm; presetId: string; + publicationReady?: boolean; tierBlocked: boolean; }; @@ -195,7 +196,9 @@ export function proposalBudgetTotal(draft: ProposalDraftForm): number { : positiveAmountTotal(draft.budgetItems, (item) => item.amount); } -function systemTargetValid(draft: ProposalDraftForm): StepValidation { +export function validateSystemDraftTarget( + draft: ProposalDraftForm, +): StepValidation { if (draft.title.trim().length === 0) { return { valid: false, firstInvalidFieldId: "title" }; } @@ -254,7 +257,7 @@ export function validateWizardStep( } return { valid: true }; } - if (stepId === "system-change") return systemTargetValid(draft); + if (stepId === "system-change") return validateSystemDraftTarget(draft); if (stepId === "plan" || stepId === "rationale") { return draft.how.trim().length > 0 ? { valid: true } @@ -289,10 +292,14 @@ export function reachableWizardSteps( context: WizardContext, ): WizardStepId[] { const result: WizardStepId[] = []; - for (const step of WIZARD_PATHS[pathId].steps) { + const definition = WIZARD_PATHS[pathId]; + for (const step of definition.steps) { result.push(step.id); if (!validateWizardStep(step.id, context).valid) break; } + if (context.publicationReady && !result.includes("review")) { + result.push("review"); + } return result; } diff --git a/src/types/api.ts b/src/types/api.ts index bb6e695..c2b4400 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -670,9 +670,30 @@ export type ProposalStatusDto = { | "completed"; pendingMilestoneIndex?: number | null; initiative?: InitiativeReferenceDto; + draftHistory?: { + draftId: string; + route: string; + revision: number; + }; updatedAt: string; }; +export type DraftPublicationStatusDto = + | "private" + | "published" + | "withdrawn" + | "submitted"; + +export type DraftPublicationSummaryDto = { + status: DraftPublicationStatusDto; + revision?: number; + publicUrl?: string; + publishedAt?: string; + publicUpdatedAt?: string; + hasUnpublishedChanges?: boolean; + submittedProposalRoute?: string; +}; + export type ProposalDraftListItemDto = { id: string; title: string; @@ -680,9 +701,31 @@ export type ProposalDraftListItemDto = { tier: string; summary: string; updated: string; + publication: DraftPublicationSummaryDto; }; export type GetProposalDraftsResponse = { items: ProposalDraftListItemDto[] }; +export type PublicProposalDraftKindDto = "policy" | "formation" | "system"; +export type PublicProposalDraftSortDto = "updated" | "published"; + +export type PublicProposalDraftListItemDto = { + id: string; + title: string; + chamber: string; + summary: string; + proposer: string; + proposalKind: PublicProposalDraftKindDto; + revision: number; + publishedAt: string; + updatedAt: string; + initiative?: InitiativeReferenceDto; +}; + +export type GetPublicProposalDraftsResponse = { + items: PublicProposalDraftListItemDto[]; + nextCursor?: string; +}; + export type ProposalDraftEditableFormDto = { templateId?: "project" | "system"; presetId?: string; @@ -743,7 +786,9 @@ export type ProposalDraftDetailDto = { teamSlots: string; milestonesPlanned: string; summary: string; + overview: string; rationale: string; + executionPlan: string[]; budgetScope: string; checklist: string[]; milestones: string[]; @@ -751,6 +796,9 @@ export type ProposalDraftDetailDto = { openSlotNeeds: { title: string; desc: string }[]; milestonesDetail: { title: string; desc: string }[]; attachments: { title: string; href?: string }[]; + authoring: ProposalAuthoringDetailsDto; + publication: DraftPublicationSummaryDto; + initiative?: InitiativeReferenceDto; editableForm?: ProposalDraftEditableFormDto; }; @@ -1103,6 +1151,7 @@ export type HumanNodeDto = { memberSince: string; formationCapable?: boolean; active: { + governor: boolean; governorActive: boolean; humanNodeActive: boolean; }; @@ -1161,6 +1210,7 @@ export type HumanDelegationChamberDto = { export type HumanNodeProfileDto = { id: string; name: string; + governor: boolean; governorActive: boolean; humanNodeActive: boolean; governanceSummary: string; diff --git a/src/types/stages.ts b/src/types/stages.ts index a97e6dc..44b733b 100644 --- a/src/types/stages.ts +++ b/src/types/stages.ts @@ -27,9 +27,10 @@ export const feedStages = [ export type FeedStage = (typeof feedStages)[number]; -export type Stage = ProposalStage | FeedStage; +export type Stage = ProposalStage | FeedStage | "draft"; export type StageChipKind = + | "draft" | "proposal_pool" | "chamber_vote" | "citizen_veto" @@ -44,6 +45,7 @@ export type StageChipKind = | "system"; export const stageToChipKind = { + draft: "draft", pool: "proposal_pool", vote: "chamber_vote", citizen_veto: "citizen_veto", @@ -59,6 +61,7 @@ export const stageToChipKind = { } as const satisfies Record; export const stageLabel = { + draft: "Draft", pool: "Proposal pool", vote: "Chamber vote", citizen_veto: "Citizen veto", diff --git a/tests/e2e/proposal-wizard.spec.ts b/tests/e2e/proposal-wizard.spec.ts index 3103fb9..d54850d 100644 --- a/tests/e2e/proposal-wizard.spec.ts +++ b/tests/e2e/proposal-wizard.spec.ts @@ -38,7 +38,9 @@ function draftDetail(id: string, editableForm: typeof existingDraftForm) { teamSlots: "0", milestonesPlanned: "0", summary: editableForm.summary, + overview: editableForm.what, rationale: editableForm.why, + executionPlan: [], budgetScope: "No Formation budget", checklist: [], milestones: [], @@ -46,6 +48,18 @@ function draftDetail(id: string, editableForm: typeof existingDraftForm) { openSlotNeeds: [], milestonesDetail: [], attachments: [], + authoring: { + kind: "project", + presetId: editableForm.presetId, + proposalType: editableForm.proposalType, + what: editableForm.what, + why: editableForm.why, + how: editableForm.how, + aboutMe: editableForm.aboutMe, + outputs: [], + budgetItems: [], + }, + publication: { status: "private" }, editableForm, }; } @@ -160,6 +174,7 @@ async function installApiFixtures(page: Page) { json: { id: address, name: "Test Governor", + governor: true, governorActive: true, humanNodeActive: true, governanceSummary: "", @@ -189,6 +204,20 @@ async function installApiFixtures(page: Page) { }); return; } + if (body.type === "proposal.draft.publish") { + await route.fulfill({ + json: { + ok: true, + type: body.type, + draftId: "draft-e2e", + revision: 1, + publicUrl: "/app/proposals/public-drafts/draft-e2e", + publishedAt: "2026-07-02T12:00:00.000Z", + updatedAt: "2026-07-02T12:00:00.000Z", + }, + }); + return; + } if (body.type === "proposal.submitToPool") { await route.fulfill({ json: { @@ -349,6 +378,37 @@ test("fresh entry ignores legacy Review state and completes policy submission", await expect(page).toHaveURL(/\/app\/proposals\/proposal-e2e\/pp$/); }); +test("Review publishes an explicit public snapshot without submitting", async ({ + page, +}) => { + await openFreshWizard(page); + await page.locator("#proposal-kind").selectOption("project"); + await page.locator("#proposal-type").selectOption("basic"); + await page.locator("#proposal-formation-mode").selectOption("policy"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.locator("#title").fill("Draft before governance"); + await page.locator("#chamber").selectOption("general"); + await page.locator("#summary").fill("Invite review before submission."); + await page.locator("#what").fill("Publish a readable snapshot."); + await page.locator("#why").fill("Catch mistakes before formal voting."); + await page.getByRole("button", { name: "Continue" }).click(); + await page.locator("#how").fill("Review, revise, and submit separately."); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator("#agree-rules")).not.toBeChecked(); + await expect(page.locator("#confirm-budget")).not.toBeChecked(); + + const publishRequest = page.waitForRequest((request) => { + if (!request.url().endsWith("/api/command")) return false; + return request.postDataJSON()?.type === "proposal.draft.publish"; + }); + await page.getByRole("button", { name: "Publish draft" }).click(); + await publishRequest; + await expect( + page.getByRole("button", { name: "Update public draft" }), + ).toBeVisible(); + await expect(page).toHaveURL(/step=review/); +}); + test("submission locks draft-changing controls until the pool response returns", async ({ page, }) => { @@ -561,6 +621,88 @@ test("submission synchronizes the newest edit after an earlier save is still pen expect(submittedDraftId).toBe("draft-latest-revision"); }); +test("an ambiguous submission failure retries without rewriting the submitted draft", async ({ + page, +}) => { + let saveCount = 0; + const submitKeys: string[] = []; + + await openFreshWizard(page); + await page.route("**/api/command", async (route) => { + const request = route.request(); + const body = request.postDataJSON() as { type?: string }; + if (body.type === "proposal.draft.save") { + saveCount += 1; + await route.fulfill({ + json: { + ok: true, + type: body.type, + draftId: "draft-ambiguous-submit", + updatedAt: "2026-07-03T12:00:00.000Z", + }, + }); + return; + } + if (body.type === "proposal.submitToPool") { + submitKeys.push(request.headers()["idempotency-key"] ?? ""); + if (submitKeys.length === 1) { + await route.fulfill({ + status: 503, + json: { + error: { + code: "temporarily_unavailable", + message: "Submission response was interrupted.", + }, + }, + }); + return; + } + await route.fulfill({ + json: { + ok: true, + type: body.type, + proposalId: "proposal-recovered-submit", + }, + }); + return; + } + await route.fallback(); + }); + + await page.locator("#proposal-kind").selectOption("project"); + await page.locator("#proposal-type").selectOption("basic"); + await page.locator("#proposal-formation-mode").selectOption("policy"); + await page.getByRole("button", { name: "Continue" }).click(); + await page.locator("#title").fill("Recover uncertain submission"); + await page.locator("#chamber").selectOption("general"); + await page.locator("#summary").fill("Retry without rewriting the draft."); + await page.locator("#what").fill("Preserve an accepted proposal command."); + await page + .locator("#why") + .fill("Network failures cannot reveal commit state."); + await page.getByRole("button", { name: "Continue" }).click(); + await page.locator("#how").fill("Replay the same idempotent submission."); + await page.getByRole("button", { name: "Continue" }).click(); + await page.locator("#agree-rules").check(); + await page.locator("#confirm-budget").check(); + + await page.getByRole("button", { name: "Submit proposal" }).click(); + await expect( + page.getByText("Submission response was interrupted."), + ).toBeVisible(); + expect(saveCount).toBe(1); + expect(submitKeys).toHaveLength(1); + + await page.getByRole("button", { name: "Submit proposal" }).click(); + await expect(page).toHaveURL( + /\/app\/proposals\/proposal-recovered-submit\/pp$/, + ); + expect(saveCount).toBe(1); + expect(submitKeys).toHaveLength(2); + expect(submitKeys[0]).not.toBe(""); + expect(submitKeys[1]).toBe(submitKeys[0]); +}); + test("a keyboard-only author can complete a policy proposal", async ({ page, }) => { diff --git a/tests/e2e/public-drafts.spec.ts b/tests/e2e/public-drafts.spec.ts new file mode 100644 index 0000000..7df2c75 --- /dev/null +++ b/tests/e2e/public-drafts.spec.ts @@ -0,0 +1,321 @@ +import { expect, test, type Page } from "@playwright/test"; + +const draftId = "draft-public-governance"; +const author = "hmr1GRb1SRdDfJZmFaYh5L1RNev3dFcTVLGS2Rqqmk3Fbgj2W"; + +const listItem = { + id: draftId, + title: "Transparent governance reporting", + chamber: "General Chamber", + summary: "Publish regular governance reports before formal submission.", + proposer: author, + proposalKind: "policy", + revision: 2, + publishedAt: "2026-07-22T10:00:00.000Z", + updatedAt: "2026-07-23T12:30:00.000Z", + initiative: { + id: "governance-observatory", + slug: "governance-observatory", + title: "Governance Observatory", + }, +}; + +const detail = { + id: draftId, + submittedAt: null, + submittedProposalId: null, + title: listItem.title, + proposer: author, + chamber: "General Chamber", + focus: "Basic", + tier: "Consul", + budget: "0 HMND", + formationEligible: false, + teamSlots: "0 / 0", + milestonesPlanned: "0", + summary: listItem.summary, + overview: "Create a public reporting policy with readable evidence.", + rationale: + "Let reviewers catch mistakes before the proposal enters governance.", + executionPlan: [ + "Publish a signed report at the end of every governance era.", + ], + budgetScope: "No Formation budget", + checklist: [], + milestones: [], + teamLocked: [], + openSlotNeeds: [], + milestonesDetail: [], + attachments: [], + authoring: { + kind: "project", + presetId: "project.policy", + proposalType: "basic", + what: "Create a public reporting policy with readable evidence.", + why: "Let reviewers catch mistakes before the proposal enters governance.", + how: "Publish a signed report at the end of every governance era.", + aboutMe: "", + outputs: [], + timeline: [], + budgetItems: [], + systemAction: { + action: null, + chamberId: null, + targetAddress: null, + title: null, + multiplier: null, + genesisMembers: [], + }, + }, + publication: { + status: "published", + revision: 2, + publicUrl: `/app/proposals/public-drafts/${draftId}`, + publishedAt: listItem.publishedAt, + publicUpdatedAt: listItem.updatedAt, + }, + initiative: listItem.initiative, +}; + +async function installFixtures(page: Page) { + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/me") { + await route.fulfill({ json: { authenticated: false } }); + return; + } + if (url.pathname === "/api/proposals/public-drafts") { + await route.fulfill({ json: { items: [listItem] } }); + return; + } + if (url.pathname === `/api/proposals/public-drafts/${draftId}`) { + await route.fulfill({ json: detail }); + return; + } + await route.fulfill({ json: { items: [] } }); + }); +} + +test("anonymous readers can discover and open a complete public draft", async ({ + page, +}) => { + await installFixtures(page); + await page.goto("/app/proposals/public-drafts"); + await expect( + page.getByRole("heading", { name: "Public drafts", exact: true }), + ).toBeVisible(); + await expect(page.getByText(listItem.title, { exact: true })).toBeVisible(); + await page.getByRole("article").getByRole("button").click(); + await page.getByRole("link", { name: "Read draft" }).click(); + await expect(page).toHaveURL(`/app/proposals/public-drafts/${draftId}`); + await expect( + page.getByRole("heading", { name: listItem.title, exact: true }), + ).toBeVisible(); + await expect(page.getByText("Draft", { exact: true }).first()).toBeVisible(); + await expect(page.getByText(detail.overview, { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Continue editing" }), + ).toHaveCount(0); +}); + +test("public draft owners receive one coherent action group", async ({ + page, +}) => { + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/me") { + await route.fulfill({ + json: { + authenticated: true, + address: author, + gate: { + eligible: true, + expiresAt: "2026-07-24T20:00:00.000Z", + }, + }, + }); + return; + } + if (url.pathname === `/api/proposals/public-drafts/${draftId}`) { + await route.fulfill({ json: detail }); + return; + } + await route.fulfill({ json: { items: [] } }); + }); + + await page.goto(`/app/proposals/public-drafts/${draftId}`); + await expect(page.getByRole("button", { name: "Copy link" })).toHaveCount(1); + await expect( + page.getByRole("link", { name: "Continue editing" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Unpublish" })).toBeVisible(); +}); + +test("changing the directory query retires the previous pagination cursor", async ({ + page, +}) => { + let mixedCursorRequest = false; + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/me") { + await route.fulfill({ json: { authenticated: false } }); + return; + } + if (url.pathname === "/api/proposals/public-drafts") { + if (url.searchParams.get("q") && url.searchParams.get("cursor")) { + mixedCursorRequest = true; + } + if (url.searchParams.get("q")) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + await route.fulfill({ + json: url.searchParams.get("q") + ? { items: [] } + : { items: [listItem], nextCursor: "cursor-from-old-query" }, + }); + return; + } + await route.fulfill({ json: { items: [] } }); + }); + + await page.goto("/app/proposals/public-drafts"); + await expect(page.getByRole("button", { name: "Load more" })).toBeVisible(); + await page.getByLabel("Search public drafts").fill("different"); + await page.waitForTimeout(350); + expect(await page.getByRole("button", { name: "Load more" }).count()).toBe(0); + expect(mixedCursorRequest).toBe(false); +}); + +test("My Drafts keeps private and public drafts together and toggles visibility", async ({ + page, +}) => { + const privateDraft = { + id: "owned-private-draft", + title: "Private policy notes", + chamber: "General Chamber", + tier: "Consul", + summary: "Notes that are ready to become a public draft.", + updated: "2026-07-24T13:00:00.000Z", + publication: { status: "private" }, + }; + const publicDraft = { + id: "owned-public-draft", + title: "Published governance outline", + chamber: "General Chamber", + tier: "Consul", + summary: "An owned draft that is already visible publicly.", + updated: "2026-07-24T12:00:00.000Z", + publication: { + status: "published", + revision: 2, + publicUrl: "/app/proposals/public-drafts/owned-public-draft", + publishedAt: "2026-07-23T12:00:00.000Z", + publicUpdatedAt: "2026-07-24T12:00:00.000Z", + hasUnpublishedChanges: false, + }, + }; + 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: author, + gate: { eligible: true, expiresAt: "2026-08-01T00:00:00.000Z" }, + }, + }); + return; + } + if (url.pathname === "/api/proposals/drafts") { + await route.fulfill({ json: { items: [privateDraft, publicDraft] } }); + return; + } + if (url.pathname.startsWith("/api/humans/")) { + await route.fulfill({ + json: { + id: author, + name: "Draft Author", + humanNodeActive: true, + governor: true, + governorActive: false, + heroStats: [], + quickDetails: [], + proofSections: {}, + governanceActions: [], + delegation: { chambers: [] }, + delegationEligibleChambers: [], + projects: [], + activity: [], + history: [], + }, + }); + return; + } + if (url.pathname === "/api/command" && request.method() === "POST") { + const body = request.postDataJSON() as { + type?: string; + payload?: { draftId?: string }; + }; + if (body.type === "proposal.draft.publish") { + await route.fulfill({ + json: { + ok: true, + type: body.type, + draftId: body.payload?.draftId, + revision: 1, + publicUrl: `/app/proposals/public-drafts/${body.payload?.draftId}`, + publishedAt: "2026-07-24T14:00:00.000Z", + updatedAt: "2026-07-24T14:00:00.000Z", + }, + }); + return; + } + if (body.type === "proposal.draft.unpublish") { + await route.fulfill({ + json: { + ok: true, + type: body.type, + draftId: body.payload?.draftId, + unpublished: true, + updatedAt: "2026-07-24T14:05:00.000Z", + }, + }); + return; + } + } + await route.fulfill({ json: { items: [] } }); + }); + + await page.goto("/app/proposals/drafts"); + const authPanel = page.locator(".sidebar__auth"); + const governorRow = authPanel.locator(".sidebar__authRow", { + has: page.getByText("Governor", { exact: true }), + }); + const activeGovernorRow = authPanel.locator(".sidebar__authRow", { + has: page.getByText("Active governor", { exact: true }), + }); + await expect(governorRow.getByText("Active", { exact: true })).toBeVisible(); + await expect( + activeGovernorRow.getByText("Not active", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(privateDraft.title, { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(publicDraft.title, { exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Make draft public" }).click(); + await expect( + page.getByRole("button", { name: "Make draft private" }), + ).toHaveCount(2); + + page.once("dialog", (dialog) => void dialog.accept()); + await page + .getByRole("button", { name: "Make draft private" }) + .first() + .click(); + await expect( + page.getByRole("button", { name: "Make draft public" }), + ).toHaveCount(1); +}); diff --git a/tests/unit/human-nodes-ui.test.ts b/tests/unit/human-nodes-ui.test.ts index 8882204..687af96 100644 --- a/tests/unit/human-nodes-ui.test.ts +++ b/tests/unit/human-nodes-ui.test.ts @@ -9,6 +9,7 @@ import { getHumanNodeManageableDelegationChambers, getHumanNodeViewerDelegationByChamber, getHumanNodeVisibleHeroStats, + governanceIdentityStatuses, isLikelyHumanodeAddress, shouldShowHumanNodeShortBadge, } from "../../src/lib/humanNodesUi"; @@ -32,6 +33,7 @@ const node = ( mm: 0, memberSince: "2026-01-01T00:00:00.000Z", active: { + governor: false, governorActive: false, humanNodeActive: false, }, @@ -44,6 +46,7 @@ const profile = ( ): HumanNodeProfileDto => ({ id: "hmpt3fxBvpWrkZxq5H5uWjZ2BgHRMJs2hKHiWJDoqD7am1xPs", name: "Human Node Profile", + governor: true, governorActive: true, humanNodeActive: true, governanceSummary: "", @@ -87,6 +90,24 @@ test("isLikelyHumanodeAddress identifies long hm addresses only", () => { expect(isLikelyHumanodeAddress("hm-short")).toBe(false); }); +test("governor and active-governor labels preserve distinct semantics", () => { + expect( + governanceIdentityStatuses({ + governor: true, + activeGovernor: false, + humanNode: true, + }), + ).toEqual({ + governor: { label: "Governor", value: "Active", active: true }, + activeGovernor: { + label: "Active governor", + value: "Not active", + active: false, + }, + humanNode: { label: "Human node", value: "Active", active: true }, + }); +}); + test("filterHumanNodes searches chamber and faction names", () => { const result = filterHumanNodes({ chambersById: { @@ -122,12 +143,16 @@ test("filterHumanNodes sorts by ACM and honors active status", () => { }, nodes: [ node("low", { - active: { governorActive: true, humanNodeActive: true }, + active: { governor: true, governorActive: true, humanNodeActive: true }, acm: 10, }), node("inactive", { acm: 100 }), node("high", { - active: { governorActive: true, humanNodeActive: false }, + active: { + governor: true, + governorActive: true, + humanNodeActive: false, + }, cmTotals: { lcm: 0, mcm: 0, acm: 50 }, }), ], diff --git a/tests/unit/proposal-draft-ui.test.ts b/tests/unit/proposal-draft-ui.test.ts new file mode 100644 index 0000000..c1ff7c7 --- /dev/null +++ b/tests/unit/proposal-draft-ui.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "@rstest/core"; + +import { + canPublishDraft, + editDraftRoute, + isPublicDraftVisible, + ownerDraftRoute, + publicationRoute, + publicDraftRoute, + reconsiderProposalRoute, +} from "../../src/pages/proposals/draft/draftUi"; + +describe("proposal draft UI contracts", () => { + test("builds encoded owner, editor, public, and reconsideration routes", () => { + expect(ownerDraftRoute("draft / one")).toBe( + "/app/proposals/drafts/draft%20%2F%20one", + ); + expect(editDraftRoute("draft / one")).toBe( + "/app/proposals/new?draftId=draft%20%2F%20one", + ); + expect(publicDraftRoute("draft / one")).toBe( + "/app/proposals/public-drafts/draft%20%2F%20one", + ); + expect(reconsiderProposalRoute("proposal / one")).toBe( + "/app/proposals/new?resubmitsProposalId=proposal%20%2F%20one", + ); + }); + + test("uses server routes and keeps publication states explicit", () => { + expect( + publicationRoute("draft-1", { + status: "published", + publicUrl: "/canonical/draft-1", + }), + ).toBe("/canonical/draft-1"); + expect(canPublishDraft({ status: "private" })).toBe(true); + expect( + canPublishDraft({ + status: "published", + hasUnpublishedChanges: false, + }), + ).toBe(false); + expect(isPublicDraftVisible({ status: "submitted" })).toBe(true); + expect(isPublicDraftVisible({ status: "withdrawn" })).toBe(false); + }); +}); diff --git a/tests/unit/proposal-stage-navigation.test.ts b/tests/unit/proposal-stage-navigation.test.ts index cabfb77..de1b514 100644 --- a/tests/unit/proposal-stage-navigation.test.ts +++ b/tests/unit/proposal-stage-navigation.test.ts @@ -44,3 +44,18 @@ test("proposal stage navigation links terminal live stage and prior formation sn assert.equal(links.passed, "/app/proposals/p-3/finished"); assert.equal(links.failed, undefined); }); + +test("proposal stage navigation exposes Draft only from server-provided history", () => { + const withoutHistory = buildProposalStageLinks({ + liveStage: "vote", + proposalId: "p-4", + }); + assert.equal(withoutHistory.draft, undefined); + + const withHistory = buildProposalStageLinks({ + draftRoute: "/app/proposals/public-drafts/draft-p-4", + liveStage: "vote", + proposalId: "p-4", + }); + assert.equal(withHistory.draft, "/app/proposals/public-drafts/draft-p-4"); +}); diff --git a/tests/unit/proposal-wizard-model.test.ts b/tests/unit/proposal-wizard-model.test.ts index 8ef6573..aa8c76c 100644 --- a/tests/unit/proposal-wizard-model.test.ts +++ b/tests/unit/proposal-wizard-model.test.ts @@ -16,6 +16,7 @@ import { validateWizardStep, type WizardContext, } from "../../src/pages/proposals/proposalCreation/wizardModel"; +import { isProposalDraftPublicationReady } from "../../src/pages/proposals/proposalCreation/publicationReadiness"; function completePolicyDraft(): ProposalDraftForm { return { @@ -79,6 +80,48 @@ test("reachable steps stop after the first invalid requirement", () => { ).toEqual(["intent", "essentials", "plan"]); }); +test("public review can be reached before the formal submission path is complete", () => { + const draft = { + ...completePolicyDraft(), + chamberId: "general", + summary: "Ready for public review", + how: "", + agreeRules: false, + confirmBudget: false, + }; + expect(isProposalDraftPublicationReady(draft, "project")).toBe(true); + expect( + reachableWizardSteps("project-policy", { + ...context(draft), + publicationReady: true, + }), + ).toEqual(["intent", "essentials", "plan", "review"]); + expect(validateWizardStep("review", context(draft)).valid).toBe(false); +}); + +test("system publication requires the action's canonical target fields", () => { + const draft: ProposalDraftForm = { + ...completePolicyDraft(), + title: "Create a chamber", + chamberId: "general", + summary: "Open a public system Draft.", + metaGovernance: { + action: "chamber.create", + chamberId: "research", + }, + }; + expect(isProposalDraftPublicationReady(draft, "system")).toBe(false); + expect( + isProposalDraftPublicationReady( + { + ...draft, + metaGovernance: { ...draft.metaGovernance!, title: "Research" }, + }, + "system", + ), + ).toBe(true); +}); + test("Formation funding validates every milestone budget", () => { const draft = { ...completePolicyDraft(), diff --git a/tests/unit/proposal-wizard-submit-routing.test.ts b/tests/unit/proposal-wizard-submit-routing.test.ts index cec2011..e850245 100644 --- a/tests/unit/proposal-wizard-submit-routing.test.ts +++ b/tests/unit/proposal-wizard-submit-routing.test.ts @@ -59,3 +59,20 @@ test("incomplete server drafts resolve to the first incomplete local step", () = ), ).toBe("plan"); }); + +test("publication validation returns to the path-owned content step", () => { + expect( + proposalSubmitErrorStep( + apiError("draft_publication_summary_required"), + "project-policy", + context, + ), + ).toBe("essentials"); + expect( + proposalSubmitErrorStep( + apiError("draft_publication_system_action_required"), + "system-change", + context, + ), + ).toBe("system-change"); +});