Skip to content
Open
Show file tree
Hide file tree
Changes from 37 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
5 changes: 5 additions & 0 deletions .changeset/import-proposal-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/dashboard": minor
---

Import a proposal from JSON when creating one.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
MDXEditor,
Separator,
StrikeThroughSupSubToggles,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
headingsPlugin,
linkDialogPlugin,
Expand Down Expand Up @@ -122,6 +124,27 @@ export const BodyField = ({ version = 0 }: BodyFieldProps) => {
linkPlugin(),
linkDialogPlugin(),
tablePlugin(),
// Required, not optional. With no plugin claiming the `code`
// mdast node, a body holding a fenced block puts the editor in
// its failure state, "Parsing of the following markdown
// structure failed: {"type":"code","name":"N/A"}", and the
// author can only edit in source mode. Real proposals quote
// calldata and JSON constantly, and an imported body carries
// whatever fences the document had.
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
codeMirrorPlugin({
// The empty key is the fallback for a fence with no language,
// which is most of them. Anything else in the map is resolved
// through @codemirror/language-data on demand.
codeBlockLanguages: {
"": "Plain text",
json: "JSON",
js: "JavaScript",
ts: "TypeScript",
solidity: "Solidity",
bash: "Shell",
},
}),
markdownShortcutPlugin(),
diffSourcePlugin({
viewMode: mode === "markdown" ? "source" : "rich-text",
Expand Down
126 changes: 126 additions & 0 deletions apps/dashboard/features/create-proposal/components/JsonTextarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"use client";

import { useRef, type Ref } from "react";

import { cn } from "@/shared/utils/cn";

/**
* A textarea that can carry a line-number gutter, for callers whose messages cite
* a line.
*
* Two things keep the numbers honest, and both are load-bearing. The content must
* not soft-wrap, so that one line of text is one line on screen; it is rendered
* `whitespace-pre` and scrolls sideways instead, which the format hint needs
* anyway since wrapping breaks `"recipient":` away from its value. And the two
* columns must share an explicit `leading-5` and the same vertical padding, or
* they inherit different line heights and drift further apart with every line.
*/

type JsonTextareaProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** 1-based line to mark in the gutter, if any. */
errorLine?: number;
/**
* Opt in to the line-number gutter. Off by default: a numbered column earns
* its width only where something refers to a line, so a caller that never
* mentions one gets a plain textarea.
*/
showLineNumbers?: boolean;
hasError?: boolean;
ariaLabel?: string;
/** Height belongs to the caller; the columns stretch to fill it. */
className?: string;
ref?: Ref<HTMLTextAreaElement>;
};

export const JsonTextarea = ({
value,
onChange,
placeholder,
errorLine,
showLineNumbers = false,
hasError = false,
ariaLabel,
className,
ref,
}: JsonTextareaProps) => {
const gutterRef = useRef<HTMLDivElement>(null);

// No numbers at all while the box is empty. Numbering a document that isn't
// there would sit "1" beside a fourteen-line placeholder.
const lineCount = value === "" ? 0 : value.split("\n").length;

// Sized to the digits it actually has to hold. A fixed column is either too
// wide, which is dead space on every line, or too narrow the moment a document
// passes its width. `ch` is exact here because the font is monospaced. Two
// digits is the floor so the text doesn't shift sideways at line 10.
const gutterDigits = Math.max(2, String(lineCount).length);

return (
<div
className={cn(
// The wrapper owns the field's surface, so the two columns read as one
// control and the focus ring goes round both.
"border-border-contrast bg-surface-default rounded-base flex overflow-hidden border transition-all duration-200",
"focus-within:shadow-[var(--shadow-focus-ring)]",
// Draggable, but bounded. Browsers clamp a resize to max-height, and
// without one the field can be dragged taller than the screen, which
// pushes the dialog's own footer out of reach and leaves no way back. The
// cap is relative to the viewport so it holds on a laptop as well as a
// large monitor, and the floor stops it being dragged shut.
"max-h-[50vh] min-h-32 resize-y",
hasError && "border-error",
className,
)}
>
{showLineNumbers && (
<div
ref={gutterRef}
aria-hidden
// Border-box, so the width covers its own padding and leaves exactly
// the digits room to sit in.
style={{ width: `calc(${gutterDigits}ch + 1rem)` }}
className="text-dimmed pointer-events-none shrink-0 select-none overflow-hidden py-2 pl-2 pr-2 text-right font-mono text-xs leading-5"
>
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1;
return (
<div
key={line}
className={line === errorLine ? "text-error" : ""}
>
{line}
</div>
);
})}
</div>
)}

<textarea
ref={ref}
value={value}
onChange={(event) => onChange(event.target.value)}
// Programmatic scrolling fires this too, so jumping to the first error
// brings the gutter with it.
onScroll={(event) => {
const gutter = gutterRef.current;
if (gutter) gutter.scrollTop = event.currentTarget.scrollTop;
}}
placeholder={placeholder}
spellCheck={false}
aria-label={ariaLabel}
className={cn(
"text-primary placeholder:text-dimmed h-full w-full resize-none whitespace-pre bg-transparent py-2 pr-2.5 font-mono text-xs leading-5",
// The same scrollbar the tables use, from the shared utility rather
// than a second definition of it, so the two stay in step.
"scrollbar-thin overflow-auto border-0 outline-none",
// The gutter's own right padding is the gap when it is there; without
// it the field falls back to the design system's own inset.
showLineNumbers ? "pl-0" : "pl-2.5",
)}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Plus } from "lucide-react";
import { useState } from "react";

import { Button } from "@/shared/components/design-system/buttons/button/Button";
import { itemStatusStyles } from "@/shared/components/design-system/combobox/styles";
import { cn } from "@/shared/utils/cn";

/**
* The two ways to start a proposal, behind the New Proposal button.
*
* The import used to be a button sitting on the creation form, which is one step
* too late: by then the author has an empty form in front of them and importing
* replaces what they are looking at. Choosing it here makes it a way of starting,
* which is what it is.
*
* Built on the Popover primitive rather than the Combobox, which is a select: it
* shows its current value in the trigger, and these two options are actions with
* nothing selected between them. The item styling is borrowed from the combobox
* so the two menus look like one system.
*/

type NewProposalMenuProps = {
onCreateNew: () => void;
onImportJson: () => void;
/**
* Forwarded to the trigger, which is where the analytics attributes live.
*
* `data-*` keys are spelled out because JSX accepts them on an element without
* being declared, but an object literal assigned to a typed prop does not.
*/
triggerProps?: React.ComponentProps<typeof Button> &
Partial<Record<`data-${string}`, string | undefined>>;
};

/** Just the label. Two options this plain need nothing explaining them. */
const MenuItem = ({
label,
onSelect,
}: {
label: string;
onSelect: () => void;
}) => (
<button
type="button"
role="menuitem"
onClick={onSelect}
className={cn(
// The same padding and type as a combobox item, so the two menus in the
// app read as one system.
"flex w-full items-center px-3 py-2 text-left",
"text-primary text-sm font-normal leading-5",
itemStatusStyles.default,
"hover:bg-surface-hover cursor-pointer transition-colors duration-150",
)}
>
{label}
</button>
);

export const NewProposalMenu = ({
onCreateNew,
onImportJson,
triggerProps,
}: NewProposalMenuProps) => {
const [open, setOpen] = useState(false);

const choose = (action: () => void) => () => {
setOpen(false);
action();
};

return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<Button
variant="primary"
size="md"
aria-haspopup="menu"
aria-expanded={open}
{...triggerProps}
className={cn(
"flex-1 whitespace-nowrap lg:w-fit lg:flex-none",
triggerProps?.className,
)}
>
<Plus className="size-4" />
New Proposal
</Button>
</PopoverPrimitive.Trigger>

<PopoverPrimitive.Content
role="menu"
align="end"
sideOffset={4}
className={cn(
"flex flex-col",
// Sized to the labels now that nothing sits under them. Wide enough
// that the two rows don't look cramped against the trigger above.
"min-w-40 py-1",
"bg-surface-contrast",
"border-border-contrast rounded-base border",
"z-50",
"animate-[popover-slide-in_0.15s_ease-out]",
)}
>
<MenuItem label="Create new" onSelect={choose(onCreateNew)} />
<MenuItem label="Import JSON" onSelect={choose(onImportJson)} />
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
);
};
Loading