Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
7236413
feat(dashboard): import a proposal from JSON when creating one
brunod-e Aug 2, 2026
c8f5cb0
refactor(dashboard): slim down the Import JSON modal
brunod-e Aug 2, 2026
b0de3eb
fix(dashboard): validate imported calldata before accepting the action
brunod-e Aug 2, 2026
cb77457
fix(dashboard): validate ABI-backed custom actions before import
brunod-e Aug 2, 2026
fd7f6fd
fix(dashboard): settle imported values that only fail at publish
brunod-e Aug 3, 2026
8e47afa
fix(dashboard): close four more paths where a paste fails after import
brunod-e Aug 3, 2026
fd943a7
fix(dashboard): carry the arg checks into composite values
brunod-e Aug 3, 2026
e30c9e9
fix(dashboard): reject amounts and abi types a paste can't carry
brunod-e Aug 3, 2026
75ebadc
fix(dashboard): void a pending decimals read when the paste is edited
brunod-e Aug 3, 2026
77d5930
fix(dashboard): require every imported figure to be quoted
brunod-e Aug 3, 2026
31d14a8
feat(dashboard): accept a .json file in the import modal
brunod-e Aug 3, 2026
86cf7b7
test(dashboard): collapse the import test boilerplate
brunod-e Aug 3, 2026
a883958
fix(dashboard): stop the import from storing what the editor can't hold
brunod-e Aug 3, 2026
2886163
fix(dashboard): refuse a bare function name several overloads share
brunod-e Aug 3, 2026
7ae1865
docs(changeset): trim the import entry to one line
brunod-e Aug 3, 2026
fcfceb3
fix(dashboard): stop accepting an ETH value the form can't show
brunod-e Aug 3, 2026
eccc01f
fix(dashboard): refuse a read-only function on import
brunod-e Aug 3, 2026
c6024de
fix(dashboard): reject a tuple filled past its declared fields
brunod-e Aug 3, 2026
a2b8c38
fix(dashboard): report a rejected upload beside the file button
brunod-e Aug 3, 2026
f9de508
fix(dashboard): trim scalar args, string excepted
brunod-e Aug 3, 2026
c6162dd
refactor(dashboard): one conversion from stored args to calldata
brunod-e Aug 3, 2026
86aeddd
refactor(dashboard): validate an action once, in the form that holds it
brunod-e Aug 3, 2026
cbb9716
fix(dashboard): close the three findings left open
brunod-e Aug 3, 2026
35707fd
feat(dashboard): rework the JSON proposal import per design review
brunod-e Aug 4, 2026
318e06c
fix(dashboard): refuse a custom action that mixes calldata with an AB…
brunod-e Aug 4, 2026
ef23bc4
fix(dashboard): fail closed when encoding a malformed composite arg
brunod-e Aug 5, 2026
1cf17b1
refactor(dashboard): reach create-proposal through its public entry p…
brunod-e Aug 5, 2026
929d8a0
fix(dashboard): agree on the import key whatever case the slug arrive…
brunod-e Aug 5, 2026
54b385b
fix(dashboard): keep the pasted proposal when the import handoff refu…
brunod-e Aug 5, 2026
f681113
fix(dashboard): refuse a composite arg leaf the ABI parameter cannot …
brunod-e Aug 5, 2026
0540720
fix(dashboard): check an action with the converter that will encode it
brunod-e Aug 5, 2026
8535f72
fix(dashboard): refuse a tuple arg that is short of its components
brunod-e Aug 5, 2026
5f94add
refactor(dashboard): cut the import utils down to what the task needs
brunod-e Aug 5, 2026
6e97745
fix(dashboard): refuse an arg count the ABI function doesn't take
brunod-e Aug 5, 2026
8b7e4d2
docs(dashboard): name the converter that actually degrades a bad leaf
brunod-e Aug 5, 2026
b266d1f
fix(dashboard): clear a cancelled import from the handoff stash
brunod-e Aug 5, 2026
96aecf5
fix(dashboard): carry the import handoff across browsing contexts
brunod-e Aug 5, 2026
a91ec72
Merge remote-tracking branch 'origin/dev' into feat/import-proposal-json
brunod-e Aug 5, 2026
21ceb96
style(dashboard): bring comments and one helper in line with the hous…
brunod-e Aug 5, 2026
9008276
refactor(dashboard): one validator for a proposal action, not three
brunod-e Aug 5, 2026
e853c5d
chore(dashboard): lock abitype and jsonc-parser
brunod-e Aug 5, 2026
906fc44
docs(dashboard): cut the commentary down to the traps
brunod-e Aug 5, 2026
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
8 changes: 8 additions & 0 deletions .changeset/import-proposal-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@anticapture/dashboard": minor
---

Add an Import JSON button to the create proposal form. It opens a modal (drawer
on mobile) with a textarea for pasting a proposal document plus a description of
the expected format, and fills the title, discussion URL, description, and
actions from what the document provides.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronLeft } from "lucide-react";
import { ChevronLeft, FileJson } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import Link from "next/link";
Expand All @@ -19,6 +19,7 @@ import daoConfig from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";
import { formatNumberUserReadable } from "@/shared/utils/formatNumberUserReadable";
import { getWhitelabelBasePath } from "@/shared/utils/whitelabel";
import { Button } from "@/shared/components/design-system/buttons/button/Button";
import { FormLabel } from "@/shared/components/design-system/form/fields/form-label/FormLabel";
import { Input } from "@/shared/components/design-system/form/fields/input/Input";
import { useLogin } from "@/shared/services/auth/LoginProvider";
Expand Down Expand Up @@ -53,6 +54,10 @@ import { PublishModal } from "@/features/create-proposal/components/modals/Publi
import { ProposalSubmittedModal } from "@/features/create-proposal/components/modals/ProposalSubmittedModal";
import { SubmissionFailedModal } from "@/features/create-proposal/components/modals/SubmissionFailedModal";
import { InsufficientVPModal } from "@/features/create-proposal/components/modals/InsufficientVPModal";
import {
ImportJsonModal,
type ImportedProposal,
} from "@/features/create-proposal/components/modals/ImportJsonModal";
import type {
CustomAction,
ERC20TransferAction,
Expand Down Expand Up @@ -213,6 +218,7 @@ export const ProposalCreationForm = ({
const [submittedOpen, setSubmittedOpen] = useState(false);
const [failedOpen, setFailedOpen] = useState(false);
const [insufficientOpen, setInsufficientOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false);

// Email/Google sign-in leaves the page, so a dirty form would be lost on
Expand Down Expand Up @@ -471,6 +477,27 @@ export const ProposalCreationForm = ({
}
};

// Field-by-field setValue rather than form.reset: the imported content is
// unsaved, so the dirty flag (and with it NavigationGuard) has to stay armed.
const handleImportJson = (imported: ImportedProposal) => {
const options = { shouldDirty: true, shouldValidate: true } as const;
if (imported.title !== undefined) {
form.setValue("title", imported.title, options);
}
if (imported.discussionUrl !== undefined) {
form.setValue("discussionUrl", imported.discussionUrl, options);
}
if (imported.body !== undefined) {
form.setValue("body", imported.body, options);
Comment thread
brunod-e marked this conversation as resolved.
// MDXEditor only reads `markdown` on mount, so remount it on the new body.
setBodyVersion((v) => v + 1);
}
if (imported.actions !== undefined) {
form.setValue("actions", imported.actions, options);
}
showCustomToast("Proposal fields imported", "success");
};

const openEditForAction = (index: number) => {
const action = values.actions[index];
if (!action) return;
Expand Down Expand Up @@ -659,6 +686,21 @@ export const ProposalCreationForm = ({
/>
)}

<div className="flex items-center justify-between gap-3">
<p className="text-secondary text-xs">
Already have this proposal as JSON? Import it to fill the form.
</p>
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
className="shrink-0"
>
<FileJson className="size-3.5" />
Import JSON
</Button>
</div>

<div className="flex w-full flex-col gap-1">
<FormLabel isRequired>Title</FormLabel>
<Input
Expand Down Expand Up @@ -731,6 +773,11 @@ export const ProposalCreationForm = ({
</>
)}

<ImportJsonModal
open={importOpen}
onOpenChange={setImportOpen}
onImport={handleImportJson}
/>
<AddTransferModal
open={transferOpen}
onOpenChange={(o) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"use client";

import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { erc20Abi } from "viem";
import { usePublicClient } from "wagmi";

import { FormLabel } from "@/shared/components/design-system/form/fields/form-label/FormLabel";
import { Textarea } from "@/shared/components/design-system/form/fields/textarea/Textarea";
import { Modal } from "@/shared/components/design-system/modal/Modal";
import daoConfig from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";
import { PROPOSAL_JSON_PLACEHOLDER } from "@/features/create-proposal/constants";
import type { ProposalFormValues } from "@/features/create-proposal/schema";
import { parseProposalJson } from "@/features/create-proposal/utils/parseProposalJson";
import {
needsDecimalsLookup,
resolveImportedDecimals,
} from "@/features/create-proposal/utils/resolveImportedDecimals";

export type ImportedProposal = {
title?: string;
discussionUrl?: string;
body?: string;
actions?: ProposalFormValues["actions"];
};

interface ImportJsonModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImport: (values: ImportedProposal) => void;
}

const Code = ({ children }: { children: React.ReactNode }) => (
<code className="text-primary font-mono">{children}</code>
);

// Keep the prose short: a line break right after a <code> swallows the space
// that follows it, so each entry has to fit on one line.
const Field = ({
name,
children,
}: {
name: string;
children: React.ReactNode;
}) => (
<li>
<Code>{name}</Code> {children}
</li>
);

export const ImportJsonModal = ({
open,
onOpenChange,
onImport,
}: ImportJsonModalProps) => {
const { daoId: daoIdParam } = useParams<{ daoId: string }>();
const daoIdEnum = (daoIdParam ?? "").toUpperCase() as DaoIdEnum;
const chainId = daoConfig[daoIdEnum]?.daoOverview?.chain?.id;
const publicClient = usePublicClient(chainId ? { chainId } : undefined);

const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const [isResolving, setIsResolving] = useState(false);

// Cancel, Escape and the close button all stay live while decimals are being
// read, and the lookup keeps running after the sheet is gone. Every attempt
// takes a ticket; a resolved lookup whose ticket is no longer current is
// dropped, so a cancelled paste (or the previous one, after a reopen) can't
// land on the form later.
const attemptRef = useRef(0);

// Start from a clean sheet on every open: a previous failed paste hanging
// around next to a stale error reads as if the import already ran.
useEffect(() => {
if (!open) return;
attemptRef.current += 1;
setText("");
setError(null);
setIsResolving(false);
}, [open]);

const close = () => {
attemptRef.current += 1;
setText("");
setError(null);
setIsResolving(false);
onOpenChange(false);
};

const handleConfirm = async () => {
const attempt = ++attemptRef.current;

const result = parseProposalJson(text);
if (!result.ok) {
setError(result.error);
return;
}

const pending = result.value.actions;
let actions: ProposalFormValues["actions"] | undefined;

if (pending) {
// Only ERC-20 transfers need the chain, so a document without one still
// imports offline. Their decimals decide how the amount is scaled at
// publish, so they come from the token rather than from the paste.
const needsChain = needsDecimalsLookup(pending);
if (needsChain && !publicClient) {
setError(
"No RPC client available to read the token decimals. Reconnect your wallet and try again.",
);
return;
}

if (needsChain) setIsResolving(true);
const resolved = await resolveImportedDecimals(
pending,
async (tokenAddress) => {
// Unreachable past the guard above, which only lets a document
// through when there is a client to read every ERC-20 it carries.
if (!publicClient) throw new Error("No RPC client");
return Number(
await publicClient.readContract({
abi: erc20Abi,
address: tokenAddress as `0x${string}`,
functionName: "decimals",
}),
);
},
);
// Closed or superseded while the chain was answering: this result is for
// a paste the user already walked away from.
if (attemptRef.current !== attempt) return;

if (needsChain) setIsResolving(false);

if (!resolved.ok) {
setError(resolved.error);
return;
}
actions = resolved.actions;
}

onImport({ ...result.value, actions });
Comment thread
brunod-e marked this conversation as resolved.
Outdated
close();
Comment thread
brunod-e marked this conversation as resolved.
Outdated
};

return (
<Modal
open={open}
// Routed through close() so dismissing with Escape or the X voids an
// in-flight decimals lookup the same way Cancel does.
onOpenChange={(o) => {
if (!o) {
close();
return;
}
onOpenChange(o);
}}
title="Import JSON"
description="Paste a proposal document to fill the form fields."
cancelLabel="Cancel"
confirmLabel="Apply"
onCancel={close}
onConfirm={() => {
void handleConfirm();
}}
isConfirmDisabled={text.trim().length === 0 || isResolving}
isConfirmLoading={isResolving}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<FormLabel isRequired>Proposal JSON</FormLabel>
<Textarea
value={text}
onChange={(e) => {
setText(e.target.value);
setError(null);
}}
Comment thread
brunod-e marked this conversation as resolved.
Outdated
placeholder={PROPOSAL_JSON_PLACEHOLDER}
// Capped so a long paste (or a drag on the native resize grip)
// can't push the footer off screen.
className="max-h-64 min-h-44 resize-y overflow-y-auto font-mono text-xs"
error={Boolean(error)}
spellCheck={false}
aria-label="Proposal JSON"
/>
{error ? (
<span className="text-error text-xs">{error}</span>
) : (
<span className="text-secondary text-xs">
Every field is optional, and only the ones present are replaced.
Recipients take an address or ENS, and amounts are human-readable
rather than wei.
</span>
)}
</div>

<ul className="text-secondary flex flex-col gap-1 text-xs">
<Field name="title">the proposal title.</Field>
<Field name="discussionUrl">
an http(s) link to the forum thread.
</Field>
<Field name="body">the description, in markdown.</Field>
<Field name="actions">
one entry per action, keyed by <Code>type</Code>:
<ul className="mt-1 flex flex-col gap-1 pl-4">
<Field name="eth-transfer">
<Code>recipient</Code> and <Code>amount</Code> in ETH.
</Field>
<Field name="erc20-transfer">
also <Code>tokenAddress</Code>, whose <Code>decimals</Code> are
read from the token.
</Field>
<Field name="custom">
<Code>contractAddress</Code>, then either{" "}
<Code>functionName</Code> with <Code>abi</Code> and{" "}
<Code>args</Code>, or raw <Code>calldata</Code>.
</Field>
</ul>
</Field>
</ul>
</div>
</Modal>
);
};
11 changes: 11 additions & 0 deletions apps/dashboard/features/create-proposal/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ export const SUGGESTED_TRANSFER_TOKENS: Partial<
],
};

/** Format hint rendered as the Import JSON textarea placeholder. */
export const PROPOSAL_JSON_PLACEHOLDER = `{
"title": "Proposal title",
"discussionUrl": "https://discuss…",
"body": "## Synopsis\\n\\nMarkdown description.",
"actions": [
{ "type": "eth-transfer", "recipient": "0x…", "amount": "1.5" },
{ "type": "custom", "contractAddress": "0x…", "calldata": "0x…" }
]
}`;

export const BODY_PLACEHOLDER = `## Synopsis

State what the proposal does in one sentence.
Expand Down
10 changes: 7 additions & 3 deletions apps/dashboard/features/create-proposal/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ import { isAddress } from "viem";

import { isEnsAddress } from "@/shared/utils/ens";

const addressOrEnsSchema = z
// Exported so the JSON import path can hold a pasted action to exactly the
// rules the form enforces. They must not drift: an action the import accepts
// but the form rejects only shows up as a Publish button that never enables,
// since action rows render no field errors.
export const addressOrEnsSchema = z
.string()
.min(1, "Required")
.refine((v) => {
const trimmed = v.trim();
return isAddress(trimmed) || isEnsAddress(trimmed);
}, "Must be a valid address or ENS name");

const strictAddressSchema = z
export const strictAddressSchema = z
.string()
.min(1, "Required")
.refine((v) => isAddress(v.trim()), "Must be a valid Ethereum address");

const positiveDecimalAmountSchema = z
export const positiveDecimalAmountSchema = z
.string()
.min(1, "Required")
.refine((v) => /^\d+(\.\d+)?$/.test(v.trim()), "Must be a valid number")
Expand Down
Loading
Loading