From dd6bec67f6b0342fbf392dd7103dc66f06554c30 Mon Sep 17 00:00:00 2001 From: Bohdan Khorolets Date: Sun, 28 Jun 2026 15:34:04 +0300 Subject: [PATCH 1/3] feat: custom proposal templates (authoring + fill + opt-in gating) Reusable templates for arbitrary FunctionCall proposals: a manifest DSL, Visual/Code authoring, a generated fill form, and per-treasury opt-in. ChangePolicy-gated writes; the produced proposal is an ordinary FunctionCall. Includes nt-be CRUD API + tests, nt-fe engine/UI + unit + e2e, docs and an authoring skill. Logging uses tracing to match the backend. --- .../author-custom-proposal-template/SKILL.md | 66 ++ CLAUDE.md | 1 + docs/CUSTOM_PROPOSAL_TEMPLATES.md | 218 ++++ ...7c005bf863105549661b4889b74dc6e60c181.json | 22 + ...d3c4a7fc68b261d3081a18728b80eb0142a9.json} | 6 +- ...269ec479c5a5b9dc94a1d1cf254236e6b7382.json | 23 + ...5fd23afb943625065102af61fd7432bbfb52.json} | 6 +- ...132571cb34e99964883c0bf99eae3fdbe06f6.json | 15 + ...6b14eb9cfee654db2842fe5ac72731d3b0df9.json | 19 - ...4a6ad21c0216728876d457954378fcaca1a3.json} | 6 +- ...55ac7c5d65094b52d2944d2551eb735d4861d.json | 76 ++ ...cc63a03f5d653bd018e8a2d90b2951c09081c.json | 28 + ...e2547de92116765a206e148e3a80968c975c2.json | 77 ++ ...1ed04133e0d66df5d949b10f5eb78b0676c37.json | 27 + ...260619000001_create_proposal_templates.sql | 44 + ...0627000001_add_custom_requests_enabled.sql | 6 + ...00002_add_pinned_to_proposal_templates.sql | 5 + nt-be/src/auth/error.rs | 22 +- nt-be/src/auth/middleware.rs | 103 ++ nt-be/src/handlers/address_book.rs | 46 +- nt-be/src/handlers/mod.rs | 1 + nt-be/src/handlers/proposal_templates.rs | 977 ++++++++++++++++++ .../src/handlers/treasury/custom_requests.rs | 278 +++++ nt-be/src/handlers/treasury/mod.rs | 1 + nt-be/src/handlers/treasury/policy.rs | 15 +- nt-be/src/routes/mod.rs | 19 +- nt-be/src/utils/test_utils.rs | 175 ++++ .../custom-templates/[slug]/edit/page.tsx | 165 +++ .../custom-templates/[slug]/page.tsx | 152 +++ .../custom-templates/create/page.tsx | 65 ++ .../[treasuryId]/custom-templates/layout.tsx | 13 + .../[treasuryId]/custom-templates/page.tsx | 298 ++++++ .../settings/components/developer-tab.tsx | 99 ++ .../(treasury)/[treasuryId]/settings/page.tsx | 39 +- .../components/animate-ui/icons/code-xml.tsx | 94 ++ nt-fe/components/sidebar.tsx | 124 ++- nt-fe/e2e/custom-templates.spec.ts | 397 +++++++ nt-fe/features/onboarding/steps/index.tsx | 8 +- .../features/onboarding/steps/page-tours.tsx | 34 +- nt-fe/features/proposal-templates/api.ts | 117 +++ .../proposal-templates/args-node.test.ts | 271 +++++ .../features/proposal-templates/args-node.ts | 178 ++++ .../proposal-templates/build-proposal.test.ts | 138 +++ .../proposal-templates/build-proposal.ts | 103 ++ .../components/args-tree-editor.tsx | 662 ++++++++++++ .../components/fields-builder.tsx | 476 +++++++++ .../components/manifest-form.tsx | 228 ++++ .../components/template-editor.tsx | 269 +++++ .../components/touched-gating.test.tsx | 50 + .../components/visual-builder.tsx | 216 ++++ .../features/proposal-templates/draft.test.ts | 231 +++++ nt-fe/features/proposal-templates/draft.ts | 363 +++++++ .../proposal-templates/error-map.test.ts | 79 ++ .../features/proposal-templates/error-map.ts | 46 + .../proposal-templates/form-schema.test.ts | 186 ++++ .../proposal-templates/form-schema.ts | 178 ++++ .../hooks/use-custom-requests-enabled.ts | 47 + .../hooks/use-proposal-template-mutations.ts | 66 ++ .../hooks/use-proposal-templates.ts | 18 + .../proposal-templates/manifest.test.ts | 408 ++++++++ nt-fe/features/proposal-templates/manifest.ts | 391 +++++++ nt-fe/features/proposal-templates/types.ts | 19 + .../trezu-custom-proposal-template/README.md | 41 + .../trezu-custom-proposal-template/SKILL.md | 141 +++ 64 files changed, 8631 insertions(+), 61 deletions(-) create mode 100644 .agents/skills/author-custom-proposal-template/SKILL.md create mode 100644 docs/CUSTOM_PROPOSAL_TEMPLATES.md create mode 100644 nt-be/.sqlx/query-0d0919b0bc0e734a5155a2766337c005bf863105549661b4889b74dc6e60c181.json rename nt-be/.sqlx/{query-39da8bb2bd0d75abfd751e39665edf7a8ed3acf2d2f5345c32e37d097d8cdc84.json => query-36567cc2245450fcf3089d57db17d3c4a7fc68b261d3081a18728b80eb0142a9.json} (77%) create mode 100644 nt-be/.sqlx/query-3abe05be828d94bd877028f9c5c269ec479c5a5b9dc94a1d1cf254236e6b7382.json rename nt-be/.sqlx/{query-836c5990a9758988bcb9fb506e57aa07995a3f19eee3d766f81d8ef8b1d4663e.json => query-3eb239014126a32c3fb4793277485fd23afb943625065102af61fd7432bbfb52.json} (78%) create mode 100644 nt-be/.sqlx/query-498093aafc01d2c8e39728c8edb132571cb34e99964883c0bf99eae3fdbe06f6.json delete mode 100644 nt-be/.sqlx/query-57e6d33d9168ec4314e8db17f256b14eb9cfee654db2842fe5ac72731d3b0df9.json rename nt-be/.sqlx/{query-da9a37eacd5055761692f11adaf62c4f76a01d92e455f81f694bd8db9f3b812f.json => query-5f205588489df021222df4247d494a6ad21c0216728876d457954378fcaca1a3.json} (76%) create mode 100644 nt-be/.sqlx/query-a999e96f58c9e0f5e0af26d859955ac7c5d65094b52d2944d2551eb735d4861d.json create mode 100644 nt-be/.sqlx/query-e648a256a6492374d9c39511c1ecc63a03f5d653bd018e8a2d90b2951c09081c.json create mode 100644 nt-be/.sqlx/query-f0909d35187099eca523e0e0a0be2547de92116765a206e148e3a80968c975c2.json create mode 100644 nt-be/.sqlx/query-fd881f5e05e9494b1ca87f66d411ed04133e0d66df5d949b10f5eb78b0676c37.json create mode 100644 nt-be/migrations/20260619000001_create_proposal_templates.sql create mode 100644 nt-be/migrations/20260627000001_add_custom_requests_enabled.sql create mode 100644 nt-be/migrations/20260627000002_add_pinned_to_proposal_templates.sql create mode 100644 nt-be/src/handlers/proposal_templates.rs create mode 100644 nt-be/src/handlers/treasury/custom_requests.rs create mode 100644 nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx create mode 100644 nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx create mode 100644 nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx create mode 100644 nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx create mode 100644 nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx create mode 100644 nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx create mode 100644 nt-fe/components/animate-ui/icons/code-xml.tsx create mode 100644 nt-fe/e2e/custom-templates.spec.ts create mode 100644 nt-fe/features/proposal-templates/api.ts create mode 100644 nt-fe/features/proposal-templates/args-node.test.ts create mode 100644 nt-fe/features/proposal-templates/args-node.ts create mode 100644 nt-fe/features/proposal-templates/build-proposal.test.ts create mode 100644 nt-fe/features/proposal-templates/build-proposal.ts create mode 100644 nt-fe/features/proposal-templates/components/args-tree-editor.tsx create mode 100644 nt-fe/features/proposal-templates/components/fields-builder.tsx create mode 100644 nt-fe/features/proposal-templates/components/manifest-form.tsx create mode 100644 nt-fe/features/proposal-templates/components/template-editor.tsx create mode 100644 nt-fe/features/proposal-templates/components/touched-gating.test.tsx create mode 100644 nt-fe/features/proposal-templates/components/visual-builder.tsx create mode 100644 nt-fe/features/proposal-templates/draft.test.ts create mode 100644 nt-fe/features/proposal-templates/draft.ts create mode 100644 nt-fe/features/proposal-templates/error-map.test.ts create mode 100644 nt-fe/features/proposal-templates/error-map.ts create mode 100644 nt-fe/features/proposal-templates/form-schema.test.ts create mode 100644 nt-fe/features/proposal-templates/form-schema.ts create mode 100644 nt-fe/features/proposal-templates/hooks/use-custom-requests-enabled.ts create mode 100644 nt-fe/features/proposal-templates/hooks/use-proposal-template-mutations.ts create mode 100644 nt-fe/features/proposal-templates/hooks/use-proposal-templates.ts create mode 100644 nt-fe/features/proposal-templates/manifest.test.ts create mode 100644 nt-fe/features/proposal-templates/manifest.ts create mode 100644 nt-fe/features/proposal-templates/types.ts create mode 100644 skills/trezu-custom-proposal-template/README.md create mode 100644 skills/trezu-custom-proposal-template/SKILL.md diff --git a/.agents/skills/author-custom-proposal-template/SKILL.md b/.agents/skills/author-custom-proposal-template/SKILL.md new file mode 100644 index 000000000..aa422ac17 --- /dev/null +++ b/.agents/skills/author-custom-proposal-template/SKILL.md @@ -0,0 +1,66 @@ +--- +name: author-custom-proposal-template +description: Author or edit a trezu custom-proposal template manifest — the JSON DSL that renders a form members fill to file a SputnikDAO FunctionCall proposal. Use when writing a manifest, wiring fields to args with {{placeholders}}, picking field types, or fixing "must be a tag-safe slug" / "references a {{placeholder}} that no field declares" errors. +--- + +# Authoring a custom-proposal template manifest + +A manifest is JSON that defines a form. Members fill it; trezu files one SputnikDAO +`FunctionCall` proposal from it. Full reference: `docs/CUSTOM_PROPOSAL_TEMPLATES.md`. +Validator: `nt-fe/features/proposal-templates/manifest.ts` (`parseManifest`) — produce +manifests that pass it. + +## Shape + +```json +{ + "version": 1, + "id": "guestbook-tip", // tag-safe slug [A-Za-z0-9_-], unique per DAO, + // NOT create/new/about + "title": "Guestbook Tip", + "summary": "Tip {{amount}}", // optional; may use {{fields}} + "binding": { // the fixed on-chain call + "receiver_id": "guestbook.near", + "method_name": "add_message", + "deposit": "1", // yoctoNEAR, INTEGER STRING + "gas": "30000000000000" // INTEGER STRING + }, + "fields": [ // the form inputs + { "name": "amount", "label": "Amount", "type": "uint", "required": true } + ], + "args": { "amount": "{{amount}}" } // method args; {{name}} -> field value +} +``` + +## Rules that trip authors up + +- **`id`**: `[A-Za-z0-9_-]` only, and not a reserved route slug (`create`/`new`/`about`). +- **field `name`**: `[A-Za-z0-9_]` only (placeholder-safe), unique within the manifest. +- **`deposit`/`gas`/`validation.min`/`max` and `uint`/`amount` values are integer + strings** in base units — no decimals, never JSON numbers (NEAR amounts exceed 2^53). + `1.1 NEAR` is `"1100000000000000000000000"`. +- **Every `{{placeholder}}` in `args` or `summary` must reference a declared field**, or + parse fails ("references a {{placeholder}} that no field declares"). +- `required` is invalid on a `bool` field. `options` is required on `select`, forbidden + elsewhere. A `default` must match the field `type`. `validation.pattern` only on + `text`/`number`; `min`/`max` only on `uint`/`amount`/`number`. + +## Field types + +`account` · `token` (omni id like `base-0x…`) · `uint` · `amount` (both u128 integer +strings) · `number` (f64, for counts/ratios, not amounts) · `text` · `select` (needs +`options`) · `bool` · `json`. + +## Wiring fields into args + +- **Direct value**: `"amount": "{{amount}}"`. +- **Composed / concatenated**: `"recipient": "{{first}}.{{last}}"` → `"alice.near"`. +- **Field inside a JSON-string arg**: `"msg": "{\"receiver_id\":\"{{receiver}}\"}"`. +- **Static value**: a literal with no placeholder (`"app": "trezu"`). +- An input you collect but don't send anywhere (no arg, no summary) is valid but rare — + use only for acknowledgment toggles or forward-compat. + +## Output + +Emit the manifest JSON, then sanity-check it against the rules above (or `parseManifest` +if you can run it). For a worked non-trivial example, see the doc. diff --git a/CLAUDE.md b/CLAUDE.md index e40a66eb2..e9912ac74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,3 +10,4 @@ All coding guidelines, testing practices, and project conventions are documented - **Subscription System (AI Guide):** [docs/AI_SUBSCRIPTION_GUIDE.md](docs/AI_SUBSCRIPTION_GUIDE.md) - Architecture, key files, payment flows, fee calculations, and implementation details - **Pricing Reference:** [docs/PRICING.md](docs/PRICING.md) - Plan tiers, features, and database schema +- **Custom Proposal Templates:** [docs/CUSTOM_PROPOSAL_TEMPLATES.md](docs/CUSTOM_PROPOSAL_TEMPLATES.md) - the manifest DSL, architecture, args-first authoring, and ChangePolicy gating. An authoring agent skill lives at [.claude/skills/author-custom-proposal-template](.claude/skills/author-custom-proposal-template/SKILL.md) diff --git a/docs/CUSTOM_PROPOSAL_TEMPLATES.md b/docs/CUSTOM_PROPOSAL_TEMPLATES.md new file mode 100644 index 000000000..85de58b0d --- /dev/null +++ b/docs/CUSTOM_PROPOSAL_TEMPLATES.md @@ -0,0 +1,218 @@ +# Custom proposal templates + +trezu creates the everyday treasury proposals — payments, transfers, staking — natively, +but a SputnikDAO can call **any method on any contract**. trezu could already *display* +those proposals; it just had no way to *create* them. **Custom proposal templates** close +that gap: a DAO authors a reusable form once, and members fill it to file an arbitrary +SputnikDAO **`FunctionCall`** proposal — no hand-written base64 args, no developer needed. + +The form definition is a JSON **manifest** (the DSL described below). The proposal a +template produces still passes the DAO's normal permissions and approvals, so a manifest +never grants authority by itself. + +This doc is the canonical reference for the manifest DSL, the architecture, and the +authoring UI. The in-app `//custom-templates/about` page and the +`author-custom-proposal-template` agent skill both derive from it. + +## What it produces + +Filling a template builds one SputnikDAO `FunctionCall` proposal: + +``` +receiver_id: +actions: [ FunctionCall { + method_name: , + args: , + deposit: , // yoctoNEAR + gas: , +} ] +description: "\n\n[trezu-tmpl:<id>]" // the tag is the provenance marker +``` + +Member-supplied values flow only into `args` (and the human-readable `summary`); the +contract, method, deposit, and gas are fixed by the template author. + +## The manifest DSL + +A manifest is a JSON object. `version` is `1` (the only shape this schema describes). + +| Key | Required | Notes | +|-----|----------|-------| +| `version` | yes | literal `1` | +| `id` | yes | tag-safe slug `[A-Za-z0-9_-]`, unique per DAO. The page URL (`/custom-templates/<id>`) **and** the `[trezu-tmpl:<id>]` description tag. Reserved: `create`, `new`, `about`. | +| `title` | yes | non-blank | +| `description` | no | non-blank if present | +| `icon` | no | non-blank if present | +| `summary` | no | human one-liner shown while filing; may use `{{placeholders}}` | +| `binding` | yes | the fixed on-chain call (below) | +| `fields` | yes | array of form inputs (below) | +| `args` | yes | the call arguments — a JSON object; string values may embed `{{placeholders}}` | + +### `binding` — the on-chain call + +```json +"binding": { + "receiver_id": "guestbook.near", // the contract + "method_name": "add_message", // the method + "deposit": "1", // yoctoNEAR, integer string (u128-safe) + "gas": "30000000000000" // gas, integer string +} +``` + +`deposit`/`gas` are **integer strings** in base units — there are no decimals at the +contract boundary (1.1 NEAR = `1100000000000000000000000` yocto). They exceed 2^53, so +they must be strings, never JSON numbers. + +### `fields` — the form inputs + +Each field is `{ name, label, type, ... }`: + +| Field key | Notes | +|-----------|-------| +| `name` | `[A-Za-z0-9_]` — must be `{{placeholder}}`-safe; unique within the manifest | +| `label` | non-blank; shown above the input | +| `type` | one of the types below | +| `required` | optional bool; **not allowed on `bool`** (a toggle always submits) | +| `default` | optional; must match `type` (string for text-ish, number for `number`, boolean for `bool`, an option for `select`, any JSON for `json`) | +| `help` | optional; shown under the input | +| `options` | required for `select`, forbidden otherwise; array of strings | +| `validation` | optional `{ min, max, pattern }` (below) | + +Types: + +| `type` | Renders / validates as | +|--------|------------------------| +| `account` | a NEAR account id (validated shape) | +| `token` | an omni token id, e.g. `base-0x…` (free text) | +| `uint` | a whole-number string, u128-safe | +| `amount` | a whole-number string, u128-safe (base units — no decimals) | +| `number` | a numeric value (JS number / f64 — for counts/ratios, **not** amounts) | +| `text` | free text | +| `select` | dropdown of `options` | +| `bool` | a toggle | +| `json` | JSON text | + +`validation`: +- `min` / `max` — integer strings; only on numeric types (`uint`/`amount`/`number`). +- `pattern` — a regular expression; only on `text` / `number`. Must compile. + +> u128 safety: `deposit`/`gas`/`validation.min`/`max` and `uint`/`amount` field values +> stay digit strings end-to-end, never JS numbers, so NEAR amounts (which exceed 2^53) +> survive untruncated. + +### `args` — the call arguments + +`args` is a JSON object — the literal method arguments. Each string value may contain +`{{name}}` placeholders that reference a declared field by `name`. At fill time the +engine substitutes each placeholder with the member's value: + +```json +"args": { + "app": "trezu", // static + "text": "{{message}}", // direct member value + "amount": "{{tip}}", // direct member value (u128) + "meta": "{\"by\":\"{{author}}\"}" // composed: a field inside a string +} +``` + +A value need not be a string. `args` may hold any JSON — numbers, booleans, `null`, +nested objects, arrays (the visual builder offers these as static value types). Non-string +values are sent to the contract **verbatim**; `{{placeholders}}` are only ever resolved +inside string values, so a number/bool/object passes through exactly as written. + +Rules: +- **Every `{{placeholder}}` must reference a declared field.** A dangling reference is + a validation error (attributed to `args` or `summary`). +- An escaped `{{{{literal}}}}` collapses to a literal `{{literal}}` and is never treated + as a placeholder. +- Concatenation / composition is just multiple placeholders in one string + (`"{{first}}.{{last}}"` → `"alice.near"`). +- Amounts stay digit strings, so u128 values never lose precision. + +### A minimal example + +```json +{ + "version": 1, + "id": "set-greeting", + "title": "Set Greeting", + "binding": { + "receiver_id": "guestbook.near", + "method_name": "set_greeting", + "deposit": "0", + "gas": "30000000000000" + }, + "fields": [ + { "name": "greeting", "label": "Greeting", "type": "text", "required": true } + ], + "args": { "greeting": "{{greeting}}" }, + "summary": "Set greeting to {{greeting}}" +} +``` + +## Authoring (the UI) + +`/<dao>/custom-templates/create` (and `…/<slug>/edit`) offer **Visual** and **Code** +modes over the same manifest; both validate through the one `parseManifest`. + +### Visual mode is args-first + +The call is the unit. Each **argument** is either: + +- **Static** — a fixed value (text/number/bool/null/object/array; text may embed + `{{field}}`), or +- **Member input (dynamic)** — its value becomes `{{key}}` and the row expands to that + input's config inline (label, type, required, help, validation). The input's **name + is the argument key**; renaming the key renames the input. + +Inputs are **derived from placeholders**: anything you reference (`{{x}}` in a static +value or in `summary`) auto-creates input `x`. Inputs referenced only inside a composed +value — or added manually — appear under **Other inputs**; an input no argument +references is flagged **Unused** (collected but not sent — used for acknowledgment +toggles or forward-compat). + +### Code mode + +A JSON textarea over the same manifest, with live validation. Useful for paste and +power edits. Switching Code → Visual needs only valid JSON (a partial manifest hydrates +with blanks); Visual → Code serializes the draft back. + +## Build one with an AI assistant + +Non-developers can generate a manifest by describing the proposal in plain English. The +self-contained, distributable skill at +[`skills/trezu-custom-proposal-template/`](../skills/trezu-custom-proposal-template/SKILL.md) +installs into Claude Code / Claude.ai / Codex (see its `README`) and produces a manifest +to paste into the **Code** tab — it needs no repo access. (The `.claude/skills/` skill, +by contrast, is for agents working *on* the trezu codebase.) + +## Permissions + +- **Authoring** (create / update / delete a template) is gated on the DAO's on-chain + **`ChangePolicy`** permission — the governance bar, not mere membership. +- **Listing / filling** a template is gated on **membership**. +- Filing the proposal still goes through the DAO's normal `add_proposal` → + approvals → execution. A template grants no authority. + +## Where the code lives + +Backend (`nt-be`): +- `src/handlers/proposal_templates.rs` — CRUD at `/api/treasury/{dao_id}/proposal-templates`, + `validate_manifest` (structural + slug/reserved checks), ChangePolicy-gated writes. +- Migration: `proposal_templates` table with a `manifest_id` generated column + (`manifest->>'id'`), unique per DAO. + +Frontend (`nt-fe/features/proposal-templates/`): +- `manifest.ts` — the zod schema + `parseManifest`, `manifestPlaceholders`, + `substitutePlaceholders`, `manifestIdOf`, reserved slugs. +- `build-proposal.ts` — the engine: manifest + values → `{ kind, description }`. +- `form-schema.ts` — manifest → react-hook-form zod schema (the fill form). +- `components/manifest-form.tsx` — the fill-form renderer. +- `draft.ts` — the visual builder's editable model (`ManifestDraft` ↔ manifest, + `normalizeFields`, the `ArgNode` args tree). +- `args-node.ts`, `error-map.ts` — args value-type helpers; per-input error routing. +- `components/{template-editor,visual-builder,fields-builder,args-tree-editor}.tsx` — + the Code/Visual authoring UI. + +Routes (`nt-fe/app/(treasury)/[treasuryId]/custom-templates/`): +`index` · `[slug]` (fill + file) · `create` · `[slug]/edit` · `about` (in-app DSL docs). diff --git a/nt-be/.sqlx/query-0d0919b0bc0e734a5155a2766337c005bf863105549661b4889b74dc6e60c181.json b/nt-be/.sqlx/query-0d0919b0bc0e734a5155a2766337c005bf863105549661b4889b74dc6e60c181.json new file mode 100644 index 000000000..ce72a8fdd --- /dev/null +++ b/nt-be/.sqlx/query-0d0919b0bc0e734a5155a2766337c005bf863105549661b4889b74dc6e60c181.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT custom_requests_enabled FROM monitored_accounts WHERE account_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "custom_requests_enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0d0919b0bc0e734a5155a2766337c005bf863105549661b4889b74dc6e60c181" +} diff --git a/nt-be/.sqlx/query-39da8bb2bd0d75abfd751e39665edf7a8ed3acf2d2f5345c32e37d097d8cdc84.json b/nt-be/.sqlx/query-36567cc2245450fcf3089d57db17d3c4a7fc68b261d3081a18728b80eb0142a9.json similarity index 77% rename from nt-be/.sqlx/query-39da8bb2bd0d75abfd751e39665edf7a8ed3acf2d2f5345c32e37d097d8cdc84.json rename to nt-be/.sqlx/query-36567cc2245450fcf3089d57db17d3c4a7fc68b261d3081a18728b80eb0142a9.json index 49f20c7a9..bab35073e 100644 --- a/nt-be/.sqlx/query-39da8bb2bd0d75abfd751e39665edf7a8ed3acf2d2f5345c32e37d097d8cdc84.json +++ b/nt-be/.sqlx/query-36567cc2245450fcf3089d57db17d3c4a7fc68b261d3081a18728b80eb0142a9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at,\n u.account_id AS created_by_wallet\n FROM address_book ab\n LEFT JOIN users u ON u.id = ab.created_by\n WHERE ab.dao_id = $1\n ORDER BY ab.created_at DESC\n ", + "query": "\n SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at,\n u.account_id AS \"created_by_wallet?\"\n FROM address_book ab\n LEFT JOIN users u ON u.id = ab.created_by\n WHERE ab.dao_id = $1\n ORDER BY ab.created_at DESC\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "created_by_wallet", + "name": "created_by_wallet?", "type_info": "Varchar" } ], @@ -60,5 +60,5 @@ false ] }, - "hash": "39da8bb2bd0d75abfd751e39665edf7a8ed3acf2d2f5345c32e37d097d8cdc84" + "hash": "36567cc2245450fcf3089d57db17d3c4a7fc68b261d3081a18728b80eb0142a9" } diff --git a/nt-be/.sqlx/query-3abe05be828d94bd877028f9c5c269ec479c5a5b9dc94a1d1cf254236e6b7382.json b/nt-be/.sqlx/query-3abe05be828d94bd877028f9c5c269ec479c5a5b9dc94a1d1cf254236e6b7382.json new file mode 100644 index 000000000..7bb0dad1d --- /dev/null +++ b/nt-be/.sqlx/query-3abe05be828d94bd877028f9c5c269ec479c5a5b9dc94a1d1cf254236e6b7382.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE monitored_accounts SET custom_requests_enabled = $2 WHERE account_id = $1 RETURNING custom_requests_enabled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "custom_requests_enabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3abe05be828d94bd877028f9c5c269ec479c5a5b9dc94a1d1cf254236e6b7382" +} diff --git a/nt-be/.sqlx/query-836c5990a9758988bcb9fb506e57aa07995a3f19eee3d766f81d8ef8b1d4663e.json b/nt-be/.sqlx/query-3eb239014126a32c3fb4793277485fd23afb943625065102af61fd7432bbfb52.json similarity index 78% rename from nt-be/.sqlx/query-836c5990a9758988bcb9fb506e57aa07995a3f19eee3d766f81d8ef8b1d4663e.json rename to nt-be/.sqlx/query-3eb239014126a32c3fb4793277485fd23afb943625065102af61fd7432bbfb52.json index 0c7a9bc12..1766d4cf0 100644 --- a/nt-be/.sqlx/query-836c5990a9758988bcb9fb506e57aa07995a3f19eee3d766f81d8ef8b1d4663e.json +++ b/nt-be/.sqlx/query-3eb239014126a32c3fb4793277485fd23afb943625065102af61fd7432bbfb52.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at,\n u.account_id AS created_by_wallet\n FROM address_book ab\n LEFT JOIN users u ON u.id = ab.created_by\n WHERE ab.dao_id = $1\n ORDER BY ab.created_at DESC\n ", + "query": "\n SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at,\n u.account_id AS \"created_by_wallet?\"\n FROM address_book ab\n LEFT JOIN users u ON u.id = ab.created_by\n WHERE ab.dao_id = $1\n ORDER BY ab.created_at DESC\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "created_by_wallet", + "name": "created_by_wallet?", "type_info": "Varchar" } ], @@ -60,5 +60,5 @@ false ] }, - "hash": "836c5990a9758988bcb9fb506e57aa07995a3f19eee3d766f81d8ef8b1d4663e" + "hash": "3eb239014126a32c3fb4793277485fd23afb943625065102af61fd7432bbfb52" } diff --git a/nt-be/.sqlx/query-498093aafc01d2c8e39728c8edb132571cb34e99964883c0bf99eae3fdbe06f6.json b/nt-be/.sqlx/query-498093aafc01d2c8e39728c8edb132571cb34e99964883c0bf99eae3fdbe06f6.json new file mode 100644 index 000000000..af7a16199 --- /dev/null +++ b/nt-be/.sqlx/query-498093aafc01d2c8e39728c8edb132571cb34e99964883c0bf99eae3fdbe06f6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM proposal_templates WHERE dao_id = $1 AND id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "498093aafc01d2c8e39728c8edb132571cb34e99964883c0bf99eae3fdbe06f6" +} diff --git a/nt-be/.sqlx/query-57e6d33d9168ec4314e8db17f256b14eb9cfee654db2842fe5ac72731d3b0df9.json b/nt-be/.sqlx/query-57e6d33d9168ec4314e8db17f256b14eb9cfee654db2842fe5ac72731d3b0df9.json deleted file mode 100644 index e766b70bc..000000000 --- a/nt-be/.sqlx/query-57e6d33d9168ec4314e8db17f256b14eb9cfee654db2842fe5ac72731d3b0df9.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO confidential_intents (dao_id, payload_hash, intent_payload, correlation_id, quote_metadata, notes)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (dao_id, payload_hash) DO UPDATE SET\n intent_payload = EXCLUDED.intent_payload,\n correlation_id = EXCLUDED.correlation_id,\n quote_metadata = EXCLUDED.quote_metadata,\n notes = EXCLUDED.notes,\n intent_type = 'shield',\n status = 'pending',\n submit_result = NULL,\n updated_at = NOW()\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Jsonb", - "Text", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "57e6d33d9168ec4314e8db17f256b14eb9cfee654db2842fe5ac72731d3b0df9" -} diff --git a/nt-be/.sqlx/query-da9a37eacd5055761692f11adaf62c4f76a01d92e455f81f694bd8db9f3b812f.json b/nt-be/.sqlx/query-5f205588489df021222df4247d494a6ad21c0216728876d457954378fcaca1a3.json similarity index 76% rename from nt-be/.sqlx/query-da9a37eacd5055761692f11adaf62c4f76a01d92e455f81f694bd8db9f3b812f.json rename to nt-be/.sqlx/query-5f205588489df021222df4247d494a6ad21c0216728876d457954378fcaca1a3.json index fb536fdfa..1b0921d88 100644 --- a/nt-be/.sqlx/query-da9a37eacd5055761692f11adaf62c4f76a01d92e455f81f694bd8db9f3b812f.json +++ b/nt-be/.sqlx/query-5f205588489df021222df4247d494a6ad21c0216728876d457954378fcaca1a3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at,\n u.account_id AS created_by_wallet\n FROM address_book ab\n LEFT JOIN users u ON u.id = ab.created_by\n WHERE ab.dao_id = $1 AND ab.id = ANY($2)\n ORDER BY ab.created_at DESC\n ", + "query": "\n SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at,\n u.account_id AS \"created_by_wallet?\"\n FROM address_book ab\n LEFT JOIN users u ON u.id = ab.created_by\n WHERE ab.dao_id = $1 AND ab.id = ANY($2)\n ORDER BY ab.created_at DESC\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "created_by_wallet", + "name": "created_by_wallet?", "type_info": "Varchar" } ], @@ -61,5 +61,5 @@ false ] }, - "hash": "da9a37eacd5055761692f11adaf62c4f76a01d92e455f81f694bd8db9f3b812f" + "hash": "5f205588489df021222df4247d494a6ad21c0216728876d457954378fcaca1a3" } diff --git a/nt-be/.sqlx/query-a999e96f58c9e0f5e0af26d859955ac7c5d65094b52d2944d2551eb735d4861d.json b/nt-be/.sqlx/query-a999e96f58c9e0f5e0af26d859955ac7c5d65094b52d2944d2551eb735d4861d.json new file mode 100644 index 000000000..ac4df0158 --- /dev/null +++ b/nt-be/.sqlx/query-a999e96f58c9e0f5e0af26d859955ac7c5d65094b52d2944d2551eb735d4861d.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT pt.id, pt.dao_id, pt.name, pt.description,\n pt.manifest AS \"manifest: serde_json::Value\",\n pt.enabled, pt.pinned, pt.created_at, pt.updated_at,\n u.account_id AS \"created_by_wallet?\"\n FROM proposal_templates pt\n LEFT JOIN users u ON u.id = pt.created_by\n WHERE pt.dao_id = $1\n ORDER BY pt.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "dao_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "manifest: serde_json::Value", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "pinned", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_by_wallet?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a999e96f58c9e0f5e0af26d859955ac7c5d65094b52d2944d2551eb735d4861d" +} diff --git a/nt-be/.sqlx/query-e648a256a6492374d9c39511c1ecc63a03f5d653bd018e8a2d90b2951c09081c.json b/nt-be/.sqlx/query-e648a256a6492374d9c39511c1ecc63a03f5d653bd018e8a2d90b2951c09081c.json new file mode 100644 index 000000000..2378b72b9 --- /dev/null +++ b/nt-be/.sqlx/query-e648a256a6492374d9c39511c1ecc63a03f5d653bd018e8a2d90b2951c09081c.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE proposal_templates\n SET name = COALESCE($3, name),\n description = COALESCE($4, description),\n manifest = COALESCE($5, manifest),\n enabled = COALESCE($6, enabled),\n pinned = COALESCE($7, pinned)\n WHERE dao_id = $1 AND id = $2\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Text", + "Text", + "Jsonb", + "Bool", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e648a256a6492374d9c39511c1ecc63a03f5d653bd018e8a2d90b2951c09081c" +} diff --git a/nt-be/.sqlx/query-f0909d35187099eca523e0e0a0be2547de92116765a206e148e3a80968c975c2.json b/nt-be/.sqlx/query-f0909d35187099eca523e0e0a0be2547de92116765a206e148e3a80968c975c2.json new file mode 100644 index 000000000..5b0db1258 --- /dev/null +++ b/nt-be/.sqlx/query-f0909d35187099eca523e0e0a0be2547de92116765a206e148e3a80968c975c2.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT pt.id, pt.dao_id, pt.name, pt.description,\n pt.manifest AS \"manifest: serde_json::Value\",\n pt.enabled, pt.pinned, pt.created_at, pt.updated_at,\n u.account_id AS \"created_by_wallet?\"\n FROM proposal_templates pt\n LEFT JOIN users u ON u.id = pt.created_by\n WHERE pt.dao_id = $1 AND pt.id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "dao_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "manifest: serde_json::Value", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "pinned", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_by_wallet?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "f0909d35187099eca523e0e0a0be2547de92116765a206e148e3a80968c975c2" +} diff --git a/nt-be/.sqlx/query-fd881f5e05e9494b1ca87f66d411ed04133e0d66df5d949b10f5eb78b0676c37.json b/nt-be/.sqlx/query-fd881f5e05e9494b1ca87f66d411ed04133e0d66df5d949b10f5eb78b0676c37.json new file mode 100644 index 000000000..102d0ddd8 --- /dev/null +++ b/nt-be/.sqlx/query-fd881f5e05e9494b1ca87f66d411ed04133e0d66df5d949b10f5eb78b0676c37.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO proposal_templates (dao_id, name, description, manifest, enabled, created_by)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Jsonb", + "Bool", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd881f5e05e9494b1ca87f66d411ed04133e0d66df5d949b10f5eb78b0676c37" +} diff --git a/nt-be/migrations/20260619000001_create_proposal_templates.sql b/nt-be/migrations/20260619000001_create_proposal_templates.sql new file mode 100644 index 000000000..ea02213aa --- /dev/null +++ b/nt-be/migrations/20260619000001_create_proposal_templates.sql @@ -0,0 +1,44 @@ +-- Per-DAO custom proposal templates for the manifest-driven custom-proposal framework. +-- A `manifest` is a JSON form definition: a techy member authors it, regular members fill +-- the resulting form to file a generic SputnikDAO FunctionCall proposal. Storing manifests +-- off-chain (mirroring `address_book`) is safe: a manifest only defines a *form*; the proposal +-- it produces still passes the DAO's on-chain permissions and approvals. +CREATE TABLE proposal_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dao_id VARCHAR(128) NOT NULL REFERENCES monitored_accounts(account_id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NULL, + manifest JSONB NOT NULL, + -- Slug derived from the manifest's own id, so the URL key (/custom-templates/<manifest_id>) and + -- the [trezu-tmpl:<id>] provenance tag are the same identifier. GENERATED + STORED so it can + -- never drift from the manifest; NOT NULL because every stored manifest has a validated id. + manifest_id TEXT GENERATED ALWAYS AS (manifest->>'id') STORED NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + created_by UUID NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One template name per DAO (human-facing identifier in the template list). Its leading +-- column also serves equality lookups on dao_id, so no separate dao_id index is needed. +CREATE UNIQUE INDEX uq_proposal_templates_dao_name ON proposal_templates(dao_id, name); +-- One manifest id per DAO: the slug must resolve to exactly one template, so +-- /custom-templates/<manifest_id> is unambiguous. Its leading dao_id column also serves +-- dao-scoped slug lookups, so no separate index is needed for the route. +CREATE UNIQUE INDEX uq_proposal_templates_dao_manifest_id ON proposal_templates(dao_id, manifest_id); +-- Indexed for FK-delete performance (ON DELETE SET NULL when a user is removed). +CREATE INDEX idx_proposal_templates_created_by ON proposal_templates(created_by); + +-- Auto-update updated_at (same pattern as the `daos` table). +CREATE OR REPLACE FUNCTION update_proposal_templates_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER proposal_templates_updated_at + BEFORE UPDATE ON proposal_templates + FOR EACH ROW + EXECUTE FUNCTION update_proposal_templates_updated_at(); diff --git a/nt-be/migrations/20260627000001_add_custom_requests_enabled.sql b/nt-be/migrations/20260627000001_add_custom_requests_enabled.sql new file mode 100644 index 000000000..9ede0cd71 --- /dev/null +++ b/nt-be/migrations/20260627000001_add_custom_requests_enabled.sql @@ -0,0 +1,6 @@ +-- Opt-in flag for the custom-proposal-templates feature ("Custom Requests"). +-- The feature ships disabled for every treasury; a DAO turns it on from Settings → Developer +-- (gated on the same on-chain ChangePolicy permission as authoring a template). Stored on +-- monitored_accounts because that is the per-treasury record proposal_templates already FKs to. +ALTER TABLE monitored_accounts + ADD COLUMN custom_requests_enabled BOOLEAN NOT NULL DEFAULT false; diff --git a/nt-be/migrations/20260627000002_add_pinned_to_proposal_templates.sql b/nt-be/migrations/20260627000002_add_pinned_to_proposal_templates.sql new file mode 100644 index 000000000..7ca3cdc9d --- /dev/null +++ b/nt-be/migrations/20260627000002_add_pinned_to_proposal_templates.sql @@ -0,0 +1,5 @@ +-- Whether a template is pinned to the sidebar. Pinning is a per-template, DAO-level flag (toggled +-- through the same ChangePolicy-gated update endpoint as the rest of a template's fields). The +-- sidebar shows only pinned templates under a collapsible chevron; unpinned ones live on the index. +ALTER TABLE proposal_templates + ADD COLUMN pinned BOOLEAN NOT NULL DEFAULT false; diff --git a/nt-be/src/auth/error.rs b/nt-be/src/auth/error.rs index e1014d513..b029fc213 100644 --- a/nt-be/src/auth/error.rs +++ b/nt-be/src/auth/error.rs @@ -48,9 +48,13 @@ impl From<near_account_id::ParseAccountError> for AuthError { } } -impl IntoResponse for AuthError { - fn into_response(self) -> Response { - let (status, message) = match &self { +impl AuthError { + /// HTTP status + client-safe message for this error — the single source of truth shared by + /// `IntoResponse` and handlers that propagate it as `(StatusCode, String)`. DB/internal + /// failures map to 500 (without leaking detail); auth/permission errors keep their 4xx code. + /// Prefer this over collapsing every variant into a fixed status with `.map_err(|_| ...)`. + pub fn status_and_message(&self) -> (StatusCode, String) { + match self { AuthError::InvalidSignature(_) => (StatusCode::UNAUTHORIZED, self.to_string()), AuthError::InvalidPublicKey(_) => (StatusCode::BAD_REQUEST, self.to_string()), AuthError::InvalidNonce(_) => (StatusCode::BAD_REQUEST, self.to_string()), @@ -69,12 +73,14 @@ impl IntoResponse for AuthError { "Internal error".to_string(), ), AuthError::NotDaoMember => (StatusCode::FORBIDDEN, self.to_string()), - }; - - let body = Json(json!({ - "error": message - })); + } + } +} +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, message) = self.status_and_message(); + let body = Json(json!({ "error": message })); (status, body).into_response() } } diff --git a/nt-be/src/auth/middleware.rs b/nt-be/src/auth/middleware.rs index 6ffe4fe04..a2ea91627 100644 --- a/nt-be/src/auth/middleware.rs +++ b/nt-be/src/auth/middleware.rs @@ -43,6 +43,22 @@ impl AuthUser { member.map(|_| ()).ok_or(AuthError::NotDaoMember) } + /// `verify_dao_member` mapped for HTTP handlers: a genuine non-member is `403`, while a + /// DB/internal failure is logged and surfaced as `500` instead of being silently collapsed + /// into a misleading `403`. Handlers should prefer this over `.map_err(|_| forbidden())`. + pub async fn verify_dao_member_for_http( + &self, + db: &sqlx::PgPool, + dao_id: &AccountIdRef, + ) -> Result<(), (StatusCode, String)> { + self.verify_dao_member(db, dao_id).await.map_err(|e| { + if !matches!(e, AuthError::NotDaoMember) { + tracing::error!("verify_dao_member failed for {}: {e}", self.account_id); + } + e.status_and_message() + }) + } + fn role_has_action_permission(role: &Value, action_name: &str) -> bool { role.get("permissions") .and_then(Value::as_array) @@ -279,3 +295,90 @@ impl FromRequestParts<Arc<AppState>> for OptionalAuthUser { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::test_utils::{build_test_state, policy_granting, seed_treasury_policy}; + use serde_json::json; + + /// The role/permission rules themselves — pure, via the synchronous + /// `verify_can_perform_action_with_policy`, so no DB / cache / RPC is involved. Covers all + /// three advertised branches: a Group role gates by member + action, an `Everyone` role lets + /// any account through, and a `*:*` wildcard permits any action. + #[test] + fn policy_permission_rules() { + let dao: AccountId = "test-dao.sputnik-dao.near".parse().unwrap(); + let alice = AuthUser { + account_id: "alice.near".parse().unwrap(), + }; + let bob = AuthUser { + account_id: "bob.near".parse().unwrap(), + }; + + // Group role granting one action: only the member, only that action. + let group = policy_granting("alice.near", &["*:ChangePolicy"]); + assert!( + alice + .verify_can_perform_action_with_policy(&group, &dao, "ChangePolicy") + .is_ok() + ); + assert!( + alice + .verify_can_perform_action_with_policy(&group, &dao, "AddProposal") + .is_err(), + "an action the role does not grant must be rejected" + ); + assert!( + bob.verify_can_perform_action_with_policy(&group, &dao, "ChangePolicy") + .is_err(), + "an account outside the role's group must be rejected" + ); + + // `Everyone` role: any account may perform the granted action. + let everyone = json!({ + "roles": [{ "name": "all", "kind": "Everyone", "permissions": ["*:ChangePolicy"] }], + }); + assert!( + bob.verify_can_perform_action_with_policy(&everyone, &dao, "ChangePolicy") + .is_ok(), + "an Everyone role must let any account through" + ); + + // `*:*` wildcard permission: the member may perform any action. + let admin = policy_granting("alice.near", &["*:*"]); + assert!( + alice + .verify_can_perform_action_with_policy(&admin, &dao, "AddProposal") + .is_ok(), + "a *:* permission must grant any action" + ); + } + + /// The cache seam this PR exists for: `seed_treasury_policy` must make + /// `verify_can_perform_action` (which fetches the policy through the cache) resolve without + /// touching the network. If the seeded key didn't match `fetch_treasury_policy_cached`'s key, + /// this would miss the cache and RPC a non-existent test DAO and error — so a passing assertion + /// also proves the seed and fetch keys agree. + #[sqlx::test] + async fn seeded_policy_is_served_from_cache(pool: sqlx::PgPool) { + let state = Arc::new(build_test_state(pool)); + let dao: AccountId = "test-dao.sputnik-dao.near".parse().unwrap(); + seed_treasury_policy( + &state, + &dao, + policy_granting("alice.near", &["*:ChangePolicy"]), + ) + .await; + + let alice = AuthUser { + account_id: "alice.near".parse().unwrap(), + }; + assert!( + alice + .verify_can_perform_action(&state, &dao, "ChangePolicy") + .await + .is_ok() + ); + } +} diff --git a/nt-be/src/handlers/address_book.rs b/nt-be/src/handlers/address_book.rs index 1cce369cc..d4aa67691 100644 --- a/nt-be/src/handlers/address_book.rs +++ b/nt-be/src/handlers/address_book.rs @@ -102,7 +102,7 @@ pub async fn list_address_book( AddressBookRow, r#" SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at, - u.account_id AS created_by_wallet + u.account_id AS "created_by_wallet?" FROM address_book ab LEFT JOIN users u ON u.id = ab.created_by WHERE ab.dao_id = $1 @@ -266,7 +266,7 @@ pub async fn export_address_book( AddressBookRow, r#" SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at, - u.account_id AS created_by_wallet + u.account_id AS "created_by_wallet?" FROM address_book ab LEFT JOIN users u ON u.id = ab.created_by WHERE ab.dao_id = $1 AND ab.id = ANY($2) @@ -288,7 +288,7 @@ pub async fn export_address_book( AddressBookRow, r#" SELECT ab.id, ab.dao_id, ab.name, ab.networks, ab.address, ab.note, ab.created_at, - u.account_id AS created_by_wallet + u.account_id AS "created_by_wallet?" FROM address_book ab LEFT JOIN users u ON u.id = ab.created_by WHERE ab.dao_id = $1 @@ -548,6 +548,46 @@ mod tests { .expect("Response body should be valid UTF-8") } + /// An entry whose creator was removed (`created_by` set NULL by the FK's `ON DELETE SET NULL`) + /// must list without crashing: the `LEFT JOIN users` yields a NULL `created_by_wallet`, which + /// the query has to decode as `None`. Regression for the missing `?` nullability override (the + /// same fix this PR applies to proposal_templates; the export queries get the override too). + #[sqlx::test] + async fn test_list_tolerates_null_creator(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + + seed_policy_member(&pool, DAO_ID, USER_ACCOUNT_ID).await; + let auth_cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + // insert_address_book_entry leaves created_by NULL — the post-ON-DELETE-SET-NULL state. + insert_address_book_entry(&pool, DAO_ID, "Orphan", "orphan.near").await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/address-book?daoId={DAO_ID}")) + .header("cookie", &auth_cookie) + .body(Body::empty()) + .expect("Should build list request"), + ) + .await + .expect("List request should complete"); + + let status = response.status(); + let body = response_text(response).await; + assert_eq!(status, StatusCode::OK, "List should succeed. Body: {body}"); + + let entries: serde_json::Value = + serde_json::from_str(&body).expect("List response should be valid JSON"); + let entries = entries + .as_array() + .expect("List response should be an array"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], "Orphan"); + assert_eq!(entries[0]["createdBy"], serde_json::Value::Null); + } + #[sqlx::test] async fn test_address_book_routes_support_crud_and_export(pool: PgPool) { let state = test_state(pool.clone()); diff --git a/nt-be/src/handlers/mod.rs b/nt-be/src/handlers/mod.rs index ba7e60fb7..12f775179 100644 --- a/nt-be/src/handlers/mod.rs +++ b/nt-be/src/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod dao; pub mod intents; pub mod lookup; pub mod notifications; +pub mod proposal_templates; pub mod proposals; pub mod proxy; pub mod public_dashboard; diff --git a/nt-be/src/handlers/proposal_templates.rs b/nt-be/src/handlers/proposal_templates.rs new file mode 100644 index 000000000..65b42da91 --- /dev/null +++ b/nt-be/src/handlers/proposal_templates.rs @@ -0,0 +1,977 @@ +//! Per-DAO custom proposal templates (the manifest-driven custom-proposal framework). +//! +//! A `manifest` is a JSON form definition. A techy DAO member authors a template; regular +//! members later fill the rendered form to file a generic SputnikDAO `FunctionCall` proposal. +//! This module is the storage + CRUD layer (mirrors `address_book`). The form engine and +//! renderer live in the frontend; the on-chain proposal a template produces still passes the +//! DAO's normal permissions and approvals, so a manifest never grants authority by itself. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use chrono::{DateTime, Utc}; +use near_api::AccountId; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +use crate::{AppState, auth::AuthUser}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProposalTemplate { + pub id: Uuid, + pub dao_id: AccountId, + pub name: String, + pub description: Option<String>, + pub manifest: Value, + pub enabled: bool, + pub pinned: bool, + pub created_by: Option<AccountId>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Row shape for the join queries (list / fetch-by-id), with the creator's wallet resolved. +struct ProposalTemplateRow { + id: Uuid, + dao_id: String, + name: String, + description: Option<String>, + manifest: Value, + enabled: bool, + pinned: bool, + created_by_wallet: Option<String>, + created_at: DateTime<Utc>, + updated_at: DateTime<Utc>, +} + +impl TryFrom<ProposalTemplateRow> for ProposalTemplate { + type Error = near_account_id::ParseAccountError; + + fn try_from(r: ProposalTemplateRow) -> Result<Self, Self::Error> { + Ok(ProposalTemplate { + id: r.id, + dao_id: r.dao_id.parse()?, + name: r.name, + description: r.description, + manifest: r.manifest, + enabled: r.enabled, + pinned: r.pinned, + created_by: r.created_by_wallet.map(|s| s.parse()).transpose()?, + created_at: r.created_at, + updated_at: r.updated_at, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProposalTemplateRequest { + pub name: String, + pub description: Option<String>, + pub manifest: Value, + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProposalTemplateRequest { + pub name: Option<String>, + /// Omitted or `null` leaves the existing description unchanged (COALESCE semantics). A + /// provided value must be non-blank (trimmed, like `name`): a whitespace-only string is + /// rejected rather than silently stored as `""`. There is no clear-to-empty here. + pub description: Option<String>, + /// Omitted or `null` leaves the existing manifest unchanged (COALESCE semantics). The + /// manifest cannot be cleared through this endpoint — send the replacement shape instead + /// (the column is NOT NULL, so an absent manifest is never a valid stored state). + pub manifest: Option<Value>, + pub enabled: Option<bool>, + /// Omitted or `null` leaves the pin state unchanged (COALESCE). Toggling this is how a template + /// is pinned to / unpinned from the sidebar. + pub pinned: Option<bool>, +} + +fn default_enabled() -> bool { + true +} + +/// Validate a manifest's structural shape and return a **normalized** copy with its known +/// string fields (`id`, `title`, `binding.receiver_id`, `binding.method_name`) trimmed — the +/// same trim-then-store treatment `name`/`description` get, so a padded `id` can't drift the +/// `[trezu-tmpl:<id>]` tag or break downstream comparisons. +/// +/// Only structural validation here; strict field-type / u128 / args-mapping checks are a +/// deliberate follow-up that must mirror the frontend validator and lands with the form engine. +fn validate_manifest(manifest: &Value) -> Result<Value, String> { + let obj = manifest + .as_object() + .ok_or("manifest must be a JSON object")?; + + // Shared by the validity check and the normalize-on-store step below. + fn trimmed( + container: &serde_json::Map<String, Value>, + key: &str, + path: &str, + ) -> Result<String, String> { + container + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("manifest.{path} must be a non-empty string")) + } + + let id = trimmed(obj, "id", "id")?; + // `id` becomes the `manifest_id` generated column and the `/custom-templates/<id>` route key, so + // enforce its slug shape here (the rest of the strict field validation stays a follow-up): a + // tag-safe charset, and not a reserved static-route slug that would shadow the template's page. + // Keep RESERVED_SLUGS in sync with the frontend `RESERVED_TEMPLATE_SLUGS` in manifest.ts. + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err("manifest.id must be a tag-safe slug ([A-Za-z0-9_-])".to_string()); + } + const RESERVED_SLUGS: [&str; 3] = ["create", "new", "about"]; + if RESERVED_SLUGS.contains(&id.to_lowercase().as_str()) { + return Err(format!( + "manifest.id must not be a reserved route slug ({})", + RESERVED_SLUGS.join(", ") + )); + } + let title = trimmed(obj, "title", "title")?; + + let binding = obj + .get("binding") + .and_then(Value::as_object) + .ok_or("manifest.binding must be an object")?; + let receiver_id = trimmed(binding, "receiver_id", "binding.receiver_id")?; + let method_name = trimmed(binding, "method_name", "binding.method_name")?; + + if !obj.get("fields").map(Value::is_array).unwrap_or(false) { + return Err("manifest.fields must be an array".to_string()); + } + + let mut normalized = manifest.clone(); + let obj = normalized + .as_object_mut() + .expect("manifest was validated as an object above"); + obj.insert("id".to_string(), Value::String(id)); + obj.insert("title".to_string(), Value::String(title)); + let binding = obj + .get_mut("binding") + .and_then(Value::as_object_mut) + .expect("binding was validated as an object above"); + binding.insert("receiver_id".to_string(), Value::String(receiver_id)); + binding.insert("method_name".to_string(), Value::String(method_name)); + Ok(normalized) +} + +fn internal_error(context: &str, e: impl std::fmt::Display) -> (StatusCode, String) { + tracing::error!("{context}: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, context.to_string()) +} + +/// Trim an optional request string and reject a blank value (`""` or whitespace-only) so a +/// blank can never be silently stored as `""`. `None` (omitted / null) passes through unchanged. +fn trim_optional( + value: Option<String>, + field: &str, +) -> Result<Option<String>, (StatusCode, String)> { + match value.as_deref().map(str::trim) { + Some("") => Err(( + StatusCode::BAD_REQUEST, + format!("{field} must not be blank"), + )), + Some(trimmed) => Ok(Some(trimmed.to_string())), + None => Ok(None), + } +} + +/// Re-fetch a full template (with creator wallet) after a mutation, scoped to the DAO. +async fn fetch_template_by_id( + pool: &PgPool, + dao_id: &str, + id: Uuid, +) -> Result<Option<ProposalTemplateRow>, sqlx::Error> { + sqlx::query_as!( + ProposalTemplateRow, + r#" + SELECT pt.id, pt.dao_id, pt.name, pt.description, + pt.manifest AS "manifest: serde_json::Value", + pt.enabled, pt.pinned, pt.created_at, pt.updated_at, + u.account_id AS "created_by_wallet?" + FROM proposal_templates pt + LEFT JOIN users u ON u.id = pt.created_by + WHERE pt.dao_id = $1 AND pt.id = $2 + "#, + dao_id, + id + ) + .fetch_optional(pool) + .await +} + +pub async fn list_proposal_templates( + State(state): State<Arc<AppState>>, + auth_user: AuthUser, + Path(dao_id): Path<AccountId>, +) -> Result<Json<Vec<ProposalTemplate>>, (StatusCode, String)> { + auth_user + .verify_dao_member_for_http(&state.db_pool, &dao_id) + .await?; + + let templates: Vec<ProposalTemplate> = sqlx::query_as!( + ProposalTemplateRow, + r#" + SELECT pt.id, pt.dao_id, pt.name, pt.description, + pt.manifest AS "manifest: serde_json::Value", + pt.enabled, pt.pinned, pt.created_at, pt.updated_at, + u.account_id AS "created_by_wallet?" + FROM proposal_templates pt + LEFT JOIN users u ON u.id = pt.created_by + WHERE pt.dao_id = $1 + ORDER BY pt.created_at DESC + "#, + dao_id.as_str() + ) + .fetch_all(&state.db_pool) + .await + .map_err(|e| internal_error("Failed to list proposal templates", e))? + .into_iter() + .map(ProposalTemplate::try_from) + .collect::<Result<_, _>>() + .map_err(|e| internal_error("Invalid account in proposal template", e))?; + + Ok(Json(templates)) +} + +pub async fn create_proposal_template( + State(state): State<Arc<AppState>>, + auth_user: AuthUser, + Path(dao_id): Path<AccountId>, + Json(req): Json<CreateProposalTemplateRequest>, +) -> Result<(StatusCode, Json<ProposalTemplate>), (StatusCode, String)> { + // Authoring a template defines a reusable on-chain action shape that members fill and execute, + // so gate writes on the DAO's policy-management permission (ChangePolicy) — reads/fills stay at + // membership. Unit-testable via `seed_treasury_policy`. + auth_user + .verify_can_perform_action(&state, &dao_id, "ChangePolicy") + .await?; + + let manifest = validate_manifest(&req.manifest).map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + // Normalize name/description like the manifest's own strings, so whitespace variants + // ("Guestbook Tip" vs " Guestbook Tip ") can't slip past the unique (dao_id, name) index. + let name = req.name.trim().to_string(); + if name.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "name must not be blank".to_string(), + )); + } + let description = trim_optional(req.description, "description")?; + + let user_id = sqlx::query_scalar!( + "SELECT id FROM users WHERE account_id = $1", + auth_user.account_id.as_str() + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| internal_error("Failed to look up user", e))?; + if user_id.is_none() { + // A valid session implies a users row (FK); a missing one is server corruption, not a + // reason to silently drop the created_by attribution. + tracing::error!( + "Authenticated user {} has no profile row; refusing to store a NULL created_by", + auth_user.account_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Authenticated user has no profile".to_string(), + )); + } + + let id: Uuid = sqlx::query_scalar!( + r#" + INSERT INTO proposal_templates (dao_id, name, description, manifest, enabled, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + "#, + dao_id.as_str(), + name, + description, + manifest, + req.enabled, + user_id + ) + .fetch_one(&state.db_pool) + .await + .map_err(|e| { + if e.as_database_error() + .map(|db| db.is_unique_violation()) + .unwrap_or(false) + { + ( + StatusCode::CONFLICT, + "A template with this name or id already exists for this DAO".to_string(), + ) + } else { + internal_error("Failed to create proposal template", e) + } + })?; + + let row = fetch_template_by_id(&state.db_pool, dao_id.as_str(), id) + .await + .map_err(|e| internal_error("Failed to load created template", e))? + .ok_or_else(|| internal_error("Created template vanished", "not found"))?; + + let template = ProposalTemplate::try_from(row) + .map_err(|e| internal_error("Invalid account in template", e))?; + + Ok((StatusCode::CREATED, Json(template))) +} + +pub async fn update_proposal_template( + State(state): State<Arc<AppState>>, + auth_user: AuthUser, + Path((dao_id, id)): Path<(AccountId, Uuid)>, + Json(req): Json<UpdateProposalTemplateRequest>, +) -> Result<Json<ProposalTemplate>, (StatusCode, String)> { + auth_user + .verify_can_perform_action(&state, &dao_id, "ChangePolicy") + .await?; + + let manifest = match &req.manifest { + Some(m) => Some(validate_manifest(m).map_err(|e| (StatusCode::BAD_REQUEST, e))?), + None => None, + }; + + // Normalize the same way create does, so a whitespace-padded rename collides with the + // existing row instead of duplicating it. A blank name/description is rejected, not stored. + let name = trim_optional(req.name, "name")?; + let description = trim_optional(req.description, "description")?; + + // COALESCE keeps existing values for any field omitted from the request. + let updated = sqlx::query_scalar!( + r#" + UPDATE proposal_templates + SET name = COALESCE($3, name), + description = COALESCE($4, description), + manifest = COALESCE($5, manifest), + enabled = COALESCE($6, enabled), + pinned = COALESCE($7, pinned) + WHERE dao_id = $1 AND id = $2 + RETURNING id + "#, + dao_id.as_str(), + id, + name, + description, + manifest, + req.enabled, + req.pinned, + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| { + if e.as_database_error() + .map(|db| db.is_unique_violation()) + .unwrap_or(false) + { + ( + StatusCode::CONFLICT, + "A template with this name or id already exists for this DAO".to_string(), + ) + } else { + internal_error("Failed to update proposal template", e) + } + })?; + + if updated.is_none() { + return Err((StatusCode::NOT_FOUND, "Template not found".to_string())); + } + + let row = fetch_template_by_id(&state.db_pool, dao_id.as_str(), id) + .await + .map_err(|e| internal_error("Failed to load updated template", e))? + .ok_or_else(|| internal_error("Updated template vanished", "not found"))?; + + let template = ProposalTemplate::try_from(row) + .map_err(|e| internal_error("Invalid account in template", e))?; + + Ok(Json(template)) +} + +pub async fn delete_proposal_template( + State(state): State<Arc<AppState>>, + auth_user: AuthUser, + Path((dao_id, id)): Path<(AccountId, Uuid)>, +) -> Result<StatusCode, (StatusCode, String)> { + auth_user + .verify_can_perform_action(&state, &dao_id, "ChangePolicy") + .await?; + + let result = sqlx::query!( + "DELETE FROM proposal_templates WHERE dao_id = $1 AND id = $2", + dao_id.as_str(), + id + ) + .execute(&state.db_pool) + .await + .map_err(|e| internal_error("Failed to delete proposal template", e))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Template not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use crate::{ + routes::create_routes, + utils::test_utils::{ + DAO_ID, USER_ACCOUNT_ID, issue_auth_cookie, policy_granting, seed_change_policy_member, + seed_policy_member, seed_treasury_policy, send, test_state, + }, + }; + use axum::http::StatusCode; + use serde_json::{Value, json}; + use sqlx::PgPool; + use uuid::Uuid; + + fn valid_manifest() -> Value { + json!({ + "version": 1, + "id": "guestbook-tip", + "title": "Guestbook Tip", + "binding": { "receiver_id": "guestbook.near", "method_name": "add_message", + "deposit": "1", "gas": "30000000000000" }, + "fields": [{ "name": "amount", "label": "Amount", "type": "uint", "required": true }], + "args": { "amount": "{{amount}}" }, + "summary": "Tip {{amount}}" + }) + } + + /// A template whose creator is unknown (`created_by = NULL`, e.g. an imported or directly-seeded + /// row) must list without crashing: the `LEFT JOIN users` yields a NULL `created_by_wallet`, + /// which the query has to decode as `None`. Regression for the missing `?` nullability override + /// — the API create path always sets a creator, so only a non-API row ever hits the null path. + #[sqlx::test] + async fn test_list_tolerates_null_creator(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + seed_policy_member(&pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let manifest = valid_manifest(); + sqlx::query!( + r#" + INSERT INTO proposal_templates (dao_id, name, manifest, enabled, created_by) + VALUES ($1, $2, $3, true, NULL) + "#, + DAO_ID, + "Imported", + manifest, + ) + .execute(&pool) + .await + .expect("insert null-creator template"); + + let (status, body) = send(app, "GET", base, &cookie, None).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + + let templates: Value = serde_json::from_str(&body).expect("json body"); + let templates = templates.as_array().expect("array body"); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0]["name"], "Imported"); + assert_eq!(templates[0]["createdBy"], Value::Null); + } + + /// `id` is the route slug, so the create endpoint rejects a reserved one — it would shadow a + /// static `/custom-templates/<slug>` route. Mirrors the frontend `RESERVED_TEMPLATE_SLUGS`. + #[sqlx::test] + async fn test_create_rejects_reserved_slug_id(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + seed_change_policy_member(&state, &pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let mut manifest = valid_manifest(); + manifest["id"] = json!("create"); + let (status, body) = send( + app, + "POST", + base, + &cookie, + Some(json!({ "name": "Reserved", "manifest": manifest })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}"); + } + + #[sqlx::test] + async fn test_proposal_template_crud(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + seed_change_policy_member(&state, &pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + // CREATE + let (status, body) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some(json!({ "name": "Guestbook Tip", "manifest": valid_manifest() })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "create should succeed: {body}"); + let created: Value = serde_json::from_str(&body).unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["createdBy"].as_str(), Some(USER_ACCOUNT_ID)); + assert_eq!(created["enabled"].as_bool(), Some(true)); + assert_eq!( + created["pinned"].as_bool(), + Some(false), + "pinned defaults to false on create" + ); + assert_eq!( + created["manifest"], + valid_manifest(), + "manifest should round-trip through JSONB unchanged" + ); + + // CREATE duplicate name -> 409 + let (status, _) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some(json!({ "name": "Guestbook Tip", "manifest": valid_manifest() })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + + // CREATE invalid manifest -> 400 + let (status, _) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some(json!({ "name": "Bad", "manifest": { "id": "" } })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // CREATE with a whitespace-only name -> 400 + let (status, _) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some(json!({ "name": " ", "manifest": valid_manifest() })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // CREATE with a whitespace-only description -> 400 (not silently stored as "") + let (status, _) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some( + json!({ "name": "Blank Desc", "description": " ", "manifest": valid_manifest() }), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // CREATE a second template (target for the rename-collision check below). Distinct + // manifest id — manifest_id is unique per DAO now. + let mut other_manifest = valid_manifest(); + other_manifest["id"] = json!("other-template"); + let (status, _) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some(json!({ "name": "Other Template", "manifest": other_manifest })), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + // A whitespace-padded duplicate of an existing name must collide on the trimmed name — + // give it a distinct manifest id so the collision is purely the name, not manifest_id. + let mut padded_manifest = valid_manifest(); + padded_manifest["id"] = json!("padded-twin"); + let (status, _) = send( + app.clone(), + "POST", + base.clone(), + &cookie, + Some(json!({ "name": " Other Template ", "manifest": padded_manifest })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + + // LIST -> two templates + let (status, body) = send(app.clone(), "GET", base.clone(), &cookie, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + serde_json::from_str::<Value>(&body) + .unwrap() + .as_array() + .unwrap() + .len(), + 2 + ); + + let item = format!("{base}/{id}"); + + // UPDATE happy path (rename + disable) + let (status, body) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "name": "Guestbook Tip v2", "enabled": false })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let updated: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(updated["name"].as_str(), Some("Guestbook Tip v2")); + assert_eq!(updated["enabled"].as_bool(), Some(false)); + // Fields omitted from the body must be preserved (COALESCE), not cleared/overwritten. + assert!( + updated["description"].is_null(), + "omitted description must stay null, not be cleared to ''" + ); + assert_eq!( + updated["manifest"], + valid_manifest(), + "omitted manifest must be preserved, not overwritten" + ); + // The BEFORE UPDATE trigger must bump updated_at while leaving created_at fixed. + assert_eq!( + updated["createdAt"], created["createdAt"], + "created_at must not change on update" + ); + assert_ne!( + updated["updatedAt"], created["updatedAt"], + "updated_at must advance on update (BEFORE UPDATE trigger must be load-bearing)" + ); + + // UPDATE setting description explicitly -> round-trips through UPDATE, + // and leaves the other (omitted) fields untouched. + let (status, body) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "description": " new desc " })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let updated: Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + updated["description"].as_str(), + Some("new desc"), + "description should be trimmed on update" + ); + assert_eq!( + updated["name"].as_str(), + Some("Guestbook Tip v2"), + "name must be preserved when only description is updated" + ); + + // PIN: set it to true. + let (status, body) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "pinned": true })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + serde_json::from_str::<Value>(&body).unwrap()["pinned"].as_bool(), + Some(true), + "pinned should be set to true" + ); + + // A name-only update must NOT clear pinned — COALESCE($7, pinned) is load-bearing; without + // it, pin state would silently reset on every other field's update. + let (status, body) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "name": "renamed" })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + serde_json::from_str::<Value>(&body).unwrap()["pinned"].as_bool(), + Some(true), + "pinned must survive a name-only update, not silently clear" + ); + + // UNPIN: toggle true -> false and read it back. + let (status, body) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "pinned": false })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + serde_json::from_str::<Value>(&body).unwrap()["pinned"].as_bool(), + Some(false), + "pinned should toggle back to false" + ); + + // UPDATE with a whitespace-only description -> 400 + let (status, _) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "description": " " })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // UPDATE with a structurally-invalid manifest -> 400 + let (status, _) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "manifest": { "id": "" } })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // UPDATE renaming onto an existing template's name -> 409 + let (status, _) = send( + app.clone(), + "PUT", + item.clone(), + &cookie, + Some(json!({ "name": "Other Template" })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + + // UPDATE a non-existent id -> 404 + let (status, _) = send( + app.clone(), + "PUT", + format!("{base}/{}", Uuid::new_v4()), + &cookie, + Some(json!({ "enabled": true })), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + // DELETE -> 204 + let (status, _) = send(app.clone(), "DELETE", item.clone(), &cookie, None).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // DELETE again (already gone) -> 404 + let (status, _) = send(app.clone(), "DELETE", item.clone(), &cookie, None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + + // LIST -> only the second template remains + let (_, body) = send(app.clone(), "GET", base.clone(), &cookie, None).await; + let remaining: Value = serde_json::from_str(&body).unwrap(); + let remaining = remaining.as_array().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0]["name"].as_str(), Some("Other Template")); + } + + #[sqlx::test] + async fn test_non_member_is_forbidden(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + + // The DAO is fully onboarded (monitored + present in `daos`) and HAS a policy member + // — just not our caller. So a 403 reflects a genuine non-member, not an unknown DAO. + seed_policy_member(&pool, DAO_ID, "someone-else.near").await; + // Seed a policy (ChangePolicy to someone else) so the write endpoints reach a real 403 + // rather than an RPC to a non-existent DAO. + let dao: near_api::AccountId = DAO_ID.parse().unwrap(); + seed_treasury_policy( + &state, + &dao, + policy_granting("someone-else.near", &["*:ChangePolicy"]), + ) + .await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let item = format!("{base}/{}", Uuid::new_v4()); + + // Every endpoint must reject a non-member before doing anything else. + let cases: [(&str, String, Option<Value>); 4] = [ + ("GET", base.clone(), None), + ( + "POST", + base.clone(), + Some(json!({ "name": "x", "manifest": valid_manifest() })), + ), + ("PUT", item.clone(), Some(json!({ "enabled": true }))), + ("DELETE", item.clone(), None), + ]; + for (method, uri, body) in cases { + let (status, _) = send(app.clone(), method, uri, &cookie, body).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{method} must be forbidden for a non-member" + ); + } + } + + /// A policy member who lacks `ChangePolicy` can read/list templates but cannot author them — + /// the read/write split the gate exists to enforce. + #[sqlx::test] + async fn test_member_without_change_policy_cannot_write(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + + // member.near is a policy member, but ChangePolicy is granted to someone else. + seed_policy_member(&pool, DAO_ID, USER_ACCOUNT_ID).await; + let dao: near_api::AccountId = DAO_ID.parse().unwrap(); + seed_treasury_policy( + &state, + &dao, + policy_granting("admin.near", &["*:ChangePolicy"]), + ) + .await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + // Reads are allowed (membership). + let (status, body) = send(app.clone(), "GET", base.clone(), &cookie, None).await; + assert_eq!(status, StatusCode::OK, "a member may list: {body}"); + + // ...but every write (create/update/delete) is rejected without ChangePolicy. + let item = format!("{base}/{}", Uuid::new_v4()); + let writes: [(&str, String, Option<Value>); 3] = [ + ( + "POST", + base.clone(), + Some(json!({ "name": "X", "manifest": valid_manifest() })), + ), + ("PUT", item.clone(), Some(json!({ "enabled": true }))), + ("DELETE", item.clone(), None), + ]; + for (method, uri, body) in writes { + let (status, _) = send(app.clone(), method, uri, &cookie, body).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{method} must be forbidden without ChangePolicy" + ); + } + } + + #[sqlx::test] + async fn test_manifest_strings_are_normalized_on_store(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + + seed_change_policy_member(&state, &pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let padded = json!({ + "version": 1, + "id": " guestbook-tip ", + "title": " Guestbook Tip ", + "binding": { "receiver_id": " guestbook.near ", "method_name": " add_message " }, + "fields": [], + "args": {} + }); + let (status, body) = send( + app, + "POST", + base, + &cookie, + Some(json!({ "name": "Padded", "manifest": padded })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "create should succeed: {body}"); + + let created: Value = serde_json::from_str(&body).unwrap(); + let m = &created["manifest"]; + assert_eq!(m["id"].as_str(), Some("guestbook-tip")); + assert_eq!(m["title"].as_str(), Some("Guestbook Tip")); + assert_eq!(m["binding"]["receiver_id"].as_str(), Some("guestbook.near")); + assert_eq!(m["binding"]["method_name"].as_str(), Some("add_message")); + } + + #[sqlx::test] + async fn test_db_error_during_membership_check_is_500_not_403(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + let base = format!("/api/treasury/{DAO_ID}/proposal-templates"); + + seed_policy_member(&pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + // Break the membership lookup at the DB layer. A DB failure must surface as 500, not be + // collapsed into a misleading 403 (the bug behind verify_dao_member_for_http). + sqlx::query("DROP TABLE dao_members CASCADE") + .execute(&pool) + .await + .unwrap(); + + let (status, _) = send(app, "GET", base, &cookie, None).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn test_validate_manifest_rejects_malformed_shapes() { + assert!(super::validate_manifest(&valid_manifest()).is_ok()); + + let binding = json!({ "receiver_id": "a.near", "method_name": "m" }); + let rejected = [ + json!("not an object"), + json!({ "id": "", "title": "t", "binding": binding.clone(), "fields": [] }), + json!({ "id": "x", "title": "", "binding": binding.clone(), "fields": [] }), + json!({ "id": "x", "title": "t", "fields": [] }), // missing binding + json!({ "id": "x", "title": "t", "binding": { "method_name": "m" }, "fields": [] }), + json!({ "id": "x", "title": "t", "binding": { "receiver_id": "a.near" }, "fields": [] }), + json!({ "id": "x", "title": "t", "binding": binding.clone() }), // missing fields + json!({ "id": "x", "title": "t", "binding": binding.clone(), "fields": {} }), // fields not array + ]; + for case in rejected { + assert!( + super::validate_manifest(&case).is_err(), + "should reject manifest: {case}" + ); + } + } +} diff --git a/nt-be/src/handlers/treasury/custom_requests.rs b/nt-be/src/handlers/treasury/custom_requests.rs new file mode 100644 index 000000000..31ce215aa --- /dev/null +++ b/nt-be/src/handlers/treasury/custom_requests.rs @@ -0,0 +1,278 @@ +//! The per-treasury "Custom Requests" feature flag (custom proposal templates). +//! +//! The feature ships disabled for every treasury. A DAO turns it on from Settings → Developer, +//! which reveals the Request Templates section in the app. Reading the flag only needs DAO +//! membership; flipping it is gated on the same on-chain `ChangePolicy` permission that gates +//! authoring a template, so a privileged member opts the whole treasury in. The flag lives on +//! `monitored_accounts` (the per-treasury record `proposal_templates` already references). + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use near_api::AccountId; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::services::monitored_accounts::{ + RegisterMonitoredAccountError, register_or_refresh_monitored_account, +}; +use crate::{AppState, auth::AuthUser}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CustomRequestsSetting { + pub enabled: bool, +} + +fn internal_error(context: &str, e: impl std::fmt::Display) -> (StatusCode, String) { + tracing::error!("{context}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) +} + +/// `GET /api/treasury/{dao_id}/custom-requests` — whether the feature is enabled for this treasury. +/// Membership-gated; a treasury with no `monitored_accounts` row yet reads as disabled. +pub async fn get_custom_requests_setting( + State(state): State<Arc<AppState>>, + auth_user: AuthUser, + Path(dao_id): Path<AccountId>, +) -> Result<Json<CustomRequestsSetting>, (StatusCode, String)> { + auth_user + .verify_dao_member_for_http(&state.db_pool, &dao_id) + .await?; + + let enabled = sqlx::query_scalar!( + "SELECT custom_requests_enabled FROM monitored_accounts WHERE account_id = $1", + dao_id.as_str() + ) + .fetch_optional(&state.db_pool) + .await + .map_err(|e| internal_error("Failed to read custom-requests flag", e))? + .unwrap_or(false); + + Ok(Json(CustomRequestsSetting { enabled })) +} + +/// `PUT /api/treasury/{dao_id}/custom-requests` — enable or disable the feature for this treasury. +/// Gated on `ChangePolicy`, matching template authoring. The treasury row is ensured through the +/// canonical `register_or_refresh_monitored_account` registrar (not a bare upsert) so it goes +/// through the same `.sputnik-dao.near` suffix gate as every other treasury, then the flag is set. +pub async fn set_custom_requests_setting( + State(state): State<Arc<AppState>>, + auth_user: AuthUser, + Path(dao_id): Path<AccountId>, + Json(req): Json<CustomRequestsSetting>, +) -> Result<Json<CustomRequestsSetting>, (StatusCode, String)> { + auth_user + .verify_can_perform_action(&state, &dao_id, "ChangePolicy") + .await?; + + // Ensure the monitored_accounts row exists via the canonical path: it enforces the + // *.sputnik-dao.near suffix gate (and on an already-tracked treasury also re-marks the daos row + // dirty for the sync loop). A ChangePolicy holder implies a real DAO (the policy was fetched + // above), so NotSputnikDao is a defensive 400. + register_or_refresh_monitored_account(&state.db_pool, &dao_id, false) + .await + .map_err(|e| match e { + RegisterMonitoredAccountError::NotSputnikDao => ( + StatusCode::BAD_REQUEST, + "Account is not a SputnikDAO treasury".to_string(), + ), + RegisterMonitoredAccountError::Db(e) => { + internal_error("Failed to register treasury", e) + } + })?; + + let enabled = sqlx::query_scalar!( + "UPDATE monitored_accounts SET custom_requests_enabled = $2 WHERE account_id = $1 RETURNING custom_requests_enabled", + dao_id.as_str(), + req.enabled + ) + .fetch_one(&state.db_pool) + .await + .map_err(|e| internal_error("Failed to set custom-requests flag", e))?; + + Ok(Json(CustomRequestsSetting { enabled })) +} + +#[cfg(test)] +mod tests { + use crate::{ + config::{PlanType, get_initial_credits}, + routes::create_routes, + utils::test_utils::{ + DAO_ID, USER_ACCOUNT_ID, issue_auth_cookie, policy_granting, seed_change_policy_member, + seed_policy_member, seed_treasury_policy, send, test_state, + }, + }; + use axum::http::StatusCode; + use serde_json::{Value, json}; + use sqlx::PgPool; + + fn uri() -> String { + format!("/api/treasury/{DAO_ID}/custom-requests") + } + + fn enabled_of(body: &str) -> Option<bool> { + serde_json::from_str::<Value>(body).unwrap()["enabled"].as_bool() + } + + #[sqlx::test] + async fn test_defaults_to_disabled_for_a_member(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + seed_policy_member(&pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let (status, body) = send(app, "GET", uri(), &cookie, None).await; + assert_eq!(status, StatusCode::OK, "a member can read the flag: {body}"); + assert_eq!( + enabled_of(&body), + Some(false), + "the flag defaults to disabled" + ); + } + + #[sqlx::test] + async fn test_enable_then_disable_round_trips(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + seed_change_policy_member(&state, &pool, DAO_ID, USER_ACCOUNT_ID).await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + // ENABLE -> 200, and a follow-up GET reflects it. + let (status, body) = send( + app.clone(), + "PUT", + uri(), + &cookie, + Some(json!({ "enabled": true })), + ) + .await; + assert_eq!(status, StatusCode::OK, "enable should succeed: {body}"); + assert_eq!(enabled_of(&body), Some(true)); + let (_, body) = send(app.clone(), "GET", uri(), &cookie, None).await; + assert_eq!(enabled_of(&body), Some(true), "GET reflects the enable"); + + // DISABLE -> 200, and the flag toggles back. + let (status, body) = send( + app.clone(), + "PUT", + uri(), + &cookie, + Some(json!({ "enabled": false })), + ) + .await; + assert_eq!(status, StatusCode::OK, "disable should succeed: {body}"); + assert_eq!(enabled_of(&body), Some(false)); + let (_, body) = send(app, "GET", uri(), &cookie, None).await; + assert_eq!(enabled_of(&body), Some(false), "GET reflects the disable"); + } + + #[sqlx::test] + async fn test_enable_forbidden_without_change_policy(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + // A policy member, but ChangePolicy is granted to someone else — so the write must 403. + seed_policy_member(&pool, DAO_ID, USER_ACCOUNT_ID).await; + let dao: near_api::AccountId = DAO_ID.parse().unwrap(); + seed_treasury_policy( + &state, + &dao, + policy_granting("admin.near", &["*:ChangePolicy"]), + ) + .await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let (status, _) = send(app, "PUT", uri(), &cookie, Some(json!({ "enabled": true }))).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "flipping the flag requires ChangePolicy" + ); + } + + #[sqlx::test] + async fn test_read_forbidden_for_non_member(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + // A real, onboarded DAO whose only member is someone else (seed both the member row and a + // policy), so a 403 reflects a genuine non-member rather than an RPC to an unknown DAO. + seed_policy_member(&pool, DAO_ID, "someone-else.near").await; + let dao: near_api::AccountId = DAO_ID.parse().unwrap(); + seed_treasury_policy( + &state, + &dao, + policy_granting("someone-else.near", &["*:ChangePolicy"]), + ) + .await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let (status, _) = send(app, "GET", uri(), &cookie, None).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a non-member cannot read the flag" + ); + } + + #[sqlx::test] + async fn test_enable_registers_a_fresh_treasury(pool: PgPool) { + let state = test_state(pool.clone()); + let app = create_routes(state.clone()); + // Grant ChangePolicy but DO NOT seed a monitored_accounts row, so the PUT exercises the + // *fresh-DAO* branch of register_or_refresh_monitored_account (the `.sputnik-dao.near` suffix + // check + INSERT with default credits) — the reason the handler routes through it at all. + let dao: near_api::AccountId = DAO_ID.parse().unwrap(); + seed_treasury_policy( + &state, + &dao, + policy_granting(USER_ACCOUNT_ID, &["*:ChangePolicy"]), + ) + .await; + let cookie = issue_auth_cookie(&pool, &state, USER_ACCOUNT_ID).await; + + let before: Option<i32> = + sqlx::query_scalar("SELECT 1 FROM monitored_accounts WHERE account_id = $1") + .bind(DAO_ID) + .fetch_optional(&pool) + .await + .unwrap(); + assert!( + before.is_none(), + "precondition: no monitored_accounts row before the PUT" + ); + + let (status, body) = + send(app, "PUT", uri(), &cookie, Some(json!({ "enabled": true }))).await; + assert_eq!( + status, + StatusCode::OK, + "enabling a fresh DAO should register it and set the flag: {body}" + ); + assert_eq!(enabled_of(&body), Some(true)); + + // The registrar created the row with Plus-plan defaults — the load-bearing reason the handler + // routes through it (cf. set_custom_requests_setting doc). Assert against the same source of + // truth so a refactor that quietly drops the defaults fails here. + let (plan, export_credits, batch_payment_credits, gas_covered): (String, i32, i32, i32) = + sqlx::query_as( + "SELECT plan_type::text, export_credits, batch_payment_credits, gas_covered_transactions \ + FROM monitored_accounts WHERE account_id = $1", + ) + .bind(DAO_ID) + .fetch_one(&pool) + .await + .expect("the fresh treasury's monitored_accounts row was created by the registrar"); + let (want_export, want_batch, want_gas) = get_initial_credits(PlanType::Plus); + assert_eq!(plan, "plus", "fresh DAO registers on the Plus plan"); + assert_eq!( + (export_credits, batch_payment_credits, gas_covered), + (want_export, want_batch, want_gas), + "fresh DAO inherits get_initial_credits(Plus)" + ); + } +} diff --git a/nt-be/src/handlers/treasury/mod.rs b/nt-be/src/handlers/treasury/mod.rs index 74f45336e..f9bd8b209 100644 --- a/nt-be/src/handlers/treasury/mod.rs +++ b/nt-be/src/handlers/treasury/mod.rs @@ -2,5 +2,6 @@ pub mod check_handle_unused; pub mod confidential_setup; pub mod config; pub mod create; +pub mod custom_requests; pub mod policy; pub mod whitelist_request; diff --git a/nt-be/src/handlers/treasury/policy.rs b/nt-be/src/handlers/treasury/policy.rs index f50f5d116..46703d60a 100644 --- a/nt-be/src/handlers/treasury/policy.rs +++ b/nt-be/src/handlers/treasury/policy.rs @@ -19,16 +19,23 @@ pub struct GetTreasuryPolicyQuery { pub at_before: Option<U64>, } +/// Cache key for a treasury's `get_policy` result. Shared with the test seeding helper +/// (`utils::test_utils::seed_treasury_policy`) so a key-scheme change can't silently desync the +/// seed from the fetch and leak into RPC calls. +pub(crate) fn treasury_policy_cache_key(treasury_id: &AccountIdRef, at_before: u64) -> String { + CacheKey::new("treasury-policy") + .with(treasury_id) + .with(at_before) + .build() +} + pub async fn fetch_treasury_policy_cached( state: &Arc<AppState>, treasury_id: &AccountIdRef, at_before: Option<u64>, ) -> Result<serde_json::Value, (StatusCode, String)> { let at_before = at_before.unwrap_or(0); - let cache_key = CacheKey::new("treasury-policy") - .with(treasury_id) - .with(at_before) - .build(); + let cache_key = treasury_policy_cache_key(treasury_id, at_before); let network = if at_before > 0 { &state.archival_network diff --git a/nt-be/src/routes/mod.rs b/nt-be/src/routes/mod.rs index c2701402c..32a4ee5e9 100644 --- a/nt-be/src/routes/mod.rs +++ b/nt-be/src/routes/mod.rs @@ -2,7 +2,7 @@ use axum::{ Json, Router, extract::State, http::StatusCode, - routing::{get, post}, + routing::{get, post, put}, }; use serde_json::{Value, json}; use std::sync::Arc; @@ -345,6 +345,23 @@ pub fn create_routes(state: Arc<AppState>) -> Router { "/api/address-book/export", get(handlers::address_book::export_address_book), ) + // Proposal template (custom-proposal framework) endpoints + .route( + "/api/treasury/{dao_id}/proposal-templates", + get(handlers::proposal_templates::list_proposal_templates) + .post(handlers::proposal_templates::create_proposal_template), + ) + .route( + "/api/treasury/{dao_id}/proposal-templates/{id}", + put(handlers::proposal_templates::update_proposal_template) + .delete(handlers::proposal_templates::delete_proposal_template), + ) + // Custom Requests feature flag (opt-in, gated on ChangePolicy) + .route( + "/api/treasury/{dao_id}/custom-requests", + get(handlers::treasury::custom_requests::get_custom_requests_setting) + .put(handlers::treasury::custom_requests::set_custom_requests_setting), + ) // DAO endpoints .route( "/api/dao/mark-dirty", diff --git a/nt-be/src/utils/test_utils.rs b/nt-be/src/utils/test_utils.rs index 047989b1e..e9279be36 100644 --- a/nt-be/src/utils/test_utils.rs +++ b/nt-be/src/utils/test_utils.rs @@ -8,6 +8,9 @@ use crate::AppState; #[cfg(test)] use crate::utils::cache::Cache; +#[cfg(test)] +use near_account_id::AccountIdRef; + #[cfg(test)] use near_api::{NetworkConfig, RPCEndpoint, Signer}; @@ -139,3 +142,175 @@ pub fn build_test_state(db_pool: sqlx::PgPool) -> AppState { neardata_client: None, } } + +/// A minimal SputnikDAO policy granting `account_id` the given `<kind>:<action>` permissions via a +/// group role — enough for `AuthUser::verify_can_perform_action` to evaluate. +#[cfg(test)] +pub fn policy_granting(account_id: &str, permissions: &[&str]) -> serde_json::Value { + serde_json::json!({ + "roles": [{ + "name": "test-role", + "kind": { "Group": [account_id] }, + "permissions": permissions, + }], + }) +} + +/// Seed the treasury-policy cache so policy-gated handlers (`verify_can_perform_action`) can be +/// unit-tested without an RPC `get_policy` call. Uses the same `treasury_policy_cache_key` the fetch +/// path builds, so the seeded key can't desync from the lookup; the seeded policy is then served +/// from cache and the RPC closure never runs. +#[cfg(test)] +pub async fn seed_treasury_policy( + state: &AppState, + dao_id: &AccountIdRef, + policy: serde_json::Value, +) { + let key = crate::handlers::treasury::policy::treasury_policy_cache_key(dao_id, 0); + state.cache.short_term.insert(key, policy).await; +} + +// --- Treasury handler-test scaffolding ---------------------------------------------------------- +// Shared by the policy-gated handler tests (`proposal_templates`, `custom_requests`): an `AppState` +// wrapper, the DAO/user seeding, an auth cookie, and a one-shot request helper. Hoisted here once a +// third copy appeared, so a new handler test imports these instead of re-rolling the same recipe. + +#[cfg(test)] +pub const DAO_ID: &str = "test-dao.sputnik-dao.near"; +#[cfg(test)] +pub const USER_ACCOUNT_ID: &str = "member.near"; + +#[cfg(test)] +pub fn test_state(pool: sqlx::PgPool) -> std::sync::Arc<AppState> { + std::sync::Arc::new(build_test_state(pool)) +} + +/// Seed `account_id` as a policy member of `dao_id` — enough for the membership-gated reads. +#[cfg(test)] +pub async fn seed_policy_member(pool: &sqlx::PgPool, dao_id: &str, account_id: &str) { + sqlx::query!( + "INSERT INTO monitored_accounts (account_id) VALUES ($1) ON CONFLICT (account_id) DO NOTHING", + dao_id, + ) + .execute(pool) + .await + .expect("seed monitored account"); + + sqlx::query!( + "INSERT INTO daos (dao_id) VALUES ($1) ON CONFLICT (dao_id) DO NOTHING", + dao_id, + ) + .execute(pool) + .await + .expect("seed dao"); + + sqlx::query!( + r#" + INSERT INTO dao_members (dao_id, account_id, is_policy_member, is_saved, is_hidden) + VALUES ($1, $2, true, false, false) + ON CONFLICT (dao_id, account_id) DO UPDATE SET is_policy_member = true + "#, + dao_id, + account_id, + ) + .execute(pool) + .await + .expect("seed policy member"); +} + +/// Seed a policy member who also holds the on-chain `ChangePolicy` permission — i.e. someone +/// allowed to author templates / flip the custom-requests flag. +#[cfg(test)] +pub async fn seed_change_policy_member( + state: &std::sync::Arc<AppState>, + pool: &sqlx::PgPool, + dao_id: &str, + account_id: &str, +) { + seed_policy_member(pool, dao_id, account_id).await; + let dao: near_api::AccountId = dao_id.parse().expect("valid dao id"); + seed_treasury_policy( + state, + &dao, + policy_granting(account_id, &["*:ChangePolicy"]), + ) + .await; +} + +/// Create a user + session for `account_id` and return its auth `cookie` header value. +#[cfg(test)] +pub async fn issue_auth_cookie( + pool: &sqlx::PgPool, + state: &std::sync::Arc<AppState>, + account_id: &str, +) -> String { + use crate::auth::{create_jwt, middleware::AUTH_COOKIE_NAME}; + + let user_id: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO users (account_id) VALUES ($1) ON CONFLICT (account_id) DO UPDATE SET updated_at = NOW() RETURNING id", + ) + .bind(account_id) + .fetch_one(pool) + .await + .expect("create test user"); + + let jwt = create_jwt( + account_id, + state.env_vars.jwt_secret.as_bytes(), + state.env_vars.jwt_expiry_hours, + ) + .expect("create JWT"); + + sqlx::query!( + "INSERT INTO user_sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", + user_id, + jwt.token_hash, + jwt.expires_at, + ) + .execute(pool) + .await + .expect("create session"); + + format!("{AUTH_COOKIE_NAME}={}", jwt.token) +} + +#[cfg(test)] +pub async fn response_text(response: axum::response::Response) -> String { + use axum::body::to_bytes; + String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body") + .to_vec(), + ) + .expect("utf-8 body") +} + +/// Send one request through the router and return `(status, body text)`. +#[cfg(test)] +pub async fn send( + app: axum::Router, + method: &str, + uri: String, + cookie: &str, + body: Option<serde_json::Value>, +) -> (axum::http::StatusCode, String) { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("cookie", cookie); + let body = match body { + Some(v) => { + builder = builder.header("content-type", "application/json"); + Body::from(v.to_string()) + } + None => Body::empty(), + }; + let resp = app.oneshot(builder.body(body).unwrap()).await.unwrap(); + let status = resp.status(); + (status, response_text(resp).await) +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx new file mode 100644 index 000000000..d11ee8837 --- /dev/null +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx @@ -0,0 +1,165 @@ +"use client"; + +/** + * Edit or delete an existing template. Reuses TemplateEditor (Visual constructor by default, Code + * tab for raw JSON) pre-filled with the template's current name + manifest, saving via the + * `ChangePolicy`-gated update endpoint. Delete is confirmed in a dialog and returns to the index. A + * non-author hitting save/delete gets the backend's 403 as a toast. + */ +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/button"; +import { PageCard } from "@/components/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/modal"; +import { PageComponentLayout } from "@/components/page-component-layout"; +import { apiErrorMessage } from "@/features/proposal-templates/api"; +import { TemplateEditor } from "@/features/proposal-templates/components/template-editor"; +import { + useDeleteProposalTemplate, + useUpdateProposalTemplate, +} from "@/features/proposal-templates/hooks/use-proposal-template-mutations"; +import { useProposalTemplates } from "@/features/proposal-templates/hooks/use-proposal-templates"; +import { manifestIdOf } from "@/features/proposal-templates/manifest"; +import { useTreasury } from "@/hooks/use-treasury"; + +export default function EditTemplatePage() { + const router = useRouter(); + const params = useParams(); + const slug = params?.slug as string | undefined; + const { treasuryId } = useTreasury(); + const { data: templates, isLoading } = useProposalTemplates(); + const updateTemplate = useUpdateProposalTemplate(); + const deleteTemplate = useDeleteProposalTemplate(); + const [confirmingDelete, setConfirmingDelete] = useState(false); + + const template = (templates ?? []).find( + (candidate) => manifestIdOf(candidate.manifest) === slug, + ); + + async function handleUpdate({ + name, + manifest, + }: { + name: string; + manifest: unknown; + }) { + if (!template || !treasuryId) { + return; + } + try { + const updated = await updateTemplate.mutateAsync({ + id: template.id, + input: { name, manifest }, + }); + toast.success("Template saved"); + router.push( + `/${treasuryId}/custom-templates/${manifestIdOf(updated.manifest)}`, + ); + } catch (error) { + toast.error(apiErrorMessage(error, "Failed to save template")); + } + } + + async function handleDelete() { + if (!template || !treasuryId) { + return; + } + setConfirmingDelete(false); + try { + await deleteTemplate.mutateAsync(template.id); + toast.success("Template deleted"); + router.push(`/${treasuryId}/custom-templates`); + } catch (error) { + toast.error(apiErrorMessage(error, "Failed to delete template")); + } + } + + return ( + <PageComponentLayout + title="Request Templates" + description="Build reusable templates for custom request types." + > + <div className="mx-auto flex w-full max-w-4xl flex-col gap-4"> + {isLoading ? ( + <PageCard> + <p className="text-muted-foreground text-sm"> + Loading… + </p> + </PageCard> + ) : template ? ( + <TemplateEditor + key={template.id} + title="Edit Template" + onBack={() => + router.push(`/${treasuryId}/custom-templates`) + } + initialName={template.name} + initialManifestText={JSON.stringify( + template.manifest, + null, + 2, + )} + submitLabel="Save Changes" + submitting={updateTemplate.isPending} + onSubmit={handleUpdate} + footer={ + <Button + type="button" + variant="ghost" + className="w-full text-destructive hover:text-destructive" + disabled={ + updateTemplate.isPending || + deleteTemplate.isPending + } + onClick={() => setConfirmingDelete(true)} + > + Delete Template + </Button> + } + /> + ) : ( + <PageCard> + <p className="text-muted-foreground text-sm"> + Template not found. + </p> + </PageCard> + )} + </div> + + <Dialog + open={confirmingDelete} + onOpenChange={(open) => !open && setConfirmingDelete(false)} + > + <DialogContent className="max-w-md gap-4"> + <DialogHeader> + <DialogTitle>Delete template</DialogTitle> + </DialogHeader> + <DialogDescription> + Delete{" "} + <span className="font-semibold">{template?.name}</span>? + Members will no longer be able to file proposals from + it. This cannot be undone. + </DialogDescription> + <DialogFooter> + <Button + variant="destructive" + className="flex-1" + disabled={deleteTemplate.isPending} + onClick={handleDelete} + > + {deleteTemplate.isPending ? "Deleting…" : "Delete"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </PageComponentLayout> + ); +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx new file mode 100644 index 000000000..5aab220f2 --- /dev/null +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * Per-template page: `/<dao>/custom-templates/<slug>` where `<slug>` is the manifest id. Resolves + * the template, renders its manifest as a form (`<ManifestForm>`), and on submit builds the + * FunctionCall (`buildTemplateProposal`) and files it via the house `createProposal` helper — the + * same gasless-relayer route the core proposals use, so a custom request behaves like a built-in one. + */ +import { ArrowLeft } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { PageCard } from "@/components/card"; +import { PageComponentLayout } from "@/components/page-component-layout"; +import { + buildTemplateProposal, + type FieldValues, +} from "@/features/proposal-templates/build-proposal"; +import { ManifestForm } from "@/features/proposal-templates/components/manifest-form"; +import { useProposalTemplates } from "@/features/proposal-templates/hooks/use-proposal-templates"; +import { + manifestErrorMessages, + manifestIdOf, + parseManifest, +} from "@/features/proposal-templates/manifest"; +import { useTreasury } from "@/hooks/use-treasury"; +import { useTreasuryPolicy } from "@/hooks/use-treasury-queries"; +import { useNear } from "@/stores/near-store"; + +export default function CustomTemplatePage() { + const params = useParams(); + const router = useRouter(); + const slug = params?.slug as string | undefined; + const { treasuryId } = useTreasury(); + // Files through the house `createProposal` helper — the same gasless-relayer path the core + // proposals (payments, exchange, members…) use: it signs the `add_proposal` delegate action, + // relays it via the backend, waits for the indexer, invalidates the requests list, and shows the + // rich "View request" toast. So a custom request behaves exactly like a built-in one. + const { createProposal } = useNear(); + const { data: policy } = useTreasuryPolicy(treasuryId); + const { data: templates, isLoading } = useProposalTemplates(); + const [submitting, setSubmitting] = useState(false); + + const template = templates?.find( + (candidate) => manifestIdOf(candidate.manifest) === slug, + ); + const parsed = template ? parseManifest(template.manifest) : null; + const templateTitle = parsed?.success ? parsed.data.title : null; + + // The section layout titles the tab "Request Templates"; on a specific template show its own + // name instead (the layout's "%s | Trezu" template doesn't apply to a client-set document.title, + // so add the suffix here). Restore the previous title on unmount, like the receipt page. + useEffect(() => { + if (typeof document === "undefined" || !templateTitle) { + return; + } + const previousTitle = document.title; + document.title = `${templateTitle} | Trezu`; + return () => { + document.title = previousTitle; + }; + }, [templateTitle]); + + // `createProposal` already gates on a full session (toasts the localized "connect + accept terms" + // message and throws) and toasts its own relayer errors — so we don't pre-check or re-toast. The + // throw propagating out is what lets ManifestForm skip its clear-on-success path on failure. + async function handleSubmit(values: FieldValues) { + if (!parsed?.success || !treasuryId) { + return; + } + setSubmitting(true); + try { + const { kind, description } = buildTemplateProposal( + parsed.data, + values, + ); + await createProposal("Request filed", { + treasuryId, + proposal: { description, kind }, + proposalBond: policy?.proposal_bond ?? "0", + proposalType: "other", + }); + } finally { + setSubmitting(false); + } + } + + return ( + <PageComponentLayout + title="Request Templates" + description="Build reusable templates for custom request types." + > + <div className="mx-auto flex w-full max-w-[600px] flex-col gap-4"> + {isLoading ? ( + <PageCard> + <p className="text-muted-foreground text-sm"> + Loading… + </p> + </PageCard> + ) : !template ? ( + <PageCard> + <p className="text-muted-foreground text-sm"> + No template found for “{slug}”. + </p> + </PageCard> + ) : parsed && !parsed.success ? ( + <PageCard> + <div className="text-destructive text-sm"> + <p>This template's manifest is invalid:</p> + <ul className="list-disc pl-5"> + {manifestErrorMessages(parsed.error).map( + (message) => ( + <li key={message}>{message}</li> + ), + )} + </ul> + </div> + </PageCard> + ) : parsed?.success ? ( + <PageCard className="gap-4"> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => + router.push( + `/${treasuryId}/custom-templates`, + ) + } + aria-label="Back" + className="text-muted-foreground transition-colors hover:text-foreground" + > + <ArrowLeft className="size-5" /> + </button> + <h2 className="font-semibold text-base"> + {parsed.data.title} + </h2> + </div> + {parsed.data.description ? ( + <p className="text-muted-foreground text-sm"> + {parsed.data.description} + </p> + ) : null} + <ManifestForm + manifest={parsed.data} + onSubmit={handleSubmit} + submitting={submitting} + /> + </PageCard> + ) : null} + </div> + </PageComponentLayout> + ); +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx new file mode 100644 index 000000000..4c0021e8e --- /dev/null +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * Author a new proposal template: the shared TemplateEditor (Visual constructor by default, with a + * Code tab for raw manifest JSON) validates live, then saves via the `ChangePolicy`-gated create + * endpoint. Lives at the reserved `create` slug. + */ +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { PageComponentLayout } from "@/components/page-component-layout"; +import { apiErrorMessage } from "@/features/proposal-templates/api"; +import { TemplateEditor } from "@/features/proposal-templates/components/template-editor"; +import { useCreateProposalTemplate } from "@/features/proposal-templates/hooks/use-proposal-template-mutations"; +import { manifestIdOf } from "@/features/proposal-templates/manifest"; +import { useTreasury } from "@/hooks/use-treasury"; + +export default function CreateTemplatePage() { + const router = useRouter(); + const { treasuryId } = useTreasury(); + const createTemplate = useCreateProposalTemplate(); + + async function handleCreate({ + name, + manifest, + }: { + name: string; + manifest: unknown; + }) { + if (!treasuryId) { + return; + } + try { + const created = await createTemplate.mutateAsync({ + name, + manifest, + }); + toast.success("Template created"); + router.push( + `/${treasuryId}/custom-templates/${manifestIdOf(created.manifest)}`, + ); + } catch (error) { + toast.error(apiErrorMessage(error, "Failed to create template")); + } + } + + return ( + <PageComponentLayout + title="Request Templates" + description="Build reusable templates for custom request types." + > + <div className="mx-auto flex w-full max-w-4xl flex-col gap-4"> + <TemplateEditor + title="New Template" + onBack={() => + router.push(`/${treasuryId}/custom-templates`) + } + submitLabel="Create Template" + submittingLabel="Creating…" + submitting={createTemplate.isPending} + onSubmit={handleCreate} + /> + </div> + </PageComponentLayout> + ); +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx new file mode 100644 index 000000000..d7a08e617 --- /dev/null +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export async function generateMetadata(): Promise<Metadata> { + return { title: "Request Templates" }; +} + +export default function CustomTemplatesLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx new file mode 100644 index 000000000..dec435e84 --- /dev/null +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +/** + * Index for the Request Templates section: the empty state (no templates yet) or the Templates list. + * Each row links to its fill page ("Create Request") and carries a ⋮ menu — Edit, Pin/Unpin (the + * pin controls whether it shows in the sidebar), Delete (confirmed). Authoring/pin/delete are + * ChangePolicy-gated server-side; a non-author gets the backend's 403 as a toast. + */ +import { + Bookmark, + CircleHelp, + EllipsisVertical, + Pencil, + Pin, + PinOff, + Plus, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/button"; +import { PageCard } from "@/components/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/modal"; +import { PageComponentLayout } from "@/components/page-component-layout"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { apiErrorMessage } from "@/features/proposal-templates/api"; +import { + useDeleteProposalTemplate, + useUpdateProposalTemplate, +} from "@/features/proposal-templates/hooks/use-proposal-template-mutations"; +import { useProposalTemplates } from "@/features/proposal-templates/hooks/use-proposal-templates"; +import { manifestIdOf } from "@/features/proposal-templates/manifest"; +import type { ProposalTemplate } from "@/features/proposal-templates/types"; +import { useTreasury } from "@/hooks/use-treasury"; + +// Predicted official docs path; swap for the maintainers' docs-site URL once it exists. +const HOW_IT_WORKS_URL = + "https://github.com/NEAR-DevHub/trezu/blob/main/docs/CUSTOM_PROPOSAL_TEMPLATES.md"; + +// The bare shadcn dropdown item is px-2 py-1.5 with no icon gap; the design wants 10×12 padding and +// a 12px icon-to-label gap, so the ⋮ menu items share this. Delete is a plain item (not red). +const MENU_ITEM_CLASS = "gap-3 px-3 py-2.5"; + +function HowItWorksLink() { + return ( + <Button + variant="ghost" + size="sm" + className="text-muted-foreground hover:text-foreground" + asChild + > + <a href={HOW_IT_WORKS_URL} target="_blank" rel="noreferrer"> + <CircleHelp className="size-4" /> How It Works + </a> + </Button> + ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( + <PageCard className="items-center gap-3 py-16 text-center"> + <div className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground"> + <Bookmark className="size-5" /> + </div> + <div className="flex flex-col gap-1"> + <h2 className="font-semibold text-base"> + Create your first template + </h2> + <p className="max-w-sm text-muted-foreground text-sm"> + Make a template for requests you send often. Your team + reuses it in one click. + </p> + </div> + <div className="flex items-center gap-2"> + <HowItWorksLink /> + <Button onClick={onCreate}> + <Plus className="size-4" /> Create Template + </Button> + </div> + </PageCard> + ); +} + +interface TemplateRowProps { + template: ProposalTemplate; + onCreateRequest: () => void; + onEdit: () => void; + onTogglePin: () => void; + onDelete: () => void; +} + +function TemplateRow({ + template, + onCreateRequest, + onEdit, + onTogglePin, + onDelete, +}: TemplateRowProps) { + return ( + <div className="group flex min-h-[75px] items-center justify-between gap-3 rounded-xl bg-[#FAFAF9] p-4 dark:bg-muted"> + <div className="flex min-w-0 flex-col gap-0.5"> + <span className="truncate font-semibold text-[15px] text-foreground"> + {template.name} + </span> + {template.description ? ( + <span className="truncate text-[12px] text-muted-foreground"> + {template.description} + </span> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-1"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-sm" + aria-label="Template actions" + className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100" + > + <EllipsisVertical className="size-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={onEdit} + className={MENU_ITEM_CLASS} + > + <Pencil className="size-4" /> Edit + </DropdownMenuItem> + <DropdownMenuItem + onClick={onTogglePin} + className={MENU_ITEM_CLASS} + > + {template.pinned ? ( + <> + <PinOff className="size-4" /> Unpin Template + </> + ) : ( + <> + <Pin className="size-4" /> Pin to the + Sidebar + </> + )} + </DropdownMenuItem> + <DropdownMenuItem + onClick={onDelete} + className={MENU_ITEM_CLASS} + > + <Trash2 className="size-4" /> Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <Button onClick={onCreateRequest}>Create Request</Button> + </div> + </div> + ); +} + +export default function CustomTemplatesIndexPage() { + const router = useRouter(); + const { treasuryId } = useTreasury(); + const { data: templates, isLoading } = useProposalTemplates(); + const updateTemplate = useUpdateProposalTemplate(); + const deleteTemplate = useDeleteProposalTemplate(); + const [confirmingDelete, setConfirmingDelete] = + useState<ProposalTemplate | null>(null); + + const enabled = (templates ?? []).filter( + (template) => template.enabled && manifestIdOf(template.manifest), + ); + + const go = (suffix: string) => + router.push(`/${treasuryId}/custom-templates${suffix}`); + + function togglePin(template: ProposalTemplate) { + updateTemplate.mutate( + { id: template.id, input: { pinned: !template.pinned } }, + { + onError: (error) => + toast.error(apiErrorMessage(error, "Could not update pin")), + }, + ); + } + + async function handleDelete() { + const template = confirmingDelete; + if (!template) { + return; + } + setConfirmingDelete(null); + try { + await deleteTemplate.mutateAsync(template.id); + toast.success("Template deleted"); + } catch (error) { + toast.error(apiErrorMessage(error, "Failed to delete template")); + } + } + + return ( + <PageComponentLayout + title="Request Templates" + description="Build reusable templates for custom request types." + > + <div className="mx-auto flex w-full max-w-4xl flex-col gap-4"> + {isLoading ? ( + <PageCard> + <p className="text-muted-foreground text-sm"> + Loading… + </p> + </PageCard> + ) : enabled.length === 0 ? ( + <EmptyState onCreate={() => go("/create")} /> + ) : ( + <PageCard className="gap-4 p-5"> + <div className="flex items-center justify-between gap-2"> + <h2 className="font-semibold text-base"> + Templates + </h2> + <div className="flex items-center gap-2"> + <HowItWorksLink /> + <Button + variant="secondary" + onClick={() => go("/create")} + > + <Plus className="size-4" /> Add New + </Button> + </div> + </div> + <div className="flex flex-col gap-3"> + {enabled.map((template) => ( + <TemplateRow + key={template.id} + template={template} + onCreateRequest={() => + go( + `/${manifestIdOf(template.manifest)}`, + ) + } + onEdit={() => + go( + `/${manifestIdOf(template.manifest)}/edit`, + ) + } + onTogglePin={() => togglePin(template)} + onDelete={() => + setConfirmingDelete(template) + } + /> + ))} + </div> + </PageCard> + )} + </div> + + <Dialog + open={confirmingDelete !== null} + onOpenChange={(open) => !open && setConfirmingDelete(null)} + > + <DialogContent className="max-w-md gap-4"> + <DialogHeader> + <DialogTitle>Delete template</DialogTitle> + </DialogHeader> + <DialogDescription> + Delete{" "} + <span className="font-semibold"> + {confirmingDelete?.name} + </span> + ? Members will no longer be able to file proposals from + it. This cannot be undone. + </DialogDescription> + <DialogFooter> + <Button + variant="destructive" + className="flex-1" + disabled={deleteTemplate.isPending} + onClick={handleDelete} + > + {deleteTemplate.isPending ? "Deleting…" : "Delete"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </PageComponentLayout> + ); +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx b/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx new file mode 100644 index 000000000..2de8b8fa4 --- /dev/null +++ b/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { CodeXml } from "lucide-react"; +/** + * Settings → Developer tab. Hosts the opt-in for Custom Requests (the custom-proposal-templates + * feature). Enabling it (ChangePolicy-gated server-side) reveals the Request Templates section in + * the sidebar; the first time a treasury turns it on, we run the one-step onboarding tour pointing + * at that freshly revealed item. + */ +import { useNextStep } from "nextstepjs"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/button"; +import { PageCard } from "@/components/card"; +import { + PAGE_TOUR_STORAGE_KEYS, + REQUEST_TEMPLATES_TOUR_NAME, +} from "@/features/onboarding/steps/page-tours"; +import { apiErrorMessage } from "@/features/proposal-templates/api"; +import { + useCustomRequestsEnabled, + useSetCustomRequestsEnabled, +} from "@/features/proposal-templates/hooks/use-custom-requests-enabled"; +import { useTreasury } from "@/hooks/use-treasury"; + +export function DeveloperTab() { + const { treasuryId } = useTreasury(); + const { data: enabled, isLoading } = useCustomRequestsEnabled(); + const setEnabled = useSetCustomRequestsEnabled(); + const { startNextStep, currentTour } = useNextStep(); + // Set when *this user* clicks Enable, so the tour only follows a deliberate action (not merely + // opening Settings on an already-enabled treasury). + const [tourRequested, setTourRequested] = useState(false); + + // Show once per treasury, ever. Keyed so each DAO a user enables gets its own hint. + const tourShownKey = `${PAGE_TOUR_STORAGE_KEYS.REQUEST_TEMPLATES_SHOWN}:${treasuryId}`; + const fired = useRef(false); + + // Fire the tour off the flag actually flipping true — i.e. once the sidebar has re-rendered the + // #request-templates-nav anchor — rather than a wall-clock guess, so it can't race the render. + useEffect(() => { + if (!tourRequested || !enabled || fired.current) { + return; + } + if (currentTour) { + return; + } + if ( + typeof window !== "undefined" && + localStorage.getItem(tourShownKey) === "true" + ) { + setTourRequested(false); + return; + } + fired.current = true; + setTourRequested(false); + localStorage.setItem(tourShownKey, "true"); + startNextStep(REQUEST_TEMPLATES_TOUR_NAME); + }, [tourRequested, enabled, currentTour, startNextStep, tourShownKey]); + + function toggle(next: boolean) { + setEnabled.mutate(next, { + onSuccess: () => { + if (next) { + setTourRequested(true); + } + }, + onError: (error) => + toast.error( + apiErrorMessage(error, "Could not update the setting"), + ), + }); + } + + return ( + <PageCard> + <div className="flex items-start gap-4"> + <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"> + <CodeXml className="size-5" /> + </div> + <div className="flex flex-1 flex-col gap-1"> + <h3 className="font-semibold text-base">Custom Requests</h3> + <p className="text-muted-foreground text-sm"> + Build your own request types when you need more than + payments and exchange. Add the fields you need and your + members can submit them like any other request. + </p> + </div> + <Button + variant={enabled ? "outline" : "default"} + disabled={isLoading || setEnabled.isPending} + onClick={() => toggle(!enabled)} + > + {enabled ? "Disable" : "Enable"} + </Button> + </div> + </PageCard> + ); +} diff --git a/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx index 8df4c2bcc..62320855c 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx @@ -1,11 +1,13 @@ "use client"; -import { useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { Suspense, useEffect, useState } from "react"; import { PageComponentLayout } from "@/components/page-component-layout"; import { TabGroup } from "@/components/tab-group"; import { features } from "@/constants/features"; +import { useTreasury } from "@/hooks/use-treasury"; +import { DeveloperTab } from "./components/developer-tab"; import { GeneralTab } from "./components/general-tab"; import { IntegrationsTab } from "./components/integrations-tab"; import { PreferencesTab } from "./components/preferences-tab"; @@ -16,18 +18,39 @@ function SettingsPageContent() { const tTabs = useTranslations("settings.tabs"); const searchParams = useSearchParams(); const tabFromUrl = searchParams.get("tab"); - const [activeTab, setActiveTab] = useState(() => - tabFromUrl === "integrations" && features.integrations - ? "integrations" - : "general", - ); + // The Developer tab only does anything for a signed-in member (its Enable action is + // ChangePolicy-gated), so hide it from guests and signed-out viewers — same idea as the + // feature-flag-gated Integrations tab. + const { isGuestTreasury } = useTreasury(); + const showDeveloper = !isGuestTreasury; + const [activeTab, setActiveTab] = useState(() => { + if (tabFromUrl === "integrations" && features.integrations) { + return "integrations"; + } + if (tabFromUrl === "developer" && showDeveloper) { + return "developer"; + } + return "general"; + }); useEffect(() => { const tab = searchParams.get("tab"); if (tab === "integrations" && features.integrations) { setActiveTab("integrations"); + } else if (tab === "developer" && showDeveloper) { + setActiveTab("developer"); + } + }, [searchParams, showDeveloper]); + + // `isGuestTreasury` resolves async, so a guest can land on `?tab=developer` before it's known; + // once Developer is hidden, never strand on it (no matching tab or body). + useEffect(() => { + if (!showDeveloper) { + setActiveTab((current) => + current === "developer" ? "general" : current, + ); } - }, [searchParams]); + }, [showDeveloper]); const tabs = [ { value: "general", label: tTabs("general") }, @@ -42,6 +65,7 @@ function SettingsPageContent() { }, ] : []), + ...(showDeveloper ? [{ value: "developer", label: "Developer" }] : []), ]; return ( @@ -61,6 +85,7 @@ function SettingsPageContent() { {activeTab === "integrations" && features.integrations && ( <IntegrationsTab /> )} + {activeTab === "developer" && showDeveloper && <DeveloperTab />} </div> </PageComponentLayout> ); diff --git a/nt-fe/components/animate-ui/icons/code-xml.tsx b/nt-fe/components/animate-ui/icons/code-xml.tsx new file mode 100644 index 000000000..ed2f00289 --- /dev/null +++ b/nt-fe/components/animate-ui/icons/code-xml.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { motion, type Variants } from "motion/react"; + +import { + getVariants, + type IconProps, + IconWrapper, + useAnimateIconContext, +} from "@/components/animate-ui/icons/icon"; + +type CodeXmlProps = IconProps<keyof typeof animations>; + +// Each keyframe list starts AND ends at the resting (visible) value, so the icon is fully drawn at +// rest and the hover just plays a flourish — no jump-to-hidden first frame (cf. bookmark's outline). +const animations = { + default: { + // "<" dips in toward centre and springs back out to rest. + left: { + initial: { x: 0 }, + animate: { + x: [0, 4, 0], + transition: { duration: 0.35, ease: "easeInOut" }, + }, + }, + // ">" mirrors it. + right: { + initial: { x: 0 }, + animate: { + x: [0, -4, 0], + transition: { duration: 0.35, ease: "easeInOut" }, + }, + }, + // "/" erases and redraws itself top → bottom, just after the brackets move. + slash: { + initial: { pathLength: 1 }, + animate: { + pathLength: [1, 0, 1], + transition: { duration: 0.4, ease: "easeInOut", delay: 0.1 }, + }, + }, + } satisfies Record<string, Variants>, +} as const; + +function IconComponent({ size, ...props }: CodeXmlProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + <motion.svg + xmlns="http://www.w3.org/2000/svg" + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + strokeLinecap="round" + strokeLinejoin="round" + {...props} + > + <motion.path + d="m18 16 4-4-4-4" + variants={variants.right} + initial="initial" + animate={controls} + /> + <motion.path + d="m6 8-4 4 4 4" + variants={variants.left} + initial="initial" + animate={controls} + /> + <motion.path + d="m14.5 4-5 16" + variants={variants.slash} + initial="initial" + animate={controls} + /> + </motion.svg> + ); +} + +function CodeXml(props: CodeXmlProps) { + return <IconWrapper icon={IconComponent} {...props} />; +} + +export { + animations, + CodeXml, + CodeXml as CodeXmlIcon, + type CodeXmlProps, + type CodeXmlProps as CodeXmlIconProps, +}; diff --git a/nt-fe/components/sidebar.tsx b/nt-fe/components/sidebar.tsx index 469586692..3c1682cca 100644 --- a/nt-fe/components/sidebar.tsx +++ b/nt-fe/components/sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChevronDown } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useNextStep } from "nextstepjs"; @@ -12,6 +13,9 @@ import { PAGE_TOUR_SELECTORS, useGuestSaveTour, } from "@/features/onboarding/steps/page-tours"; +import { useCustomRequestsEnabled } from "@/features/proposal-templates/hooks/use-custom-requests-enabled"; +import { useProposalTemplates } from "@/features/proposal-templates/hooks/use-proposal-templates"; +import { manifestIdOf } from "@/features/proposal-templates/manifest"; import { useProposals } from "@/hooks/use-proposals"; import { useSubscription } from "@/hooks/use-subscription"; import { useTreasury } from "@/hooks/use-treasury"; @@ -23,6 +27,7 @@ import { ArrowUpDown } from "./animate-ui/icons/arrow-up-down"; import { Bookmark } from "./animate-ui/icons/bookmark"; import { ChartColumn } from "./animate-ui/icons/chart-column"; import { ChartNoAxesCombined } from "./animate-ui/icons/chart-no-axes-combined"; +import { CodeXml } from "./animate-ui/icons/code-xml"; import { ContactRound } from "./animate-ui/icons/contact-round"; import { CreditCard } from "./animate-ui/icons/credit-card"; import { AnimateIcon, type IconProps } from "./animate-ui/icons/icon"; @@ -84,7 +89,9 @@ function NavLink({ <div className="flex w-full min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-3"> <Icon className="size-5 shrink-0" /> - {showLabels && <span>{label}</span>} + {showLabels && ( + <span className="truncate">{label}</span> + )} </div> {showLabels && (showBadge || endAdornment) && ( <div className="ml-auto flex shrink-0 items-center gap-2"> @@ -174,6 +181,7 @@ export function Sidebar({ onClose }: SidebarProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const [hasInitialized, setHasInitialized] = useState(false); const [supportModalOpen, setSupportModalOpen] = useState(false); + const [templatesExpanded, setTemplatesExpanded] = useState(true); const { accountId } = useNear(); const tNav = useTranslations("nav"); const tPages = useTranslations("pages"); @@ -193,10 +201,21 @@ export function Sidebar({ onClose }: SidebarProps) { }), }); const { data: subscription } = useSubscription(treasuryId); + const { data: proposalTemplates } = useProposalTemplates(); + const { data: customRequestsEnabled } = useCustomRequestsEnabled(); const { isMobile, mounted, isSidebarOpen: isOpen } = useResponsiveSidebar(); const isReduced = !isMobile && !isOpen; + const showLabels = isMobile ? isOpen : !isReduced; + // Only pinned (and enabled, slug-resolvable) templates show under the sidebar chevron; the rest + // live on the Request Templates index. Pinning is toggled from that page's ⋮ menu. + const pinnedTemplates = (proposalTemplates ?? []).filter( + (template) => + template.enabled && + template.pinned && + manifestIdOf(template.manifest), + ); const saveTreasuryMutation = useSaveTreasuryMutation(accountId, treasuryId); useGuestSaveTour(accountId ?? undefined, isSaved ?? false); @@ -322,7 +341,6 @@ export function Sidebar({ onClose }: SidebarProps) { const showBadge = link.path === "requests" && (proposals?.total ?? 0) > 0; - const showLabels = isMobile ? isOpen : !isReduced; return ( <NavLink @@ -345,6 +363,108 @@ export function Sidebar({ onClose }: SidebarProps) { /> ); })} + + {customRequestsEnabled && ( + <div className="flex flex-col gap-1"> + {/* Header is two targets: the label navigates to the index, the chevron + (only when something is pinned) toggles the pinned list — so they + can't be nested buttons. */} + <div + className={cn( + "flex items-center rounded-md transition-colors", + pathname === + `/${treasuryId}/custom-templates` + ? "bg-accent text-accent-foreground" + : "text-muted-foreground hover:bg-accent hover:text-accent-foreground", + )} + > + <AnimateIcon animateOnHover="default" asChild> + <Button + id="request-templates-nav" + variant="link" + // Collapsed sidebar is icon-only, so restore the hover label + // there (and keep the tour selector id on this element). + tooltipContent={ + showLabels + ? undefined + : "Request Templates" + } + side="right" + onClick={() => { + router.push( + `/${treasuryId}/custom-templates`, + ); + if (isMobile) onClose(); + }} + className={cn( + // `justify-start` overrides the Button's base + // `justify-center`, which would otherwise centre the icon + // in the flex-1 width and shift it right when no chevron. + "flex min-w-0 flex-1 items-center justify-start gap-3 py-[5.5px] font-medium text-inherit text-sm hover:text-inherit", + showLabels + ? "px-3" + : "justify-center px-3", + )} + > + <CodeXml className="size-5 shrink-0" /> + {showLabels && ( + <span className="truncate"> + Request Templates + </span> + )} + </Button> + </AnimateIcon> + {showLabels && pinnedTemplates.length > 0 && ( + <button + type="button" + aria-label={ + templatesExpanded + ? "Collapse pinned templates" + : "Expand pinned templates" + } + aria-expanded={templatesExpanded} + onClick={() => + setTemplatesExpanded( + (value) => !value, + ) + } + className="shrink-0 cursor-pointer px-2 py-[5.5px]" + > + <ChevronDown + className={cn( + "size-4 transition-transform", + !templatesExpanded && + "-rotate-90", + )} + /> + </button> + )} + </div> + {showLabels && templatesExpanded && ( + // Indent so a child's icon lines up under the parent's *text*: + // header text starts at 44px (px-3 + 20px icon + gap-3); the child's + // own NavLink adds px-3 (12px), so the wrapper supplies the other 32. + <div className="flex flex-col gap-1 pl-8"> + {pinnedTemplates.map((template) => { + const href = `/${treasuryId}/custom-templates/${manifestIdOf(template.manifest)}`; + return ( + <NavLink + key={template.id} + isActive={pathname === href} + icon={Bookmark} + label={template.name} + showLabels={showLabels} + onClick={() => { + router.push(href); + if (isMobile) onClose(); + }} + /> + ); + })} + </div> + )} + </div> + )} </nav> <div className="hidden lg:flex flex-col w-full justify-center items-center gap-2"> diff --git a/nt-fe/e2e/custom-templates.spec.ts b/nt-fe/e2e/custom-templates.spec.ts new file mode 100644 index 000000000..88d255f9e --- /dev/null +++ b/nt-fe/e2e/custom-templates.spec.ts @@ -0,0 +1,397 @@ +import { expect, type Page, type Route, test } from "@playwright/test"; +import { + maybeFulfillMockWalletRequest, + seedMockWalletAccount, +} from "./helpers/mock-wallet"; + +/** + * E2E for the Custom Request Templates feature (authoring + fill + pin). + * + * Mirrors requests-page.spec.ts: all client-side API calls are route-mocked, while the server + * component layout's `getTreasuryConfig` resolves against the real sandbox backend — so we reuse the + * same proven treasury id that already loads in CI rather than invent one the sandbox may not seed. + * + * Scope is the pure client-side flows (validation gating, code-mode errors, the pin PUT, and the + * fill form's required-field gate). The successful submit (sign → relay → form reset) needs a + * sandbox account seeded to file `add_proposal`; it is left as a `test.fixme` below until that + * seeding is verified, so this suite never lands a flaky red. + */ + +const TREASURY_ID = "requests-e2e-test.sputnik-dao.near"; +const ACCOUNT_ID = "test.near"; + +const TREASURY_POLICY = { + roles: [ + { + name: "council", + kind: { Group: [ACCOUNT_ID] }, + permissions: [ + "*:AddProposal", + "*:VoteApprove", + "*:VoteReject", + "*:VoteRemove", + "*:ChangePolicy", + ], + vote_policy: {}, + }, + ], + default_vote_policy: { + weight_kind: "RoleWeight", + quorum: "0", + threshold: [1, 2], + }, + proposal_bond: "100000000000000000000000", + proposal_period: "604800000000000", + bounty_bond: "100000000000000000000000", + bounty_forgiveness_period: "604800000000000", +}; + +const EMPTY_PROPOSALS = { page: 0, page_size: 15, total: 0, proposals: [] }; + +/** Full subscription shape — the treasury layout reads `planConfig.limits`, so a stub crashes it. */ +const SUBSCRIPTION = { + accountId: TREASURY_ID, + planType: "free", + planConfig: { + planType: "free", + name: "Free", + description: "Free plan", + limits: { + monthlyVolumeLimitCents: null, + overageRateBps: 0, + exchangeFeeBps: 0, + monthlyExportCredits: null, + trialExportCredits: 100, + monthlyBatchPaymentCredits: null, + trialBatchPaymentCredits: 50, + gasCoveredTransactions: null, + historyLookupMonths: 3, + }, + pricing: { monthlyPriceCents: null, yearlyPriceCents: null }, + }, + exportCredits: 100, + batchPaymentCredits: 50, + gasCoveredTransactions: 100, + creditsResetAt: "2026-05-06T00:00:00Z", + monthlyUsedVolumeCents: 0, +}; + +/** A representative manifest with one required text field — drives the fill + authoring tests. */ +const SET_GREETING_MANIFEST = { + version: 1, + id: "set-greeting", + title: "Set Greeting", + description: "Update the greeting shown on a guest-book contract.", + binding: { + receiver_id: "guestbook.near", + method_name: "set_greeting", + deposit: "0", + gas: "30000000000000", + }, + fields: [ + { + name: "greeting", + label: "Greeting", + type: "text", + required: true, + help: "The new message", + }, + ], + args: { greeting: "{{greeting}}" }, + summary: "Set greeting to {{greeting}}", +}; + +/** The JSON text an author pastes into Code mode (same shape as the manifest above). */ +const VALID_MANIFEST_TEXT = JSON.stringify(SET_GREETING_MANIFEST, null, 2); + +function template( + overrides: Partial<Record<string, unknown>> = {}, +): Record<string, unknown> { + return { + id: "11111111-1111-1111-1111-111111111111", + daoId: TREASURY_ID, + name: "Set Greeting", + description: "Update the greeting shown on a guest-book contract.", + manifest: SET_GREETING_MANIFEST, + enabled: true, + pinned: false, + createdBy: ACCOUNT_ID, + createdAt: "2026-06-01T00:00:00Z", + updatedAt: "2026-06-01T00:00:00Z", + ...overrides, + }; +} + +test.use({ locale: "en-US" }); + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +/** + * Mock every client-side call for a signed-in council member who owns the treasury. `GET + * proposal-templates` returns `templates`; non-GET mutations on that endpoint fall through so a + * per-test route (registered later, higher priority) can intercept and assert them. + */ +async function setupMocks(page: Page, templates: Record<string, unknown>[]) { + await seedMockWalletAccount(page, ACCOUNT_ID, "init"); + + await page.route("**/*", async (route) => { + if (await maybeFulfillMockWalletRequest(route)) { + return; + } + const request = route.request(); + const url = request.url(); + const method = request.method(); + + if (url.includes("/auth/me")) { + return json(route, { accountId: ACCOUNT_ID, termsAccepted: true }); + } + if (url.includes("/treasury/creation-status")) { + return json(route, { creationAvailable: true }); + } + if (url.includes("/user/treasuries")) { + return json(route, [ + { + daoId: TREASURY_ID, + config: { name: "Custom Templates E2E", metadata: {} }, + isMember: true, + isSaved: true, + isHidden: false, + }, + ]); + } + if (url.includes("/treasury/policy")) { + return json(route, TREASURY_POLICY); + } + if (url.includes("/api/subscription/")) { + return json(route, SUBSCRIPTION); + } + if (url.includes("/user/assets")) { + return json(route, []); + } + if (url.includes("/proposals/")) { + return json(route, EMPTY_PROPOSALS); + } + // The Custom Requests feature gate (sidebar visibility) — enabled. + if (url.endsWith("/custom-requests") && method === "GET") { + return json(route, { enabled: true }); + } + // Template list. Mutations (POST/PUT/DELETE) are owned by per-test routes. + if (url.includes("/proposal-templates") && method === "GET") { + return json(route, templates); + } + + return route.continue(); + }); +} + +test.describe("Custom Templates — authoring", () => { + test("submit gating: disabled until a name and a valid manifest are present", async ({ + page, + }) => { + await setupMocks(page, []); + await page.goto(`/${TREASURY_ID}/custom-templates/create`); + + await expect( + page.getByRole("heading", { name: "New Template" }), + ).toBeVisible({ timeout: 15000 }); + + const submit = page.getByRole("button", { name: "Create Template" }); + // Empty draft is an invalid manifest → disabled out of the gate. + await expect(submit).toBeDisabled(); + + // Author in Code mode (one textarea drives the same validator as Visual). + await page.getByRole("tab", { name: "Code" }).click(); + const code = page.locator("textarea"); + await code.fill(VALID_MANIFEST_TEXT); + + // Valid manifest, but the name is still empty → still gated. + await expect(submit).toBeDisabled(); + + // Name is addressed by its aria-label — its placeholder "Set Greeting" collides with the + // manifest textarea's example and the Visual builder's Title field. + await page + .getByRole("textbox", { name: "Name", exact: true }) + .fill("My Template"); + await expect(submit).toBeEnabled(); + }); + + test("touched-gating: 'Name is required' shows only after the name is blurred", async ({ + page, + }) => { + await setupMocks(page, []); + await page.goto(`/${TREASURY_ID}/custom-templates/create`); + + // Name is addressed by its aria-label (see submit-gating for the placeholder collision). + const name = page.getByRole("textbox", { name: "Name", exact: true }); + await expect(name).toBeVisible({ timeout: 15000 }); + + // Untouched → no error yet (editing the builder must not light it up). + await expect(page.getByText("Name is required")).not.toBeVisible(); + + // Focus then blur without typing → the field is touched and the error appears. + await name.focus(); + await name.blur(); + await expect(page.getByText("Name is required")).toBeVisible(); + }); + + test("code-mode section errors: invalid JSON surfaces errors and blocks submit", async ({ + page, + }) => { + await setupMocks(page, []); + await page.goto(`/${TREASURY_ID}/custom-templates/create`); + + await page.getByRole("tab", { name: "Code" }).click(); + const code = page.locator("textarea"); + // Malformed JSON — only shows once the textarea is touched (typing sets that). + await code.fill('{ "version": 1, '); + + const submit = page.getByRole("button", { name: "Create Template" }); + await expect(submit).toBeDisabled(); + // The error list under the editor renders at least one item. + await expect( + page.locator("ul.text-destructive li").first(), + ).toBeVisible(); + }); + + test("create happy path: POST fires and redirects to the template's fill page", async ({ + page, + }) => { + await setupMocks(page, [template()]); + + let created: Record<string, unknown> | null = null; + await page.route("**/proposal-templates", async (route) => { + if (route.request().method() !== "POST") { + return route.fallback(); + } + created = route.request().postDataJSON(); + return json(route, template(), 201); + }); + + await page.goto(`/${TREASURY_ID}/custom-templates/create`); + await page.getByRole("tab", { name: "Code" }).click(); + await page.locator("textarea").fill(VALID_MANIFEST_TEXT); + await page + .getByRole("textbox", { name: "Name", exact: true }) + .fill("Set Greeting"); + + await page.getByRole("button", { name: "Create Template" }).click(); + + await page.waitForURL(/custom-templates\/set-greeting$/, { + timeout: 15000, + }); + expect(created).toMatchObject({ name: "Set Greeting" }); + }); + + test("visual mode: a required field reds only after it is blurred", async ({ + page, + }) => { + await setupMocks(page, []); + await page.goto(`/${TREASURY_ID}/custom-templates/create`); + + // The editor opens in the Visual builder. The Receiver field is required, so the empty draft + // is already invalid — but the error must stay hidden until that input is touched. The unit + // tests pin the initial-render half; this is the after-blur half they defer to e2e. + const receiver = page.getByPlaceholder("guestbook.near"); + await expect(receiver).toBeVisible({ timeout: 15000 }); + await expect(receiver).not.toHaveAttribute("aria-invalid", "true"); + + await receiver.focus(); + await receiver.blur(); + await expect(receiver).toHaveAttribute("aria-invalid", "true"); + }); +}); + +test.describe("Custom Templates — pin", () => { + test("'Pin to the Sidebar' fires a PUT and the menu flips to 'Unpin Template'", async ({ + page, + }) => { + // Mutable so the post-mutation refetch (the hook invalidates the list query) reflects the + // new pinned state — that invalidation→refetch→UI contract is what this asserts. + const templates = [template({ pinned: false })]; + await setupMocks(page, templates); + + let putBody: Record<string, unknown> | null = null; + await page.route("**/proposal-templates/*", async (route) => { + if (route.request().method() !== "PUT") { + return route.fallback(); + } + putBody = route.request().postDataJSON(); + templates[0] = { ...templates[0], ...putBody }; + return json(route, templates[0]); + }); + + await page.goto(`/${TREASURY_ID}/custom-templates`); + await expect(page.getByText("Set Greeting")).toBeVisible({ + timeout: 15000, + }); + + await page.getByRole("button", { name: "Template actions" }).click(); + await page + .getByRole("menuitem", { name: /pin to the sidebar/i }) + .click(); + + // The request carried the flag... + await expect.poll(() => putBody).toMatchObject({ pinned: true }); + // ...and after the list refetches, re-opening the row menu shows the flipped label. + await page.getByRole("button", { name: "Template actions" }).click(); + await expect( + page.getByRole("menuitem", { name: /unpin template/i }), + ).toBeVisible(); + }); +}); + +test.describe("Custom Templates — fill", () => { + test("renders the manifest fields and the File Proposal button", async ({ + page, + }) => { + await setupMocks(page, [template()]); + await page.goto(`/${TREASURY_ID}/custom-templates/set-greeting`); + + await expect( + page.getByRole("heading", { name: "Set Greeting" }), + ).toBeVisible({ timeout: 15000 }); + // Exact — "Greeting" (the field label) is a substring of the "Set Greeting" heading. + await expect(page.getByText("Greeting", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "File Proposal" }), + ).toBeVisible(); + }); + + test("required-field gate: submitting empty shows an error and fires no relay", async ({ + page, + }) => { + await setupMocks(page, [template()]); + + // Spy on the gasless relay — it must never be hit when validation fails. + let relayHit = false; + await page.route("**/relay/delegate-action", async (route) => { + relayHit = true; + return json(route, { success: true }); + }); + + await page.goto(`/${TREASURY_ID}/custom-templates/set-greeting`); + const submit = page.getByRole("button", { name: "File Proposal" }); + await expect(submit).toBeVisible({ timeout: 15000 }); + + await submit.click(); + + await expect(page.getByText("Greeting is required")).toBeVisible(); + expect(relayHit).toBe(false); + }); + + /** + * Successful fill → sign → relay → form reset. Needs the sandbox seeded with the signing account + * (test.near) holding the mock executor's access key and council membership so `add_proposal` + * relays. Enable once that seeding is confirmed against the sandbox image. + */ + test.fixme( + "successful submit files the proposal and resets the form", + async () => {}, + ); +}); diff --git a/nt-fe/features/onboarding/steps/index.tsx b/nt-fe/features/onboarding/steps/index.tsx index f32abea79..1bce2a82f 100644 --- a/nt-fe/features/onboarding/steps/index.tsx +++ b/nt-fe/features/onboarding/steps/index.tsx @@ -1,11 +1,12 @@ import { DASHBOARD_TOUR, INFO_BOX_TOUR } from "./dashboard"; import { - PAYMENTS_BULK_TOUR, - PAYMENTS_PENDING_TOUR, EXCHANGE_SETTINGS_TOUR, - MEMBERS_PENDING_TOUR, GUEST_SAVE_TOUR, + MEMBERS_PENDING_TOUR, NEW_FEATURE_TOUR, + PAYMENTS_BULK_TOUR, + PAYMENTS_PENDING_TOUR, + REQUEST_TEMPLATES_TOUR, } from "./page-tours"; export const TOURS = [ @@ -17,4 +18,5 @@ export const TOURS = [ MEMBERS_PENDING_TOUR, GUEST_SAVE_TOUR, NEW_FEATURE_TOUR, + REQUEST_TEMPLATES_TOUR, ]; diff --git a/nt-fe/features/onboarding/steps/page-tours.tsx b/nt-fe/features/onboarding/steps/page-tours.tsx index 30d3d1e9f..ef9d49ce9 100644 --- a/nt-fe/features/onboarding/steps/page-tours.tsx +++ b/nt-fe/features/onboarding/steps/page-tours.tsx @@ -1,11 +1,9 @@ "use client"; -import { useNextStep } from "nextstepjs"; -import type { Tour } from "nextstepjs"; import { useTranslations } from "next-intl"; -import { useEffect, useCallback, useRef } from "react"; -import { useTreasury } from "@/hooks/use-treasury"; -import { useResponsiveSidebar } from "@/stores/sidebar-store"; +import type { Tour } from "nextstepjs"; +import { useNextStep } from "nextstepjs"; +import { useCallback, useEffect, useRef } from "react"; import { EARN_ANNOUNCEMENT_TOUR_NAME, FEATURE_DEFINITIONS, @@ -13,6 +11,8 @@ import { useFeatureAnnouncementQueueSlot, useFeatureAnnouncementsUnlocked, } from "@/features/onboarding/feature-announcement-queue"; +import { useTreasury } from "@/hooks/use-treasury"; +import { useResponsiveSidebar } from "@/stores/sidebar-store"; type PageTourKey = | "paymentsBulk" @@ -40,8 +40,13 @@ export const PAGE_TOUR_NAMES = { MEMBERS_PENDING: "members-pending", GUEST_SAVE: "guest-save", EARN_ANNOUNCEMENT: EARN_ANNOUNCEMENT_TOUR_NAME, + REQUEST_TEMPLATES: "request-templates", } as const; +// Fired right after a DAO enables Custom Requests in Settings → Developer, to point at the +// newly revealed sidebar section. +export const REQUEST_TEMPLATES_TOUR_NAME = PAGE_TOUR_NAMES.REQUEST_TEMPLATES; + // Local storage keys export const PAGE_TOUR_STORAGE_KEYS = { PAYMENTS_BULK_SHOWN: "payments-bulk-tour-shown", @@ -49,6 +54,7 @@ export const PAGE_TOUR_STORAGE_KEYS = { EXCHANGE_SETTINGS_SHOWN: "exchange-settings-tour-shown", MEMBERS_PENDING_SHOWN: "members-pending-tour-shown", GUEST_SAVE_SHOWN: "guest-save-tour-shown", + REQUEST_TEMPLATES_SHOWN: "request-templates-tour-shown", } as const; // Selector IDs @@ -59,6 +65,7 @@ export const PAGE_TOUR_SELECTORS = { MEMBERS_PENDING_BTN: "#members-pending-btn", GUEST_BADGE: "#guest-badge", GUEST_SAVE_BTN: "#guest-save-btn", + REQUEST_TEMPLATES_NAV: "#request-templates-nav", } as const; export const EARN_ANNOUNCEMENT = { @@ -176,6 +183,23 @@ export const NEW_FEATURE_TOUR: Tour = { ], }; +export const REQUEST_TEMPLATES_TOUR: Tour = { + tour: PAGE_TOUR_NAMES.REQUEST_TEMPLATES, + steps: [ + { + ...defaultStepProps, + content: ( + <> + Custom Requests is ready. Open it here to build your first + request type. + </> + ), + selector: PAGE_TOUR_SELECTORS.REQUEST_TEMPLATES_NAV, + side: "right", + }, + ], +}; + function getVersionedStorageKey(storageKey: string, version = 1) { return `${storageKey}:v${version}`; } diff --git a/nt-fe/features/proposal-templates/api.ts b/nt-fe/features/proposal-templates/api.ts new file mode 100644 index 000000000..1557add98 --- /dev/null +++ b/nt-fe/features/proposal-templates/api.ts @@ -0,0 +1,117 @@ +import axios, { isAxiosError } from "axios"; +import type { ProposalTemplate } from "./types"; + +const BACKEND_API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_API_BASE}/api`; + +/** List a DAO's proposal templates (`GET /api/treasury/{dao_id}/proposal-templates`). */ +export async function getProposalTemplates( + daoId: string, +): Promise<ProposalTemplate[]> { + const response = await axios.get<ProposalTemplate[]>( + `${BACKEND_API_BASE}/treasury/${encodeURIComponent(daoId)}/proposal-templates`, + { withCredentials: true }, + ); + return response.data; +} + +/** Fields the create endpoint accepts; `manifest` is the raw authored JSON. */ +export interface CreateProposalTemplateInput { + name: string; + description?: string | null; + manifest: unknown; + enabled?: boolean; +} + +/** + * Create a template (`POST /api/treasury/{dao_id}/proposal-templates`). Authoring is gated on the + * DAO's `ChangePolicy` permission — a member who lacks it gets a 403, surfaced to the author. + */ +export async function createProposalTemplate( + daoId: string, + input: CreateProposalTemplateInput, +): Promise<ProposalTemplate> { + const response = await axios.post<ProposalTemplate>( + `${BACKEND_API_BASE}/treasury/${encodeURIComponent(daoId)}/proposal-templates`, + input, + { withCredentials: true }, + ); + return response.data; +} + +/** Fields the update endpoint accepts; omitted fields are left unchanged (COALESCE on the server). */ +export interface UpdateProposalTemplateInput { + name?: string; + description?: string | null; + manifest?: unknown; + enabled?: boolean; + pinned?: boolean; +} + +/** + * Update a template (`PUT /api/treasury/{dao_id}/proposal-templates/{id}`). ChangePolicy-gated. + * `id` is the template's UUID, not its slug. + */ +export async function updateProposalTemplate( + daoId: string, + id: string, + input: UpdateProposalTemplateInput, +): Promise<ProposalTemplate> { + const response = await axios.put<ProposalTemplate>( + `${BACKEND_API_BASE}/treasury/${encodeURIComponent(daoId)}/proposal-templates/${id}`, + input, + { withCredentials: true }, + ); + return response.data; +} + +/** Delete a template (`DELETE /api/treasury/{dao_id}/proposal-templates/{id}`). ChangePolicy-gated. */ +export async function deleteProposalTemplate( + daoId: string, + id: string, +): Promise<void> { + await axios.delete( + `${BACKEND_API_BASE}/treasury/${encodeURIComponent(daoId)}/proposal-templates/${id}`, + { withCredentials: true }, + ); +} + +/** + * Whether the Custom Requests feature is enabled for a treasury + * (`GET /api/treasury/{dao_id}/custom-requests`). Membership-gated; defaults to false. + */ +export async function getCustomRequestsEnabled( + daoId: string, +): Promise<boolean> { + const response = await axios.get<{ enabled: boolean }>( + `${BACKEND_API_BASE}/treasury/${encodeURIComponent(daoId)}/custom-requests`, + { withCredentials: true }, + ); + return response.data.enabled; +} + +/** + * Enable or disable Custom Requests for a treasury + * (`PUT /api/treasury/{dao_id}/custom-requests`). Gated on the DAO's `ChangePolicy` permission. + */ +export async function setCustomRequestsEnabled( + daoId: string, + enabled: boolean, +): Promise<boolean> { + const response = await axios.put<{ enabled: boolean }>( + `${BACKEND_API_BASE}/treasury/${encodeURIComponent(daoId)}/custom-requests`, + { enabled }, + { withCredentials: true }, + ); + return response.data.enabled; +} + +/** Extract the backend's plain-string error body (403/404/409) from an axios error, for toasts. */ +export function apiErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const data = error.response?.data; + if (typeof data === "string" && data.length > 0) { + return data; + } + } + return error instanceof Error ? error.message : fallback; +} diff --git a/nt-fe/features/proposal-templates/args-node.test.ts b/nt-fe/features/proposal-templates/args-node.test.ts new file mode 100644 index 000000000..9a13f00e1 --- /dev/null +++ b/nt-fe/features/proposal-templates/args-node.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "bun:test"; +import { + changeType, + duplicateArgKeys, + dynamicArgNames, + emptyNodeOf, + isDynamicArg, + resolveDisplayType, + valueTypeOf, +} from "./args-node"; +import type { ArgEntry } from "./draft"; + +describe("valueTypeOf", () => { + it("infers `field` for a lone placeholder, `text` otherwise", () => { + expect(valueTypeOf({ kind: "string", value: "{{amount}}" })).toBe( + "field", + ); + expect(valueTypeOf({ kind: "string", value: "hi {{amount}}" })).toBe( + "text", + ); + expect(valueTypeOf({ kind: "string", value: "static" })).toBe("text"); + }); + + it("maps non-string kinds directly", () => { + expect(valueTypeOf({ kind: "object", entries: [] })).toBe("object"); + expect(valueTypeOf({ kind: "array", items: [] })).toBe("array"); + expect(valueTypeOf({ kind: "number", value: 1 })).toBe("number"); + expect(valueTypeOf({ kind: "boolean", value: true })).toBe("boolean"); + expect(valueTypeOf({ kind: "null" })).toBe("null"); + }); +}); + +describe("emptyNodeOf", () => { + it("seeds a `field` node with the first declared field", () => { + expect(emptyNodeOf("field", ["amount", "token"])).toEqual({ + kind: "string", + value: "{{amount}}", + }); + }); + + it("seeds an empty string for `field` when nothing is declared", () => { + expect(emptyNodeOf("field", [])).toEqual({ kind: "string", value: "" }); + }); + + it("builds empty containers and scalars", () => { + expect(emptyNodeOf("object", [])).toEqual({ + kind: "object", + entries: [], + }); + expect(emptyNodeOf("array", [])).toEqual({ kind: "array", items: [] }); + expect(emptyNodeOf("number", [])).toEqual({ kind: "number", value: 0 }); + expect(emptyNodeOf("null", [])).toEqual({ kind: "null" }); + }); +}); + +describe("changeType", () => { + it("keeps the string as-is when switching to `text`", () => { + const node = { kind: "string", value: "hi {{x}}" } as const; + expect(changeType(node, "text", [])).toBe(node); + }); + + it("keeps a lone placeholder when switching `text` → `field`", () => { + const node = { kind: "string", value: "{{amount}}" } as const; + expect(changeType(node, "field", ["amount"])).toBe(node); + }); + + it("seeds a field when switching free text → `field`", () => { + expect( + changeType({ kind: "string", value: "hello" }, "field", ["amount"]), + ).toEqual({ kind: "string", value: "{{amount}}" }); + }); + + it("resets to an empty node when changing across kinds", () => { + expect(changeType({ kind: "number", value: 5 }, "boolean", [])).toEqual( + { kind: "boolean", value: false }, + ); + }); + + it("leaves a lone placeholder unchanged on field → text (display override handles it)", () => { + const node = { kind: "string", value: "{{amount}}" } as const; + expect(changeType(node, "text", [])).toBe(node); + }); + + it("resets a string to an empty container when switching to object or array", () => { + const node = { kind: "string", value: "{{amount}}" } as const; + expect(changeType(node, "object", [])).toEqual({ + kind: "object", + entries: [], + }); + expect(changeType(node, "array", [])).toEqual({ + kind: "array", + items: [], + }); + }); +}); + +describe("resolveDisplayType", () => { + it("returns the inferred type when there's no explicit pick", () => { + expect( + resolveDisplayType({ kind: "string", value: "{{x}}" }, null), + ).toBe("field"); + }); + + it("honors an explicit text pick on a lone placeholder (no snap-back)", () => { + expect( + resolveDisplayType({ kind: "string", value: "{{x}}" }, "text"), + ).toBe("text"); + }); + + it("honors an explicit field pick on free text", () => { + expect( + resolveDisplayType({ kind: "string", value: "hi" }, "field"), + ).toBe("field"); + }); + + it("ignores an explicit pick that no longer matches the node's kind", () => { + expect(resolveDisplayType({ kind: "number", value: 1 }, "text")).toBe( + "number", + ); + }); +}); + +describe("isDynamicArg / dynamicArgNames", () => { + it("flags an entry whose value is exactly {{key}}", () => { + expect( + isDynamicArg({ + id: "1", + key: "amount", + value: { kind: "string", value: "{{amount}}" }, + }), + ).toBe(true); + }); + + it("rejects a mismatched placeholder, composed text, blank key, or non-string", () => { + expect( + isDynamicArg({ + id: "1", + key: "amount", + value: { kind: "string", value: "{{other}}" }, + }), + ).toBe(false); + expect( + isDynamicArg({ + id: "1", + key: "amount", + value: { kind: "string", value: "x {{amount}}" }, + }), + ).toBe(false); + expect( + isDynamicArg({ + id: "1", + key: "", + value: { kind: "string", value: "{{}}" }, + }), + ).toBe(false); + expect( + isDynamicArg({ + id: "1", + key: "amount", + value: { kind: "number", value: 1 }, + }), + ).toBe(false); + }); + + it("collects the keys of dynamic arguments only", () => { + const entries: ArgEntry[] = [ + { + id: "1", + key: "amount", + value: { kind: "string", value: "{{amount}}" }, + }, + { + id: "2", + key: "owner", + value: { kind: "string", value: "vault.near" }, + }, + ]; + expect([...dynamicArgNames(entries)]).toEqual(["amount"]); + }); +}); + +describe("duplicateArgKeys", () => { + it("flags a top-level repeat with an empty path", () => { + const entries: ArgEntry[] = [ + { id: "1", key: "a", value: { kind: "string", value: "x" } }, + { id: "2", key: "b", value: { kind: "string", value: "y" } }, + { id: "3", key: "a", value: { kind: "string", value: "z" } }, + ]; + expect(duplicateArgKeys(entries)).toEqual([{ path: "", key: "a" }]); + }); + + it("reports a key repeated 3+ times only once", () => { + const entries: ArgEntry[] = [ + { id: "1", key: "a", value: { kind: "string", value: "1" } }, + { id: "2", key: "a", value: { kind: "string", value: "2" } }, + { id: "3", key: "a", value: { kind: "string", value: "3" } }, + ]; + expect(duplicateArgKeys(entries)).toEqual([{ path: "", key: "a" }]); + }); + + it("ignores blank keys (mid-edit) and all-unique keys", () => { + const entries: ArgEntry[] = [ + { id: "1", key: "", value: { kind: "string", value: "" } }, + { id: "2", key: "", value: { kind: "string", value: "" } }, + { id: "3", key: "a", value: { kind: "string", value: "z" } }, + ]; + expect(duplicateArgKeys(entries)).toEqual([]); + }); + + it("tags a duplicate nested in an object with its path", () => { + const entries: ArgEntry[] = [ + { + id: "1", + key: "outer", + value: { + kind: "object", + entries: [ + { + id: "2", + key: "dup", + value: { kind: "string", value: "1" }, + }, + { + id: "3", + key: "dup", + value: { kind: "string", value: "2" }, + }, + ], + }, + }, + ]; + expect(duplicateArgKeys(entries)).toEqual([ + { path: "outer", key: "dup" }, + ]); + }); + + it("includes the array index in the path of a duplicate inside an array item", () => { + const entries: ArgEntry[] = [ + { + id: "1", + key: "list", + value: { + kind: "array", + items: [ + { + id: "i0", + value: { + kind: "object", + entries: [ + { + id: "2", + key: "k", + value: { kind: "string", value: "1" }, + }, + { + id: "3", + key: "k", + value: { kind: "string", value: "2" }, + }, + ], + }, + }, + ], + }, + }, + ]; + expect(duplicateArgKeys(entries)).toEqual([ + { path: "list.0", key: "k" }, + ]); + }); +}); diff --git a/nt-fe/features/proposal-templates/args-node.ts b/nt-fe/features/proposal-templates/args-node.ts new file mode 100644 index 000000000..df79843fa --- /dev/null +++ b/nt-fe/features/proposal-templates/args-node.ts @@ -0,0 +1,178 @@ +/** + * The args-tree editor's value-type layer over `ArgNode`. The model stores string leaves as plain + * strings (which may embed `{{field}}` placeholders); the editor presents a friendlier type choice + * where a string is either a single-field reference ("field") or free "text". These pure helpers + * map between the two and seed new nodes — kept out of the component so they're unit-testable. + */ +import type { ArgEntry, ArgNode } from "./draft"; + +export type ArgValueType = + | "text" + | "field" + | "number" + | "boolean" + | "null" + | "object" + | "array"; + +export const ARG_VALUE_TYPES: ArgValueType[] = [ + "text", + "field", + "number", + "boolean", + "null", + "object", + "array", +]; + +/** A string that is exactly one `{{field}}` placeholder (group 1 = the field name). */ +export const SINGLE_FIELD_RE = /^\{\{\s*([a-zA-Z0-9_]+)\s*\}\}$/; + +/** The editor value-type for a node — a string is "field" iff it's a lone placeholder. */ +export function valueTypeOf(node: ArgNode): ArgValueType { + switch (node.kind) { + case "object": + return "object"; + case "array": + return "array"; + case "number": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "string": + return SINGLE_FIELD_RE.test(node.value) ? "field" : "text"; + } +} + +/** A fresh node for a chosen type; a "field" node seeds the first declared field, if any. */ +export function emptyNodeOf(type: ArgValueType, fieldNames: string[]): ArgNode { + switch (type) { + case "object": + return { kind: "object", entries: [] }; + case "array": + return { kind: "array", items: [] }; + case "number": + return { kind: "number", value: 0 }; + case "boolean": + return { kind: "boolean", value: false }; + case "null": + return { kind: "null" }; + case "field": + return { + kind: "string", + value: fieldNames[0] ? `{{${fieldNames[0]}}}` : "", + }; + case "text": + return { kind: "string", value: "" }; + } +} + +/** Change a node's type, preserving the string content when staying within string kinds. */ +export function changeType( + node: ArgNode, + type: ArgValueType, + fieldNames: string[], +): ArgNode { + if (node.kind === "string") { + if (type === "text") { + return node; + } + if (type === "field") { + return SINGLE_FIELD_RE.test(node.value) + ? node + : emptyNodeOf("field", fieldNames); + } + } + return emptyNodeOf(type, fieldNames); +} + +/** + * The value-type the dropdown should show: the user's explicit pick when it's still compatible with + * the node, else the inferred type. A lone-placeholder string infers as "field", so picking "text" + * on it would otherwise snap back — both "text" and "field" are valid displays of a string's value, + * so an explicit string choice is honored until the type genuinely changes. + */ +export function resolveDisplayType( + node: ArgNode, + explicit: ArgValueType | null, +): ArgValueType { + const inferred = valueTypeOf(node); + if (!explicit) { + return inferred; + } + if ( + node.kind === "string" && + (explicit === "text" || explicit === "field") + ) { + return explicit; + } + return explicit === inferred ? explicit : inferred; +} + +/** A top-level argument that's a direct member input: its value is exactly `{{key}}`. */ +export function isDynamicArg(entry: ArgEntry): boolean { + return ( + entry.key !== "" && + entry.value.kind === "string" && + entry.value.value === `{{${entry.key}}}` + ); +} + +/** Keys of the arguments configured as direct member inputs (their field config is shown inline). */ +export function dynamicArgNames(entries: ArgEntry[]): Set<string> { + return new Set(entries.filter(isDynamicArg).map((entry) => entry.key)); +} + +/** A duplicated args key with the dotted path (relative to `args`) of its containing object. */ +export interface DuplicateArgKey { + /** Path to the object that holds the duplicate; `""` for the top-level args object. */ + path: string; + key: string; +} + +/** + * Keys that appear more than once within the same object level, anywhere in the args tree, each + * tagged with the path to its containing object so the error can point at the offender. The + * serializer is last-write-wins, so duplicate keys would silently drop on save; the builder surfaces + * these so the loss is visible. Blank keys (mid-edit) are not counted. + */ +export function duplicateArgKeys(entries: ArgEntry[]): DuplicateArgKey[] { + const dupes: DuplicateArgKey[] = []; + walkEntries(entries, "", dupes); + return dupes; +} + +function walkEntries( + entries: ArgEntry[], + path: string, + dupes: DuplicateArgKey[], +): void { + const seen = new Set<string>(); + const flagged = new Set<string>(); + for (const entry of entries) { + if ( + entry.key !== "" && + seen.has(entry.key) && + !flagged.has(entry.key) + ) { + // Report a given key once per object level, however many times it repeats. + dupes.push({ path, key: entry.key }); + flagged.add(entry.key); + } + seen.add(entry.key); + const childPath = path === "" ? entry.key : `${path}.${entry.key}`; + walkNode(entry.value, childPath, dupes); + } +} + +function walkNode(node: ArgNode, path: string, dupes: DuplicateArgKey[]): void { + if (node.kind === "object") { + walkEntries(node.entries, path, dupes); + } else if (node.kind === "array") { + node.items.forEach((item, index) => { + walkNode(item.value, `${path}.${index}`, dupes); + }); + } +} diff --git a/nt-fe/features/proposal-templates/build-proposal.test.ts b/nt-fe/features/proposal-templates/build-proposal.test.ts new file mode 100644 index 000000000..2af3646d9 --- /dev/null +++ b/nt-fe/features/proposal-templates/build-proposal.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "bun:test"; +import { base64ToJson } from "@/lib/utils"; +import { buildTemplateProposal, interpolateArgs } from "./build-proposal"; +import { + type Manifest, + manifestErrorMessages, + parseManifest, +} from "./manifest"; + +/** Parse a fixture through the real validator so the engine is only ever fed valid manifests. */ +function manifest(raw: unknown): Manifest { + const result = parseManifest(raw); + if (!result.success) { + throw new Error(manifestErrorMessages(result.error).join("; ")); + } + return result.data; +} + +// A guestbook post with a tip — a FunctionCall exercising static, direct, and composed args. +const rawGuestbook = { + version: 1, + id: "guestbook-tip", + title: "Guestbook Tip", + binding: { + receiver_id: "guestbook.near", + method_name: "add_message", + deposit: "1", + gas: "30000000000000", + }, + fields: [ + { name: "message", label: "Message", type: "text" }, + { name: "author", label: "Author", type: "account" }, + { name: "tip", label: "Tip (yocto)", type: "uint" }, + ], + args: { + app: "trezu", // static + text: "{{message}}", // direct + amount: "{{tip}}", // direct, u128 + meta: '{"by":"{{author}}"}', // composed: a field inside a JSON string + }, + summary: "Tip {{tip}} from {{author}}", +}; + +const guestbookValues = { + message: "gm", + author: "alice.near", + tip: "5", +}; + +describe("interpolateArgs", () => { + it("substitutes placeholders in nested strings, arrays, and objects", () => { + const out = interpolateArgs( + { a: "{{x}}", b: ["{{y}}", { c: "z={{x}}" }], n: 5 }, + { x: "X", y: "Y" }, + ); + expect(out).toEqual({ a: "X", b: ["Y", { c: "z=X" }], n: 5 }); + }); + + it("resolves a missing value to an empty string", () => { + expect(interpolateArgs("{{absent}}", {})).toBe(""); + }); + + it("stringifies number, boolean, and json values", () => { + expect( + interpolateArgs("{{n}}/{{b}}/{{j}}", { + n: 7, + b: true, + j: { k: 1 }, + }), + ).toBe('7/true/{"k":1}'); + }); + + it("collapses an escaped {{{{literal}}}} to {{literal}}", () => { + expect(interpolateArgs("{{{{keep}}}} {{x}}", { x: "X" })).toBe( + "{{keep}} X", + ); + }); + + it("returns an empty args object unchanged", () => { + expect(interpolateArgs({}, {})).toEqual({}); + }); + + it("tolerates whitespace inside a placeholder ({{ name }})", () => { + expect(interpolateArgs("{{ x }}", { x: "X" })).toBe("X"); + }); +}); + +describe("buildTemplateProposal", () => { + it("reproduces the add_message call exactly", () => { + const { kind } = buildTemplateProposal( + manifest(rawGuestbook), + guestbookValues, + ); + const fc = kind.FunctionCall; + + expect(fc.receiver_id).toBe("guestbook.near"); + expect(fc.actions).toHaveLength(1); + + const action = fc.actions[0]; + expect(action.method_name).toBe("add_message"); + expect(action.deposit).toBe("1"); + expect(action.gas).toBe("30000000000000"); + expect(base64ToJson(action.args)).toEqual({ + app: "trezu", + text: "gm", + amount: "5", + meta: '{"by":"alice.near"}', + }); + }); + + it("keeps a u128 amount as a digit string (never a JS number)", () => { + const big = "340282366920938463463374607431768211455"; // 2^128 - 1 + const { kind } = buildTemplateProposal(manifest(rawGuestbook), { + ...guestbookValues, + tip: big, + }); + expect(base64ToJson(kind.FunctionCall.actions[0].args).amount).toBe( + big, + ); + }); + + it("tags the description with [trezu-tmpl:<id>] and interpolates the summary", () => { + const { description } = buildTemplateProposal( + manifest(rawGuestbook), + guestbookValues, + ); + expect(description).toContain("[trezu-tmpl:guestbook-tip]"); + expect(description).toContain("Tip 5"); + }); + + it("falls back to the title when no summary is declared", () => { + const { description } = buildTemplateProposal( + manifest({ ...rawGuestbook, summary: undefined }), + guestbookValues, + ); + expect(description).toContain("Guestbook Tip"); + }); +}); diff --git a/nt-fe/features/proposal-templates/build-proposal.ts b/nt-fe/features/proposal-templates/build-proposal.ts new file mode 100644 index 000000000..c206e8061 --- /dev/null +++ b/nt-fe/features/proposal-templates/build-proposal.ts @@ -0,0 +1,103 @@ +/** + * Template engine for the custom-proposal framework: turn a validated manifest plus the values a + * member filled in the rendered form into the exact SputnikDAO `FunctionCall` proposal — `kind` + + * `description` — that `createProposal` (stores/near-store) submits. + * + * Pure and framework-free, so it unit-tests without React or a wallet, and it reuses the manifest + * module's `substitutePlaceholders` so the args it emits can never drift from what the validator + * checked. v1 substitution is string-based: every `{{field}}` resolves to a string (NEAR amounts + * stay digit strings, so u128 values never round-trip through a JS number). Typed JSON injection + * (a `bool`/`json` field landing as a raw value rather than a string) is a deliberate non-goal here + * — every v1 binding expresses its args as strings. + */ +import type { FunctionCallKind } from "@/lib/proposals-api"; +import { encodeToMarkdown, jsonToBase64 } from "@/lib/utils"; +import { type Manifest, substitutePlaceholders } from "./manifest"; + +/** Values a member filled, keyed by field `name`. */ +export type FieldValues = Record<string, unknown>; + +/** The on-chain proposal a template produces: a `FunctionCall` kind and its description. */ +export interface TemplateProposal { + kind: FunctionCallKind; + description: string; +} + +/** Coerce a field value into the string that replaces its `{{placeholder}}`. */ +function resolveValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + // A `json` field's object/array value injects its JSON text (e.g. a stringified `msg`). + return JSON.stringify(value); +} + +/** Interpolate `{{field}}` placeholders in a single string — the one path both args and summary use. */ +function interpolateString(text: string, values: FieldValues): string { + return substitutePlaceholders(text, (name) => resolveValue(values[name])); +} + +/** Recursively interpolate `{{field}}` placeholders through an args template using `values`. */ +export function interpolateArgs( + template: unknown, + values: FieldValues, +): unknown { + if (typeof template === "string") { + return interpolateString(template, values); + } + if (Array.isArray(template)) { + return template.map((item) => interpolateArgs(item, values)); + } + if (template !== null && typeof template === "object") { + const out: Record<string, unknown> = {}; + for (const [key, item] of Object.entries(template)) { + out[key] = interpolateArgs(item, values); + } + return out; + } + return template; +} + +/** + * Build the `{ kind, description }` an authored `manifest` and a member's `values` produce. + * + * The description carries the `[trezu-tmpl:<id>]` tag so a created proposal is traceable back to + * the template that minted it (the manifest `id` is constrained to a tag-safe slug for exactly + * this). `deposit`/`gas` come verbatim from the manifest `binding` (digit strings, never numbers). + */ +export function buildTemplateProposal( + manifest: Manifest, + values: FieldValues, +): TemplateProposal { + const args = interpolateArgs(manifest.args, values); + const kind: FunctionCallKind = { + FunctionCall: { + receiver_id: manifest.binding.receiver_id, + actions: [ + { + method_name: manifest.binding.method_name, + args: jsonToBase64(args), + deposit: manifest.binding.deposit, + gas: manifest.binding.gas, + }, + ], + }, + }; + + const summary = manifest.summary + ? interpolateString(manifest.summary, values) + : manifest.title; + const description = encodeToMarkdown({ + proposal_action: "custom", + template: `[trezu-tmpl:${manifest.id}]`, + summary, + }); + + return { kind, description }; +} diff --git a/nt-fe/features/proposal-templates/components/args-tree-editor.tsx b/nt-fe/features/proposal-templates/components/args-tree-editor.tsx new file mode 100644 index 000000000..96b9f8b8a --- /dev/null +++ b/nt-fe/features/proposal-templates/components/args-tree-editor.tsx @@ -0,0 +1,662 @@ +"use client"; + +/** + * Args-first editor: each argument is the unit. A top-level argument is **Static** (a fixed value — + * text/number/bool/null/object/array, `{{field}}` allowed in text) or **Dynamic** (a member input), + * in which case its value is `{{key}}` and the row expands to that input's config inline (the field + * named `key`). Renaming a dynamic key renames its field too, so args + fields update atomically via + * one `onChange({ args, fields })`. Nested object/array values are static-only here; a member input + * inside them is a composed `{{placeholder}}` configured under "Other inputs". + */ +import { Plus, Trash2 } from "lucide-react"; +import { Fragment } from "react"; +import { Button } from "@/components/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { + ARG_VALUE_TYPES, + type ArgValueType, + changeType, + emptyNodeOf, + isDynamicArg, + valueTypeOf, +} from "../args-node"; +import { + type ArgEntry, + type ArgItem, + type ArgNode, + type FieldDraft, + makeArgEntry, + makeArgItem, +} from "../draft"; +import { errorFor } from "../error-map"; +import { FieldConfigFields, Labeled, LabeledInput } from "./fields-builder"; + +/** Static value kinds — "field" is excluded (a direct member input is the Dynamic mode instead). */ +const STATIC_TYPES: ArgValueType[] = ARG_VALUE_TYPES.filter( + (type) => type !== "field", +); + +/** The static value-type to display for a node (a lone placeholder reads as plain text here). */ +function staticTypeOf(node: ArgNode): ArgValueType { + const inferred = valueTypeOf(node); + return inferred === "field" ? "text" : inferred; +} + +interface ArgsTreeEditorProps { + args: ArgEntry[]; + fields: FieldDraft[]; + fieldNames: string[]; + errors: string[]; + onChange: (next: { args: ArgEntry[]; fields: FieldDraft[] }) => void; +} + +/** Top-level args editor — entries carry the Static/Dynamic choice. */ +export function ArgsTreeEditor({ + args, + fields, + fieldNames, + errors, + onChange, +}: ArgsTreeEditorProps) { + return ( + <div className="flex flex-col gap-4"> + {args.map((entry, index) => ( + <Fragment key={entry.id}> + {index > 0 ? <Separator /> : null} + <TopEntryRow + args={args} + index={index} + fields={fields} + fieldNames={fieldNames} + errors={errors} + onChange={onChange} + /> + </Fragment> + ))} + <Button + type="button" + variant="ghost" + size="sm" + className="self-start px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" + onClick={() => + onChange({ args: [...args, makeArgEntry()], fields }) + } + > + <Plus className="size-4" /> Add Argument + </Button> + </div> + ); +} + +interface TopEntryRowProps { + args: ArgEntry[]; + index: number; + fields: FieldDraft[]; + fieldNames: string[]; + errors: string[]; + onChange: (next: { args: ArgEntry[]; fields: FieldDraft[] }) => void; +} + +function TopEntryRow({ + args, + index, + fields, + fieldNames, + errors, + onChange, +}: TopEntryRowProps) { + const entry = args[index]; + const dynamic = isDynamicArg(entry); + const fieldIndex = fields.findIndex((field) => field.name === entry.key); + const field = fields[fieldIndex]; + // For a dynamic arg the key *is* the field name, so a `field.name` validation error (e.g. a + // space or other non-identifier char) has no input of its own — surface it on the key input. + const keyError = dynamic + ? errorFor(errors, `fields.${fieldIndex}.name`) + : undefined; + // A lone static string value can have a `{{field}}` placeholder appended via the inline inserter. + const staticStringValue = + entry.value.kind === "string" ? entry.value.value : null; + + const setArgs = (next: ArgEntry[]) => onChange({ args: next, fields }); + const replace = (next: ArgEntry) => + setArgs(args.map((existing, i) => (i === index ? next : existing))); + + function setKey(key: string) { + if (dynamic) { + // Rename the field with the key and re-point the placeholder — atomically. + onChange({ + args: args.map((existing, i) => + i === index + ? { + ...existing, + key, + value: { kind: "string", value: `{{${key}}}` }, + } + : existing, + ), + fields: fields.map((candidate) => + candidate.name === entry.key + ? { ...candidate, name: key } + : candidate, + ), + }); + return; + } + replace({ ...entry, key }); + } + + function setDynamic(next: boolean) { + if (next) { + // Value becomes {{key}}; normalizeFields (at the top) creates the field. + replace({ + ...entry, + value: { kind: "string", value: `{{${entry.key}}}` }, + }); + return; + } + replace({ ...entry, value: { kind: "string", value: "" } }); + } + + function setStaticType(type: ArgValueType) { + replace({ ...entry, value: changeType(entry.value, type, fieldNames) }); + } + + function setValue(value: ArgNode) { + replace({ ...entry, value }); + } + + function setFieldConfig(patch: Partial<FieldDraft>) { + onChange({ + args, + fields: fields.map((candidate, i) => + i === fieldIndex ? { ...candidate, ...patch } : candidate, + ), + }); + } + + function removeEntry() { + // A dynamic arg owns its field (name == key) — drop both so it can't linger in Other inputs. + onChange({ + args: args.filter((_, i) => i !== index), + fields: dynamic + ? fields.filter((candidate) => candidate.name !== entry.key) + : fields, + }); + } + + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center justify-between"> + <h4 className="font-semibold text-sm">Argument {index + 1}</h4> + <Button + type="button" + variant="ghost" + size="sm" + className="text-muted-foreground hover:text-foreground" + onClick={removeEntry} + > + <Trash2 className="size-4" /> Remove + </Button> + </div> + + <div className="grid gap-3 sm:grid-cols-2"> + <LabeledInput + label="Argument name" + value={entry.key} + onChange={setKey} + placeholder="argument" + error={keyError} + /> + <Labeled label="Type"> + <Select + value={dynamic ? "dynamic" : "static"} + onValueChange={(value) => + setDynamic(value === "dynamic") + } + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="static">Static</SelectItem> + <SelectItem + value="dynamic" + disabled={entry.key === ""} + > + Member input + </SelectItem> + </SelectContent> + </Select> + </Labeled> + </div> + + {dynamic && field ? ( + <FieldConfigFields + field={field} + path={`fields.${fieldIndex}`} + errors={errors} + onChange={setFieldConfig} + /> + ) : null} + + {!dynamic ? ( + <div className="grid gap-3 sm:grid-cols-2"> + <Labeled label="Value type"> + <Select + value={staticTypeOf(entry.value)} + onValueChange={(value) => + setStaticType(value as ArgValueType) + } + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {STATIC_TYPES.map((staticType) => ( + <SelectItem + key={staticType} + value={staticType} + > + {staticType} + </SelectItem> + ))} + </SelectContent> + </Select> + </Labeled> + <Labeled + label="Value" + action={ + staticStringValue !== null && + fieldNames.length > 0 ? ( + <FieldInserter + fieldNames={fieldNames} + onInsert={(name) => + setValue({ + kind: "string", + value: `${staticStringValue}{{${name}}}`, + }) + } + /> + ) : undefined + } + > + <StaticLeaf + node={entry.value} + fieldNames={[]} + onChange={setValue} + /> + </Labeled> + </div> + ) : null} + + {!dynamic && entry.value.kind === "object" ? ( + <StaticEntriesEditor + entries={entry.value.entries} + fieldNames={fieldNames} + onChange={(entries) => + setValue({ kind: "object", entries }) + } + /> + ) : null} + {!dynamic && entry.value.kind === "array" ? ( + <StaticItemsEditor + items={entry.value.items} + fieldNames={fieldNames} + onChange={(items) => setValue({ kind: "array", items })} + /> + ) : null} + </div> + ); +} + +/** Static value editor: a type dropdown + the matching inline control (object/array nest below). */ +function StaticValue({ + type, + node, + fieldNames, + onTypeChange, + onChange, +}: { + type: ArgValueType; + node: ArgNode; + fieldNames: string[]; + onTypeChange: (type: ArgValueType) => void; + onChange: (node: ArgNode) => void; +}) { + return ( + <div className="flex flex-1 items-center gap-2"> + <Select + value={type} + onValueChange={(value) => onTypeChange(value as ArgValueType)} + > + <SelectTrigger className="w-24 shrink-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {STATIC_TYPES.map((staticType) => ( + <SelectItem key={staticType} value={staticType}> + {staticType} + </SelectItem> + ))} + </SelectContent> + </Select> + <StaticLeaf + node={node} + fieldNames={fieldNames} + onChange={onChange} + /> + </div> + ); +} + +/** The scalar editor for a static value (object/array carry no inline control — they nest). */ +function StaticLeaf({ + node, + fieldNames, + onChange, +}: { + node: ArgNode; + fieldNames: string[]; + onChange: (node: ArgNode) => void; +}) { + if (node.kind === "string") { + return ( + <div className="flex flex-1 items-center gap-2"> + <Input + className="flex-1 font-mono text-xs" + value={node.value} + onChange={(event) => + onChange({ kind: "string", value: event.target.value }) + } + placeholder="text or {{field}}" + /> + {fieldNames.length > 0 ? ( + <FieldInserter + fieldNames={fieldNames} + onInsert={(name) => + onChange({ + kind: "string", + value: `${node.value}{{${name}}}`, + }) + } + /> + ) : null} + </div> + ); + } + if (node.kind === "number") { + return ( + <Input + type="number" + className="flex-1" + value={String(node.value)} + onChange={(event) => + onChange({ + kind: "number", + value: Number(event.target.value), + }) + } + /> + ); + } + if (node.kind === "boolean") { + return ( + <Switch + checked={node.value} + onCheckedChange={(checked) => + onChange({ kind: "boolean", value: checked }) + } + /> + ); + } + // null / object / array carry no inline editor. + return <div className="flex-1" />; +} + +/** Inline link-style dropdown (top-right of the Value label) that appends a `{{field}}` placeholder. */ +function FieldInserter({ + fieldNames, + onInsert, +}: { + fieldNames: string[]; + onInsert: (name: string) => void; +}) { + return ( + <Select value="" onValueChange={onInsert}> + <SelectTrigger className="h-auto w-auto gap-1 border-0 bg-transparent p-0 text-muted-foreground text-xs shadow-none hover:text-foreground focus:ring-0 focus-visible:ring-0"> + + field + </SelectTrigger> + <SelectContent> + {fieldNames.map((name) => ( + <SelectItem key={name} value={name}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> + ); +} + +/** Static key→value entries inside a nested object (no Dynamic here — use a composed placeholder). */ +function StaticEntriesEditor({ + entries, + fieldNames, + onChange, +}: { + entries: ArgEntry[]; + fieldNames: string[]; + onChange: (entries: ArgEntry[]) => void; +}) { + return ( + <div className="flex flex-col gap-2 border-l pl-3"> + {entries.map((entry, index) => ( + <div key={entry.id} className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Input + className="w-40 shrink-0" + value={entry.key} + onChange={(event) => + onChange( + entries.map((existing, i) => + i === index + ? { + ...existing, + key: event.target.value, + } + : existing, + ), + ) + } + placeholder="key" + /> + <StaticValue + type={staticTypeOf(entry.value)} + node={entry.value} + fieldNames={fieldNames} + onTypeChange={(type) => + onChange( + entries.map((existing, i) => + i === index + ? { + ...existing, + value: changeType( + existing.value, + type, + fieldNames, + ), + } + : existing, + ), + ) + } + onChange={(value) => + onChange( + entries.map((existing, i) => + i === index + ? { ...existing, value } + : existing, + ), + ) + } + /> + <Button + type="button" + variant="ghost" + size="icon-sm" + className="text-destructive hover:text-destructive" + onClick={() => + onChange(entries.filter((_, i) => i !== index)) + } + > + <Trash2 className="size-4" /> + </Button> + </div> + {entry.value.kind === "object" ? ( + <StaticEntriesEditor + entries={entry.value.entries} + fieldNames={fieldNames} + onChange={(nested) => + onChange( + entries.map((existing, i) => + i === index + ? { + ...existing, + value: { + kind: "object", + entries: nested, + }, + } + : existing, + ), + ) + } + /> + ) : null} + {entry.value.kind === "array" ? ( + <StaticItemsEditor + items={entry.value.items} + fieldNames={fieldNames} + onChange={(nested) => + onChange( + entries.map((existing, i) => + i === index + ? { + ...existing, + value: { + kind: "array", + items: nested, + }, + } + : existing, + ), + ) + } + /> + ) : null} + </div> + ))} + <Button + type="button" + variant="outline" + size="sm" + className="self-start" + onClick={() => onChange([...entries, makeArgEntry()])} + > + <Plus className="size-4" /> Add Key + </Button> + </div> + ); +} + +/** Static array items (all static). New items match the last item's kind. */ +function StaticItemsEditor({ + items, + fieldNames, + onChange, +}: { + items: ArgItem[]; + fieldNames: string[]; + onChange: (items: ArgItem[]) => void; +}) { + function addItem() { + const last = items.at(-1)?.value; + const seed = last + ? emptyNodeOf(staticTypeOf(last), fieldNames) + : { kind: "string" as const, value: "" }; + onChange([...items, makeArgItem(seed)]); + } + + return ( + <div className="flex flex-col gap-2 border-l pl-3"> + {items.map((item, index) => ( + <div key={item.id} className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <span className="w-6 shrink-0 text-muted-foreground text-xs"> + {index} + </span> + <StaticValue + type={staticTypeOf(item.value)} + node={item.value} + fieldNames={fieldNames} + onTypeChange={(type) => + onChange( + items.map((existing, i) => + i === index + ? { + ...existing, + value: changeType( + existing.value, + type, + fieldNames, + ), + } + : existing, + ), + ) + } + onChange={(value) => + onChange( + items.map((existing, i) => + i === index + ? { ...existing, value } + : existing, + ), + ) + } + /> + <Button + type="button" + variant="ghost" + size="icon-sm" + className="text-destructive hover:text-destructive" + onClick={() => + onChange(items.filter((_, i) => i !== index)) + } + > + <Trash2 className="size-4" /> + </Button> + </div> + </div> + ))} + <Button + type="button" + variant="outline" + size="sm" + className="self-start" + onClick={addItem} + > + <Plus className="size-4" /> Add Item + </Button> + </div> + ); +} diff --git a/nt-fe/features/proposal-templates/components/fields-builder.tsx b/nt-fe/features/proposal-templates/components/fields-builder.tsx new file mode 100644 index 000000000..0fe6208b7 --- /dev/null +++ b/nt-fe/features/proposal-templates/components/fields-builder.tsx @@ -0,0 +1,476 @@ +"use client"; + +/** + * The Inputs section's row editor + the reusable `FieldConfigFields` block (label / type / required / + * select options / advanced help+validation+default). `FieldConfigFields` is shared by the standalone + * `FieldRow` here and by the args editor's inline "dynamic" config, so a member input is configured + * the same way wherever it's edited. The optional extras (help / validation / default) sit behind a + * per-row "Advanced options" disclosure that auto-opens when pre-filled or on a validation error, + * and only the ones a field type allows are shown. Errors render under the input they belong to + * (after it's touched); `draftToField` drops type-incompatible extras on serialize. + */ +import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react"; +import { Fragment, useId, useState } from "react"; +import { Button } from "@/components/button"; +import { Textarea } from "@/components/textarea"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { type FieldDraft, makeFieldDraft } from "../draft"; +import { errorFor } from "../error-map"; +import { MANIFEST_FIELD_TYPES, type ManifestFieldType } from "../manifest"; + +const NUMERIC_TYPES: ManifestFieldType[] = ["uint", "amount", "number"]; +const PATTERN_TYPES: ManifestFieldType[] = ["text", "number"]; + +/** Whether a field already carries any advanced config — used to open the disclosure pre-filled. */ +function hasAdvanced(field: FieldDraft): boolean { + return Boolean( + field.help || + field.default !== undefined || + field.validation.min || + field.validation.max || + field.validation.pattern, + ); +} + +interface FieldsBuilderProps { + fields: FieldDraft[]; + errors: string[]; + /** Field names referenced by an argument/summary placeholder — the rest are flagged unused. */ + usedNames: Set<string>; + /** Names configured inline on a dynamic argument — hidden here so they aren't double-edited. */ + hideNames?: Set<string>; + onChange: (fields: FieldDraft[]) => void; +} + +export function FieldsBuilder({ + fields, + errors, + usedNames, + hideNames, + onChange, +}: FieldsBuilderProps) { + function updateField(index: number, patch: Partial<FieldDraft>) { + onChange( + fields.map((field, i) => + i === index ? { ...field, ...patch } : field, + ), + ); + } + + function swapFields(a: number, b: number) { + const next = [...fields]; + [next[a], next[b]] = [next[b], next[a]]; + onChange(next); + } + + // Fields configured inline on a dynamic argument are hidden here; number the rest sequentially. + const visible = fields + .map((field, index) => ({ field, index })) + .filter(({ field }) => !hideNames?.has(field.name)); + + return ( + <div className="flex flex-col gap-4"> + {visible.map(({ field, index }, displayIndex) => ( + <Fragment key={field.key}> + {displayIndex > 0 ? <Separator /> : null} + <FieldRow + field={field} + index={displayIndex} + used={field.name === "" || usedNames.has(field.name)} + path={`fields.${index}`} + errors={errors} + canMoveUp={displayIndex > 0} + canMoveDown={displayIndex < visible.length - 1} + onChange={(patch) => updateField(index, patch)} + onRemove={() => + onChange(fields.filter((_, i) => i !== index)) + } + onMove={(delta) => { + // Move relative to the visible neighbor, not the raw array slot, so a + // swap never lands on a hidden inline-dynamic field. + const target = displayIndex + delta; + if (target < 0 || target >= visible.length) { + return; + } + swapFields(index, visible[target].index); + }} + /> + </Fragment> + ))} + <Button + type="button" + variant="ghost" + size="sm" + className="self-start px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" + onClick={() => onChange([...fields, makeFieldDraft()])} + > + <Plus className="size-4" /> Add Field + </Button> + </div> + ); +} + +interface FieldRowProps { + field: FieldDraft; + /** Sequential position among the visible "Other inputs" rows, for the "Input N" header. */ + index: number; + used: boolean; + path: string; + errors: string[]; + canMoveUp: boolean; + canMoveDown: boolean; + onChange: (patch: Partial<FieldDraft>) => void; + onRemove: () => void; + onMove: (delta: number) => void; +} + +function FieldRow({ + field, + index, + used, + path, + errors, + canMoveUp, + canMoveDown, + onChange, + onRemove, + onMove, +}: FieldRowProps) { + return ( + <div className="flex flex-col gap-3"> + <div className="flex items-center justify-between gap-2"> + <h4 className="font-semibold text-sm">Input {index + 1}</h4> + <div className="flex items-center gap-1"> + <Button + type="button" + variant="ghost" + size="icon-sm" + disabled={!canMoveUp} + onClick={() => onMove(-1)} + > + <ChevronUp className="size-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="icon-sm" + disabled={!canMoveDown} + onClick={() => onMove(1)} + > + <ChevronDown className="size-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + className="text-muted-foreground hover:text-foreground" + onClick={onRemove} + > + <Trash2 className="size-4" /> Remove + </Button> + </div> + </div> + + <LabeledInput + label="Name" + value={field.name} + onChange={(value) => onChange({ name: value })} + placeholder="greeting" + error={errorFor(errors, `${path}.name`)} + /> + + {used ? null : ( + <p className="text-amber-600 text-xs dark:text-amber-500"> + {`Unused — no argument uses {{${field.name}}}; members fill it but it isn't sent.`} + </p> + )} + + <FieldConfigFields + field={field} + path={path} + errors={errors} + onChange={onChange} + /> + </div> + ); +} + +/** The config controls for one member input (everything but its name). Shared with the args editor. */ +export function FieldConfigFields({ + field, + path, + errors, + onChange, +}: { + field: FieldDraft; + path: string; + errors: string[]; + onChange: (patch: Partial<FieldDraft>) => void; +}) { + const requiredId = useId(); + // The options textarea isn't a LabeledInput, so it tracks its own touched state to match the + // per-field "no error until touched" behavior. + const [optionsTouched, setOptionsTouched] = useState(false); + const isNumeric = NUMERIC_TYPES.includes(field.type); + const allowsPattern = PATTERN_TYPES.includes(field.type); + const showRequired = field.type !== "bool"; + const showDefault = field.type !== "bool" && field.type !== "json"; + + const minError = errorFor(errors, `${path}.validation.min`); + const maxError = errorFor(errors, `${path}.validation.max`); + const patternError = errorFor(errors, `${path}.validation.pattern`); + const defaultError = errorFor(errors, `${path}.default`); + const advancedError = minError || maxError || patternError || defaultError; + + const [open, setOpen] = useState(() => hasAdvanced(field)); + const showAdvanced = open || Boolean(advancedError); + + function setDefault(text: string) { + if (text.trim() === "") { + onChange({ default: undefined }); + return; + } + if (field.type === "number") { + const value = Number(text); + // Keep the raw text when it isn't a number so the schema flags the mismatch. + onChange({ default: Number.isNaN(value) ? text : value }); + return; + } + onChange({ default: text }); + } + + function setValidation(patch: Partial<FieldDraft["validation"]>) { + onChange({ validation: { ...field.validation, ...patch } }); + } + + return ( + <div className="flex flex-col gap-3"> + <div className="grid gap-2 sm:grid-cols-2"> + <LabeledInput + label="Label" + value={field.label} + onChange={(value) => onChange({ label: value })} + placeholder="Greeting" + error={errorFor(errors, `${path}.label`)} + /> + <Labeled label="Type"> + <Select + value={field.type} + onValueChange={(value) => + // Clear default on a type switch (can't mismatch the new type), and clear + // `required` when moving to bool — it's not allowed there and there's no + // Required switch on a bool to clear it from. + onChange({ + type: value as ManifestFieldType, + default: undefined, + ...(value === "bool" + ? { required: false } + : {}), + }) + } + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {MANIFEST_FIELD_TYPES.map((type) => ( + <SelectItem key={type} value={type}> + {type} + </SelectItem> + ))} + </SelectContent> + </Select> + </Labeled> + </div> + + {showRequired ? ( + <div className="flex items-center gap-2"> + <Switch + id={requiredId} + checked={field.required} + onCheckedChange={(checked) => + onChange({ required: checked }) + } + /> + <Label htmlFor={requiredId} className="text-sm"> + Required + </Label> + </div> + ) : null} + + {field.type === "select" ? ( + <Labeled label="Options (one per line)"> + <Textarea + rows={3} + className="font-mono text-xs" + value={field.options.join("\n")} + aria-invalid={ + optionsTouched && + errorFor(errors, `${path}.options`) + ? true + : undefined + } + onBlur={() => setOptionsTouched(true)} + onChange={(event) => + onChange({ + options: event.target.value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + }) + } + placeholder={"option-a\noption-b"} + /> + <FieldError + message={ + optionsTouched + ? errorFor(errors, `${path}.options`) + : undefined + } + /> + </Labeled> + ) : null} + + <button + type="button" + onClick={() => setOpen((value) => !value)} + className="flex items-center gap-1 self-start text-muted-foreground text-xs transition-colors hover:text-foreground" + > + {showAdvanced ? ( + <ChevronUp className="size-3.5" /> + ) : ( + <ChevronDown className="size-3.5" /> + )} + Advanced options + </button> + + {showAdvanced ? ( + <div className="flex flex-col gap-3 border-t pt-3"> + <LabeledInput + label="Help (optional)" + value={field.help} + onChange={(value) => onChange({ help: value })} + placeholder="Shown under the input" + /> + + {isNumeric ? ( + <div className="grid gap-3 sm:grid-cols-2"> + <LabeledInput + label="Min (optional)" + value={field.validation.min} + onChange={(value) => + setValidation({ min: value }) + } + placeholder="0" + error={minError} + /> + <LabeledInput + label="Max (optional)" + value={field.validation.max} + onChange={(value) => + setValidation({ max: value }) + } + error={maxError} + /> + </div> + ) : null} + + {allowsPattern ? ( + <LabeledInput + label="Pattern (regex, optional)" + value={field.validation.pattern} + onChange={(value) => + setValidation({ pattern: value }) + } + placeholder="^[A-Za-z]" + error={patternError} + /> + ) : null} + + {showDefault ? ( + <LabeledInput + label="Default (optional)" + value={String(field.default ?? "")} + onChange={setDefault} + error={defaultError} + /> + ) : null} + </div> + ) : null} + </div> + ); +} + +export function LabeledInput({ + label, + value, + onChange, + placeholder, + error, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + error?: string; +}) { + // Show this field's error only after the user has touched it (blurred once), so an untouched + // field never reds — even while other fields in the form are invalid. + const [touched, setTouched] = useState(false); + const shownError = touched ? error : undefined; + return ( + <Labeled label={label}> + <Input + value={value} + onChange={(event) => onChange(event.target.value)} + onBlur={() => setTouched(true)} + placeholder={placeholder} + aria-invalid={shownError ? true : undefined} + /> + <FieldError message={shownError} /> + </Labeled> + ); +} + +export function FieldError({ message }: { message?: string }) { + if (!message) { + return null; + } + return <p className="text-destructive text-xs">{message}</p>; +} + +export function Labeled({ + label, + className, + action, + children, +}: { + label: string; + className?: string; + /** Optional control rendered at the top-right of the label row (e.g. an inline "+ field" link). */ + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + <div className={cn("flex flex-col gap-1", className)}> + {/* Fixed height so a label row with an action (e.g. the "+ field" link) stays the same + height as one without — otherwise paired grid cells misalign their inputs. */} + <div className="flex h-5 items-center justify-between gap-2"> + <Label className="text-muted-foreground text-xs">{label}</Label> + {action} + </div> + {children} + </div> + ); +} diff --git a/nt-fe/features/proposal-templates/components/manifest-form.tsx b/nt-fe/features/proposal-templates/components/manifest-form.tsx new file mode 100644 index 000000000..885a746af --- /dev/null +++ b/nt-fe/features/proposal-templates/components/manifest-form.tsx @@ -0,0 +1,228 @@ +"use client"; + +/** + * Render a validated manifest as a react-hook-form form using the trezu design system: each field + * is an `InputBlock` (muted card, label as title, help as an info tooltip, red on invalid) wrapping + * a borderless input chosen by `type`. Validated by `buildFormSchema`; on submit the parsed values + * go to `onSubmit` for the engine (`buildTemplateProposal`). + * + * Inputs are controlled (value/onChange/onBlur, no RHF registration ref) — the same way the house + * `account-id-input` / payments fields wire LargeInput/Textarea. The form is dynamic (its shape is + * the runtime manifest), so values flow as the generic `FieldValues`. + */ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { type ControllerRenderProps, useForm } from "react-hook-form"; +import { Button } from "@/components/button"; +import { InputBlock } from "@/components/input-block"; +import { LargeInput } from "@/components/large-input"; +import { Textarea } from "@/components/textarea"; +import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import type { FieldValues } from "../build-proposal"; +import { buildFormSchema, defaultValuesFor } from "../form-schema"; +import type { Manifest, ManifestField } from "../manifest"; + +interface ManifestFormProps { + manifest: Manifest; + /** Throw to signal failure (or a missing session); resolving is treated as a filed proposal. */ + onSubmit: (values: FieldValues) => void | Promise<void>; + submitting?: boolean; + submitLabel?: string; +} + +type FieldControl = ControllerRenderProps<FieldValues, string>; + +const SELECT_TRIGGER_CLASS = + "h-auto w-full border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"; + +// Same slide as the core step forms: the cleared set exits left, the fresh one enters from the right. +const SLIDE_VARIANTS = { + enter: { x: "100%", opacity: 0 }, + center: { x: 0, opacity: 1 }, + exit: { x: "-100%", opacity: 0 }, +}; + +/** The current value as a string for text-like inputs (form state is string-valued except bool). */ +function asText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** Draw the borderless input that matches a field's `type`. Validation lives in `buildFormSchema`. */ +function FieldControlInput({ + field, + control, +}: { + field: ManifestField; + control: FieldControl; +}) { + switch (field.type) { + case "bool": + return ( + <div className="pt-1"> + <Switch + checked={Boolean(control.value)} + onCheckedChange={control.onChange} + onBlur={control.onBlur} + /> + </div> + ); + case "select": + return ( + <Select + value={asText(control.value)} + onValueChange={control.onChange} + > + <SelectTrigger + className={SELECT_TRIGGER_CLASS} + onBlur={control.onBlur} + > + <SelectValue placeholder="Choose an option" /> + </SelectTrigger> + <SelectContent> + {(field.options ?? []).map((option) => ( + <SelectItem key={option} value={option}> + {option} + </SelectItem> + ))} + </SelectContent> + </Select> + ); + case "json": + return ( + <Textarea + borderless + rows={3} + value={asText(control.value)} + onChange={control.onChange} + onBlur={control.onBlur} + placeholder="{ }" + /> + ); + case "number": + return ( + <LargeInput + borderless + type="number" + value={asText(control.value)} + onChange={control.onChange} + onBlur={control.onBlur} + /> + ); + default: + // account, token, uint, amount, text — a free string input. + return ( + <LargeInput + borderless + value={asText(control.value)} + onChange={control.onChange} + onBlur={control.onBlur} + /> + ); + } +} + +export function ManifestForm({ + manifest, + onSubmit, + submitting, + submitLabel, +}: ManifestFormProps) { + const form = useForm<FieldValues>({ + resolver: zodResolver(buildFormSchema(manifest)), + defaultValues: defaultValuesFor(manifest), + mode: "onBlur", + }); + // Bumped on a successful submit so the filled fields slide out and a fresh empty set slides in — + // the same clear-with-slide the core step forms do (StepWizard) when they reset to step 0. + const [formKey, setFormKey] = useState(0); + + // Clear the form only after a successful submit (the page throws on failure / no session), so a + // filed proposal leaves an empty form instead of all the values still sitting there. + const submit = async (values: FieldValues) => { + try { + await onSubmit(values); + } catch { + return; + } + form.reset(defaultValuesFor(manifest)); + setFormKey((key) => key + 1); + }; + + const label = submitLabel ?? "File Proposal"; + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(submit)} + className="flex flex-col gap-3" + > + <div className="relative overflow-hidden"> + <AnimatePresence mode="popLayout" initial={false}> + <motion.div + key={formKey} + variants={SLIDE_VARIANTS} + initial="enter" + animate="center" + exit="exit" + transition={{ + x: { + type: "tween", + duration: 0.25, + ease: "easeInOut", + }, + opacity: { duration: 0.2 }, + }} + className="flex flex-col gap-3" + > + {manifest.fields.map((field) => ( + <FormField + key={field.name} + control={form.control} + name={field.name} + render={({ + field: control, + fieldState, + }) => ( + <FormItem> + <InputBlock + title={field.label} + info={field.help} + invalid={!!fieldState.error} + > + <FieldControlInput + field={field} + control={control} + /> + </InputBlock> + <FormMessage /> + </FormItem> + )} + /> + ))} + </motion.div> + </AnimatePresence> + </div> + <Button type="submit" className="w-full" disabled={submitting}> + {submitting ? ( + <> + <Loader2 className="mr-2 size-4 animate-spin" /> + {label} + </> + ) : ( + label + )} + </Button> + </form> + </Form> + ); +} diff --git a/nt-fe/features/proposal-templates/components/template-editor.tsx b/nt-fe/features/proposal-templates/components/template-editor.tsx new file mode 100644 index 000000000..64c63683e --- /dev/null +++ b/nt-fe/features/proposal-templates/components/template-editor.tsx @@ -0,0 +1,269 @@ +"use client"; + +/** + * Authoring form shared by create and edit, with a Code | Visual toggle over the manifest. Code + * mode is a JSON textarea; Visual mode is the sectioned `VisualBuilder`. Both feed the same + * `parseManifest`, so errors and submit behave identically regardless of mode. The page supplies + * `onSubmit` (create vs update) and the labels; this stays agnostic of which mutation runs. + * + * State: the name (the DB record name, separate from the manifest), the code textarea string, and + * the visual draft. Switching Code → Visual parses the textarea into a draft (blocked on invalid + * JSON); Visual → Code serializes the draft back to text. The active mode is the source of truth. + */ +import { ArrowLeft } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/button"; +import { PageCard } from "@/components/card"; +import { InputBlock } from "@/components/input-block"; +import { LargeInput } from "@/components/large-input"; +import { Textarea } from "@/components/textarea"; +import { Tabs, TabsList, TabsTrigger } from "@/components/underline-tabs"; +import { duplicateArgKeys } from "../args-node"; +import { + draftToManifest, + emptyDraft, + jsonToDraft, + type ManifestDraft, + normalizeFields, +} from "../draft"; +import { + type Manifest, + manifestErrorMessages, + parseManifest, + validateManifestText, +} from "../manifest"; +import { VisualBuilder } from "./visual-builder"; + +const EXAMPLE = `{ + "version": 1, + "id": "set-greeting", + "title": "Set Greeting", + "description": "Update the greeting shown on a guest-book contract.", + "binding": { + "receiver_id": "guestbook.near", + "method_name": "set_greeting", + "deposit": "0", + "gas": "30000000000000" + }, + "fields": [ + { "name": "greeting", "label": "Greeting", "type": "text", "required": true, "help": "The new message" } + ], + "args": { "greeting": "{{greeting}}" }, + "summary": "Set greeting to {{greeting}}" +}`; + +type Mode = "visual" | "code"; + +/** Derive a manifest + error lines from the visual draft via the same validator code mode uses. */ +function manifestFromDraft(draft: ManifestDraft): { + manifest?: Manifest; + errors: string[]; +} { + const parsed = parseManifest(draftToManifest(draft)); + const baseErrors = parsed.success + ? [] + : manifestErrorMessages(parsed.error); + // Duplicate args keys collapse on serialize (last-write-wins), so parseManifest can't see them; + // detect them on the draft and block save with a visible error in the Arguments section. + const dupeErrors = duplicateArgKeys(draft.args).map(({ path, key }) => { + const where = path ? `args.${path}` : "args"; + return `${where}: duplicate argument key "${key}" — only the last is kept`; + }); + const errors = [...baseErrors, ...dupeErrors]; + if (parsed.success && dupeErrors.length === 0) { + return { manifest: parsed.data, errors }; + } + return { errors }; +} + +/** Empty text (a new template) or any syntactically valid JSON may open in Visual mode. */ +function isParseableJson(text: string): boolean { + if (!text.trim()) { + return true; + } + try { + JSON.parse(text); + return true; + } catch { + return false; + } +} + +/** Hydrate the initial draft leniently; blank or unparseable text falls back to an empty draft. */ +function parseDraft(text: string): ManifestDraft { + if (!text.trim()) { + return emptyDraft(); + } + try { + return normalizeFields(jsonToDraft(JSON.parse(text))); + } catch { + return emptyDraft(); + } +} + +interface TemplateEditorProps { + /** In-card header title with the back arrow, e.g. "New Template" / "Edit Template". */ + title: string; + /** Invoked by the header back arrow (the page owns where "back" goes). */ + onBack: () => void; + initialName?: string; + initialManifestText?: string; + submitLabel: string; + /** Label shown on the submit button while the mutation runs (defaults to "Saving…"). */ + submittingLabel?: string; + /** Called with the trimmed name and the validated manifest when the user submits. */ + onSubmit: (values: { + name: string; + manifest: unknown; + }) => void | Promise<void>; + submitting?: boolean; + /** Optional footer slot (e.g. the edit page's delete control). */ + footer?: React.ReactNode; +} + +export function TemplateEditor({ + title, + onBack, + initialName = "", + initialManifestText = "", + submitLabel, + submittingLabel = "Saving…", + onSubmit, + submitting = false, + footer, +}: TemplateEditorProps) { + const [name, setName] = useState(initialName); + const [manifestText, setManifestText] = useState(initialManifestText); + // Default to Visual; fall back to Code only when editing a manifest that doesn't parse. + const [mode, setMode] = useState<Mode>(() => + isParseableJson(initialManifestText) ? "visual" : "code", + ); + const [draft, setDraft] = useState<ManifestDraft>(() => + parseDraft(initialManifestText), + ); + // Name owns its touched state, so editing the builder doesn't light up "Name is required". + const [nameTouched, setNameTouched] = useState(false); + // Code mode is one textarea, so it keeps a single touched gate. The visual builder gates errors + // per input (inside each LabeledInput), so an untouched field never shows red. + const [codeTouched, setCodeTouched] = useState(false); + // Cross-input section errors (duplicate keys, unique names, unknown placeholders) aren't tied to + // one input, so they show once the builder has been touched at all — consistent with per-input. + const [builderTouched, setBuilderTouched] = useState(false); + + const { manifest, errors } = + mode === "code" + ? validateManifestText(manifestText) + : manifestFromDraft(draft); + const codeShowErrors = codeTouched && errors.length > 0; + const nameMissing = name.trim().length === 0; + const canSubmit = !!manifest && !nameMissing && !submitting; + + function switchMode(next: string) { + const target = next as Mode; + if (target === mode) { + return; + } + if (target === "visual") { + let parsed: unknown; + try { + parsed = manifestText.trim() ? JSON.parse(manifestText) : {}; + } catch { + toast.error( + "Fix the invalid JSON before switching to the visual builder", + ); + return; + } + setDraft(normalizeFields(jsonToDraft(parsed))); + setMode("visual"); + return; + } + // → Code: serialize the current draft into the textarea. + setManifestText(JSON.stringify(draftToManifest(draft), null, 2)); + setMode("code"); + } + + return ( + <PageCard className="gap-4"> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={onBack} + aria-label="Back" + className="text-muted-foreground transition-colors hover:text-foreground" + > + <ArrowLeft className="size-5" /> + </button> + <h2 className="font-semibold text-base">{title}</h2> + </div> + + <InputBlock title="Name" invalid={nameTouched && nameMissing}> + <LargeInput + borderless + aria-label="Name" + value={name} + onChange={(event) => setName(event.target.value)} + onBlur={() => setNameTouched(true)} + placeholder="Set Greeting" + /> + {nameTouched && nameMissing ? ( + <p className="text-destructive text-sm">Name is required</p> + ) : null} + </InputBlock> + + <Tabs value={mode} onValueChange={switchMode}> + <TabsList> + <TabsTrigger value="visual">Visual</TabsTrigger> + <TabsTrigger value="code">Code</TabsTrigger> + </TabsList> + </Tabs> + + {mode === "code" ? ( + <InputBlock title="Manifest (JSON)" invalid={codeShowErrors}> + <Textarea + borderless + rows={16} + className="font-mono text-xs" + value={manifestText} + onChange={(event) => { + setManifestText(event.target.value); + setCodeTouched(true); + }} + placeholder={EXAMPLE} + /> + </InputBlock> + ) : ( + <VisualBuilder + draft={draft} + errors={errors} + showSectionErrors={builderTouched} + onChange={(next) => { + setDraft(normalizeFields(next)); + setBuilderTouched(true); + }} + /> + )} + + {mode === "code" && codeShowErrors ? ( + <ul className="list-disc pl-5 text-destructive text-sm"> + {errors.map((message) => ( + <li key={message}>{message}</li> + ))} + </ul> + ) : null} + + <Button + type="button" + className="w-full" + disabled={!canSubmit} + onClick={() => + manifest && onSubmit({ name: name.trim(), manifest }) + } + > + {submitting ? submittingLabel : submitLabel} + </Button> + + {footer} + </PageCard> + ); +} diff --git a/nt-fe/features/proposal-templates/components/touched-gating.test.tsx b/nt-fe/features/proposal-templates/components/touched-gating.test.tsx new file mode 100644 index 000000000..c4a180826 --- /dev/null +++ b/nt-fe/features/proposal-templates/components/touched-gating.test.tsx @@ -0,0 +1,50 @@ +/** + * Pins the "no red until touched" contract for the authoring form. Uses react-dom/server (no DOM + * harness, no testing-library) to assert the *initial* render — i.e. the load state, before any + * blur/edit — which is exactly the regression these tests guard against ("red whole form on load"). + * The interactive half (error appears *after* blur) belongs in the Playwright e2e. + */ +import { describe, expect, it } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { emptyDraft } from "../draft"; +import { LabeledInput } from "./fields-builder"; +import { VisualBuilder } from "./visual-builder"; + +describe("LabeledInput touched gating", () => { + it("does not render its error before the input is touched", () => { + const html = renderToStaticMarkup( + <LabeledInput + label="Receiver" + value="" + onChange={() => {}} + error="receiver is required" + />, + ); + expect(html).not.toContain("receiver is required"); + }); +}); + +describe("VisualBuilder section-error gating", () => { + // An `args:`-prefixed error is routed to the Arguments section foot (a cross-input error). + const sectionError = + 'args: duplicate argument key "x" — only the last is kept'; + + function render(showSectionErrors: boolean): string { + return renderToStaticMarkup( + <VisualBuilder + draft={emptyDraft()} + errors={[sectionError]} + showSectionErrors={showSectionErrors} + onChange={() => {}} + />, + ); + } + + it("hides section-foot errors until the builder has been touched", () => { + expect(render(false)).not.toContain("duplicate argument key"); + }); + + it("shows section-foot errors once the builder has been touched", () => { + expect(render(true)).toContain("duplicate argument key"); + }); +}); diff --git a/nt-fe/features/proposal-templates/components/visual-builder.tsx b/nt-fe/features/proposal-templates/components/visual-builder.tsx new file mode 100644 index 000000000..d7069cee7 --- /dev/null +++ b/nt-fe/features/proposal-templates/components/visual-builder.tsx @@ -0,0 +1,216 @@ +"use client"; + +/** + * The visual constructor body: edits a `ManifestDraft` through sectioned form controls instead of + * raw JSON. Validation errors render under the input they're about (red field + message beneath), + * via `errorFor`. The few that aren't tied to a single input — the cross-field "unique names" rule, + * `args` placeholder issues — show at their section's foot. `icon` isn't surfaced but rides along + * in the draft, so round-tripping never drops it. + */ +import { dynamicArgNames } from "../args-node"; +import { type ManifestDraft, usedFieldNames } from "../draft"; +import { errorFor, isInlineErrorPath } from "../error-map"; +import { ArgsTreeEditor } from "./args-tree-editor"; +import { FieldsBuilder, LabeledInput } from "./fields-builder"; + +/** Errors in the args subtree — shown at the Arguments section's foot (not mappable per-leaf). */ +function argErrors(errors: string[]): string[] { + return errors.filter((entry) => entry.split(":")[0].startsWith("args")); +} + +/** Field errors not tied to one input: the bounds refine, the unique-names rule, a stray `required`. */ +function fieldsSectionErrors(errors: string[]): string[] { + return errors.filter((entry) => { + const path = entry.split(":")[0]; + return path.startsWith("fields") && !isInlineErrorPath(path); + }); +} + +/** Anything no input renders and no section above claims — the final safety net. */ +function otherErrors(errors: string[]): string[] { + return errors.filter((entry) => { + const path = entry.split(":")[0]; + return ( + !path.startsWith("args") && + !path.startsWith("fields") && + !isInlineErrorPath(path) + ); + }); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( + <div className="flex flex-col gap-3 rounded-xl border p-4"> + <div className="flex flex-col gap-0.5"> + <h3 className="font-medium text-sm">{title}</h3> + {description ? ( + <p className="text-muted-foreground text-xs"> + {description} + </p> + ) : null} + </div> + {children} + </div> + ); +} + +function ErrorList({ errors }: { errors: string[] }) { + if (errors.length === 0) { + return null; + } + return ( + <ul className="list-disc pl-5 text-destructive text-sm"> + {errors.map((message) => ( + <li key={message}>{message}</li> + ))} + </ul> + ); +} + +interface VisualBuilderProps { + draft: ManifestDraft; + errors: string[]; + /** + * Whether to show the cross-input section-foot errors (duplicate keys, unique-names, unknown + * placeholders). Per-input errors self-gate on their own touch inside each input; these don't + * belong to a single input, so the parent gates them on the builder having been touched at all. + */ + showSectionErrors: boolean; + onChange: (draft: ManifestDraft) => void; +} + +export function VisualBuilder({ + draft, + errors, + showSectionErrors, + onChange, +}: VisualBuilderProps) { + const update = (patch: Partial<ManifestDraft>) => + onChange({ ...draft, ...patch }); + const updateBinding = (patch: Partial<ManifestDraft["binding"]>) => + onChange({ ...draft, binding: { ...draft.binding, ...patch } }); + const used = usedFieldNames(draft); + const inlineNames = dynamicArgNames(draft.args); + + return ( + <div className="flex flex-col gap-4"> + <Section title="Details"> + <div className="grid gap-3 sm:grid-cols-2"> + <LabeledInput + label="ID (slug)" + value={draft.id} + onChange={(value) => update({ id: value })} + placeholder="set-greeting" + error={errorFor(errors, "id")} + /> + <LabeledInput + label="Title" + value={draft.title} + onChange={(value) => update({ title: value })} + placeholder="Set Greeting" + error={errorFor(errors, "title")} + /> + </div> + <LabeledInput + label="Description (optional)" + value={draft.description} + onChange={(value) => update({ description: value })} + error={errorFor(errors, "description")} + /> + <LabeledInput + label="Summary (optional, supports {{fields}})" + value={draft.summary} + onChange={(value) => update({ summary: value })} + placeholder="Set greeting to {{greeting}}" + error={errorFor(errors, "summary")} + /> + </Section> + + <Section + title="On-chain call" + description="The fixed FunctionCall this template files." + > + <div className="grid gap-3 sm:grid-cols-2"> + <LabeledInput + label="Receiver (contract)" + value={draft.binding.receiver_id} + onChange={(value) => + updateBinding({ receiver_id: value }) + } + placeholder="guestbook.near" + error={errorFor(errors, "binding.receiver_id")} + /> + <LabeledInput + label="Method" + value={draft.binding.method_name} + onChange={(value) => + updateBinding({ method_name: value }) + } + placeholder="set_greeting" + error={errorFor(errors, "binding.method_name")} + /> + <LabeledInput + label="Deposit (yoctoNEAR)" + value={draft.binding.deposit} + onChange={(value) => updateBinding({ deposit: value })} + placeholder="0" + error={errorFor(errors, "binding.deposit")} + /> + <LabeledInput + label="Gas" + value={draft.binding.gas} + onChange={(value) => updateBinding({ gas: value })} + placeholder="30000000000000" + error={errorFor(errors, "binding.gas")} + /> + </div> + </Section> + + <Section + title="Arguments" + description="The arguments sent to the contract. Each is Static (a fixed value) or a Member input (dynamic) — for the latter, the input's config appears inline." + > + <ArgsTreeEditor + args={draft.args} + fields={draft.fields} + fieldNames={draft.fields + .map((field) => field.name) + .filter(Boolean)} + errors={errors} + onChange={({ args, fields }) => update({ args, fields })} + /> + <ErrorList + errors={showSectionErrors ? argErrors(errors) : []} + /> + </Section> + + <Section + title="Other inputs" + description="Inputs used only inside a composed value (not a direct member-input argument), plus any you add manually. Most templates have none." + > + <FieldsBuilder + fields={draft.fields} + errors={errors} + usedNames={used} + hideNames={inlineNames} + onChange={(fields) => update({ fields })} + /> + <ErrorList + errors={ + showSectionErrors ? fieldsSectionErrors(errors) : [] + } + /> + </Section> + + <ErrorList errors={showSectionErrors ? otherErrors(errors) : []} /> + </div> + ); +} diff --git a/nt-fe/features/proposal-templates/draft.test.ts b/nt-fe/features/proposal-templates/draft.test.ts new file mode 100644 index 000000000..18215ba62 --- /dev/null +++ b/nt-fe/features/proposal-templates/draft.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "bun:test"; +import { + type ArgEntry, + argNodeToJson, + draftToManifest, + emptyDraft, + jsonToArgNode, + jsonToDraft, + makeFieldDraft, + manifestToDraft, + normalizeFields, +} from "./draft"; +import { type Manifest, parseManifest } from "./manifest"; + +const guestbook = { + version: 1, + id: "guestbook-tip", + title: "Guestbook Tip", + binding: { + receiver_id: "guestbook.near", + method_name: "add_message", + deposit: "1", + gas: "30000000000000", + }, + fields: [ + { name: "message", label: "Message", type: "text" }, + { name: "tip", label: "Tip", type: "uint", required: true }, + { name: "author", label: "Author", type: "account", required: true }, + ], + args: { + app: "trezu", + text: "{{message}}", + amount: "{{tip}}", + meta: '{"by":"{{author}}"}', + }, + summary: "Tip {{tip}}", +}; + +/** Parse a fixture, push it through the draft, re-parse — both ends are normalized manifests. */ +function roundTrip(input: unknown): { original: Manifest; reparsed: Manifest } { + const original = parseManifest(input); + if (!original.success) { + throw new Error("fixture must be a valid manifest"); + } + const draft = manifestToDraft(original.data); + const reparsed = parseManifest(draftToManifest(draft)); + if (!reparsed.success) { + throw new Error( + `round-trip produced an invalid manifest: ${JSON.stringify(reparsed.error.issues)}`, + ); + } + return { original: original.data, reparsed: reparsed.data }; +} + +describe("jsonToArgNode / argNodeToJson", () => { + it("round-trips primitives, arrays, nested objects, and placeholder strings", () => { + const values: unknown[] = [ + null, + "static", + "{{field}}", + '{"receiver_id":"{{receiver}}"}', + 42, + true, + ["a", 1, false, null], + { a: "x", b: { c: ["{{y}}"] }, d: [] }, + {}, + ]; + for (const value of values) { + expect(argNodeToJson(jsonToArgNode(value))).toEqual(value); + } + }); + + it("preserves object key order", () => { + const node = jsonToArgNode({ z: 1, a: 2, m: 3 }); + expect( + node.kind === "object" && node.entries.map((e) => e.key), + ).toEqual(["z", "a", "m"]); + }); +}); + +describe("manifestToDraft / draftToManifest", () => { + it("round-trips the guestbook template through the draft losslessly", () => { + const { original, reparsed } = roundTrip(guestbook); + expect(reparsed).toEqual(original); + }); + + it("round-trips field options, validation, and typed defaults of every type", () => { + const { original, reparsed } = roundTrip({ + ...guestbook, + fields: [ + { + name: "amount", + label: "Amount", + type: "amount", + required: true, + validation: { min: "1", max: "100" }, + }, + { + name: "memo", + label: "Memo", + type: "text", + default: "hi", + validation: { pattern: "^h" }, + }, + { name: "count", label: "Count", type: "number", default: 5 }, + { name: "agree", label: "Agree", type: "bool", default: true }, + { + name: "chain", + label: "Chain", + type: "select", + options: ["eth", "base"], + default: "eth", + }, + ], + args: { + amount: "{{amount}}", + memo: "{{memo}}", + count: "{{count}}", + agree: "{{agree}}", + chain: "{{chain}}", + }, + summary: "static", + }); + expect(reparsed).toEqual(original); + }); + + it("preserves optional meta (description, icon, summary)", () => { + const { reparsed } = roundTrip({ + ...guestbook, + description: "A demo", + icon: "coin", + }); + expect(reparsed.description).toBe("A demo"); + expect(reparsed.icon).toBe("coin"); + expect(reparsed.summary).toBe("Tip {{tip}}"); + }); + + it("omits blank optional meta from the serialized manifest", () => { + const draft = emptyDraft(); + draft.id = "x"; + draft.title = "X"; + const manifest = draftToManifest(draft); + expect("description" in manifest).toBe(false); + expect("icon" in manifest).toBe(false); + expect("summary" in manifest).toBe(false); + }); +}); + +describe("jsonToDraft (lenient, for Code → Visual mid-edit)", () => { + it("tolerates a partial manifest, filling blanks and gas/deposit defaults", () => { + const draft = jsonToDraft({ id: "x", fields: [{ name: "a" }] }); + expect(draft.id).toBe("x"); + expect(draft.title).toBe(""); + expect(draft.binding.receiver_id).toBe(""); + expect(draft.binding.deposit).toBe("0"); + expect(draft.binding.gas).toBe("30000000000000"); + expect(draft.fields).toHaveLength(1); + expect(draft.fields[0].name).toBe("a"); + // An unknown/missing field type degrades to "text" rather than throwing. + expect(draft.fields[0].type).toBe("text"); + expect(draft.args).toEqual([]); + }); + + it("falls back gracefully for non-object input", () => { + expect(jsonToDraft(null).fields).toEqual([]); + expect(jsonToDraft("nope").id).toBe(""); + }); +}); + +describe("normalizeFields (args-first auto-derive)", () => { + function argsFrom(obj: Record<string, unknown>): ArgEntry[] { + const node = jsonToArgNode(obj); + return node.kind === "object" ? node.entries : []; + } + + it("creates a field per new placeholder in args, with a Title-Cased label", () => { + const draft = normalizeFields({ + ...emptyDraft(), + args: argsFrom({ amount: "{{amount}}", to: "{{tx_hash}}" }), + }); + const byName = Object.fromEntries( + draft.fields.map((field) => [field.name, field]), + ); + expect(Object.keys(byName).sort()).toEqual(["amount", "tx_hash"]); + expect(byName.tx_hash.label).toBe("Tx Hash"); + expect(byName.amount.type).toBe("text"); + }); + + it("picks up placeholders from the summary and composed strings", () => { + const draft = normalizeFields({ + ...emptyDraft(), + summary: "Tip {{amount}}", + args: argsFrom({ msg: '{"to":"{{rcv}}"}' }), + }); + expect(draft.fields.map((field) => field.name).sort()).toEqual([ + "amount", + "rcv", + ]); + }); + + it("leaves an existing field and its config untouched", () => { + const existing = { + ...makeFieldDraft(), + name: "amount", + label: "Custom", + required: true, + }; + const draft = normalizeFields({ + ...emptyDraft(), + fields: [existing], + args: argsFrom({ amount: "{{amount}}" }), + }); + expect(draft.fields).toHaveLength(1); + expect(draft.fields[0].label).toBe("Custom"); + expect(draft.fields[0].required).toBe(true); + }); + + it("keeps unreferenced fields and returns the same reference when nothing changed", () => { + const normalized = normalizeFields({ + ...emptyDraft(), + fields: [{ ...makeFieldDraft(), name: "unused", label: "Unused" }], + args: argsFrom({ amount: "{{amount}}" }), + }); + expect(normalized.fields.map((field) => field.name).sort()).toEqual([ + "amount", + "unused", + ]); + // Re-running with nothing new to add returns the same object (no needless re-render). + expect(normalizeFields(normalized)).toBe(normalized); + }); +}); diff --git a/nt-fe/features/proposal-templates/draft.ts b/nt-fe/features/proposal-templates/draft.ts new file mode 100644 index 000000000..286cf768a --- /dev/null +++ b/nt-fe/features/proposal-templates/draft.ts @@ -0,0 +1,363 @@ +/** + * Editable draft model for the visual constructor, and lossless conversion to/from a manifest. + * + * The visual builder edits a `ManifestDraft` (flat strings for the meta/binding, a list for fields, + * and an `ArgNode` tree mirroring `args`) rather than raw JSON. `manifestToDraft` hydrates it from a + * parsed manifest (e.g. when switching from Code mode), and `draftToManifest` serializes it back to + * a manifest object that `parseManifest` then validates — so the builder shares one source of truth + * with code mode and can never emit a shape the backend would reject. + * + * Round-trip is the contract: `draftToManifest(manifestToDraft(m))` re-parses to `m`. The unit tests + * pin that for a representative template, every field type, validations, and optional meta fields. + */ +import { + MANIFEST_FIELD_TYPES, + type Manifest, + type ManifestFieldType, + manifestPlaceholders, +} from "./manifest"; + +/** + * A node in the `args` tree — an editable mirror of a JSON value. String leaves may embed + * `{{field}}` placeholders (the fill engine substitutes them); the builder decides how to present a + * string (plain / single field-ref / templated) but the model just stores the raw string. + */ +export type ArgNode = + | { kind: "object"; entries: ArgEntry[] } + | { kind: "array"; items: ArgItem[] } + | { kind: "string"; value: string } + | { kind: "number"; value: number } + | { kind: "boolean"; value: boolean } + | { kind: "null" }; + +/** A keyed object entry. `id` is a stable client id for React keys; never serialized. */ +export interface ArgEntry { + id: string; + key: string; + value: ArgNode; +} + +/** A keyed array item. `id` is a stable client id for React keys; never serialized. */ +export interface ArgItem { + id: string; + value: ArgNode; +} + +/** A fresh args object entry for the builder's "Add Key". */ +export function makeArgEntry(): ArgEntry { + return { + id: crypto.randomUUID(), + key: "", + value: { kind: "string", value: "" }, + }; +} + +/** A fresh args array item wrapping `value`, for the builder's "Add Item". */ +export function makeArgItem(value: ArgNode): ArgItem { + return { id: crypto.randomUUID(), value }; +} + +/** Build an args node from a parsed JSON value. */ +export function jsonToArgNode(value: unknown): ArgNode { + if (value === null) { + return { kind: "null" }; + } + if (typeof value === "string") { + return { kind: "string", value }; + } + if (typeof value === "number") { + return { kind: "number", value }; + } + if (typeof value === "boolean") { + return { kind: "boolean", value }; + } + if (Array.isArray(value)) { + return { + kind: "array", + items: value.map((item) => ({ + id: crypto.randomUUID(), + value: jsonToArgNode(item), + })), + }; + } + if (typeof value === "object") { + return { + kind: "object", + entries: Object.entries(value as Record<string, unknown>).map( + ([key, child]) => ({ + id: crypto.randomUUID(), + key, + value: jsonToArgNode(child), + }), + ), + }; + } + // undefined / function / symbol never appear in parsed JSON; collapse to null defensively. + return { kind: "null" }; +} + +/** Serialize an args node back to a JSON value. */ +export function argNodeToJson(node: ArgNode): unknown { + switch (node.kind) { + case "null": + return null; + case "string": + return node.value; + case "number": + return node.value; + case "boolean": + return node.value; + case "array": + return node.items.map((item) => argNodeToJson(item.value)); + case "object": + return argEntriesToJson(node.entries); + } +} + +function argEntriesToJson(entries: ArgEntry[]): Record<string, unknown> { + const result: Record<string, unknown> = {}; + for (const entry of entries) { + result[entry.key] = argNodeToJson(entry.value); + } + return result; +} + +/** A field row in the builder. Optional manifest bits become empty strings / `[]` / `undefined`. */ +export interface FieldDraft { + /** Stable client-side id for React keys + per-row UI state. Never serialized to the manifest. */ + key: string; + name: string; + label: string; + type: ManifestFieldType; + required: boolean; + help: string; + /** The typed default value, or `undefined` for none. The builder coerces input per `type`. */ + default: unknown; + /** Choices for a `select` field. */ + options: string[]; + validation: { min: string; max: string; pattern: string }; +} + +/** The whole editable manifest. `args` holds the top-level object's entries (args is always an object). */ +export interface ManifestDraft { + id: string; + title: string; + description: string; + icon: string; + summary: string; + binding: { + receiver_id: string; + method_name: string; + deposit: string; + gas: string; + }; + fields: FieldDraft[]; + args: ArgEntry[]; +} + +/** A blank field row (with a fresh client key) for the builder's "Add Field". */ +export function makeFieldDraft(): FieldDraft { + return { + key: crypto.randomUUID(), + name: "", + label: "", + type: "text", + required: false, + help: "", + default: undefined, + options: [], + validation: { min: "", max: "", pattern: "" }, + }; +} + +/** A blank draft for a new template (sensible gas/deposit defaults; everything else empty). */ +export function emptyDraft(): ManifestDraft { + return { + id: "", + title: "", + description: "", + icon: "", + summary: "", + binding: { + receiver_id: "", + method_name: "", + deposit: "0", + gas: "30000000000000", + }, + fields: [], + args: [], + }; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function isManifestFieldType(value: unknown): value is ManifestFieldType { + return ( + typeof value === "string" && + (MANIFEST_FIELD_TYPES as readonly string[]).includes(value) + ); +} + +/** Best-effort field draft from arbitrary JSON: unknown/blank bits become safe defaults. */ +function rawFieldToDraft(value: unknown): FieldDraft { + const field = (value && typeof value === "object" ? value : {}) as Record< + string, + unknown + >; + const validation = ( + field.validation && typeof field.validation === "object" + ? field.validation + : {} + ) as Record<string, unknown>; + return { + key: crypto.randomUUID(), + name: asString(field.name), + label: asString(field.label), + type: isManifestFieldType(field.type) ? field.type : "text", + required: field.required === true, + help: asString(field.help), + default: field.default, + options: Array.isArray(field.options) + ? field.options.filter( + (option): option is string => typeof option === "string", + ) + : [], + validation: { + min: asString(validation.min), + max: asString(validation.max), + pattern: asString(validation.pattern), + }, + }; +} + +function draftToField(field: FieldDraft): Record<string, unknown> { + // Only emit `options`/`validation` bits the field's type actually permits, so switching a + // field's type in the builder can't leave stale data that the schema then rejects. + const numeric = + field.type === "uint" || + field.type === "amount" || + field.type === "number"; + const patternable = field.type === "text" || field.type === "number"; + const validation: Record<string, unknown> = {}; + if (numeric && field.validation.min) { + validation.min = field.validation.min; + } + if (numeric && field.validation.max) { + validation.max = field.validation.max; + } + if (patternable && field.validation.pattern) { + validation.pattern = field.validation.pattern; + } + return { + name: field.name, + label: field.label, + type: field.type, + ...(field.required ? { required: true } : {}), + ...(field.help ? { help: field.help } : {}), + ...(field.default !== undefined ? { default: field.default } : {}), + ...(field.type === "select" && field.options.length > 0 + ? { options: field.options } + : {}), + ...(Object.keys(validation).length > 0 ? { validation } : {}), + }; +} + +/** + * Build a draft from arbitrary parsed JSON — tolerant of a partial or invalid manifest so switching + * Code → Visual works mid-edit (an empty id, missing binding, etc. hydrate as blanks). Only the JSON + * syntax has to be valid; the visual builder then surfaces what's still incomplete. + */ +export function jsonToDraft(value: unknown): ManifestDraft { + const root = ( + value && typeof value === "object" && !Array.isArray(value) ? value : {} + ) as Record<string, unknown>; + const binding = ( + root.binding && typeof root.binding === "object" ? root.binding : {} + ) as Record<string, unknown>; + const argsNode = jsonToArgNode( + root.args && typeof root.args === "object" ? root.args : {}, + ); + return { + id: asString(root.id), + title: asString(root.title), + description: asString(root.description), + icon: asString(root.icon), + summary: asString(root.summary), + binding: { + receiver_id: asString(binding.receiver_id), + method_name: asString(binding.method_name), + deposit: asString(binding.deposit) || "0", + gas: asString(binding.gas) || "30000000000000", + }, + fields: Array.isArray(root.fields) + ? root.fields.map(rawFieldToDraft) + : [], + args: argsNode.kind === "object" ? argsNode.entries : [], + }; +} + +/** Hydrate a draft from a validated manifest (e.g. the initial edit state). */ +export function manifestToDraft(manifest: Manifest): ManifestDraft { + return jsonToDraft(manifest); +} + +/** + * Serialize a draft to a manifest object. Not validated here — the caller runs `parseManifest` so + * the visual and code paths share one validator. Optional meta fields are omitted when blank. + */ +export function draftToManifest(draft: ManifestDraft): Record<string, unknown> { + return { + version: 1, + id: draft.id, + title: draft.title, + ...(draft.description ? { description: draft.description } : {}), + ...(draft.icon ? { icon: draft.icon } : {}), + binding: { + receiver_id: draft.binding.receiver_id, + method_name: draft.binding.method_name, + deposit: draft.binding.deposit, + gas: draft.binding.gas, + }, + fields: draft.fields.map(draftToField), + args: argEntriesToJson(draft.args), + ...(draft.summary ? { summary: draft.summary } : {}), + }; +} + +/** "first_name" / "tx-hash" → "First Name" / "Tx Hash" — a readable default label. */ +function titleCase(name: string): string { + return name + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim(); +} + +/** + * Args-first: the call drives the inputs. Ensure every `{{placeholder}}` referenced in `args` or + * `summary` has a field — missing ones are auto-created with a Title-Cased label. Existing fields + * (and their config) are left untouched, and unreferenced fields are kept (they may be intentional + * or visual-only). Pure; returns the same draft reference when nothing changed. Call on every edit. + */ +/** Field names referenced by a `{{placeholder}}` in args or summary — the "used" (wired) inputs. */ +export function usedFieldNames(draft: ManifestDraft): Set<string> { + return new Set<string>([ + ...manifestPlaceholders(argEntriesToJson(draft.args)), + ...manifestPlaceholders(draft.summary), + ]); +} + +export function normalizeFields(draft: ManifestDraft): ManifestDraft { + const used = usedFieldNames(draft); + const existing = new Set(draft.fields.map((field) => field.name)); + const added: FieldDraft[] = []; + for (const name of used) { + if (name && !existing.has(name)) { + added.push({ ...makeFieldDraft(), name, label: titleCase(name) }); + } + } + return added.length > 0 + ? { ...draft, fields: [...draft.fields, ...added] } + : draft; +} diff --git a/nt-fe/features/proposal-templates/error-map.test.ts b/nt-fe/features/proposal-templates/error-map.test.ts new file mode 100644 index 000000000..d3fc8c689 --- /dev/null +++ b/nt-fe/features/proposal-templates/error-map.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { errorFor, isInlineErrorPath } from "./error-map"; + +const errors = [ + "id: id must be a tag-safe slug", + "binding.receiver_id: binding.receiver_id must not be blank", + "fields.0.name: required", +]; + +describe("errorFor", () => { + it("strips the leading field token so the message reads cleanly", () => { + expect(errorFor(errors, "id")).toBe("Must be a tag-safe slug"); + expect(errorFor(errors, "binding.receiver_id")).toBe( + "Must not be blank", + ); + }); + + it("capitalizes a message that has no leading field token", () => { + expect(errorFor(errors, "fields.0.name")).toBe("Required"); + }); + + it("strips only a dotted path or meta key, never an ordinary leading word", () => { + expect( + errorFor( + ["binding.gas: binding.gas must be an integer string"], + "binding.gas", + ), + ).toBe("Must be an integer string"); + // a descriptive message with no path token is only capitalized, not mis-stripped + expect( + errorFor( + ["args: references a placeholder no field declares"], + "args", + ), + ).toBe("References a placeholder no field declares"); + }); + + it("does not match a different path that shares a prefix", () => { + expect(errorFor(["icon: bad"], "id")).toBeUndefined(); + // "fields" must not swallow "fields.0.name". + expect(errorFor(["fields.0.name: x"], "fields")).toBeUndefined(); + }); + + it("returns undefined when nothing matches", () => { + expect(errorFor(errors, "title")).toBeUndefined(); + }); +}); + +describe("isInlineErrorPath", () => { + it("claims the paths an input actually renders", () => { + for (const path of [ + "id", + "title", + "binding.receiver_id", + "binding.gas", + "fields.0.name", + "fields.2.label", + "fields.0.validation.min", + "fields.1.validation.pattern", + "fields.0.default", + "fields.0.options", + ]) { + expect(isInlineErrorPath(path)).toBe(true); + } + }); + + it("does NOT claim cross-field refines or the unique-names rule (they need the catch-all)", () => { + for (const path of [ + "fields", // unique field names + "fields.0.validation", // min/max cross-bound refine + "fields.0.required", // `required` on a bool + "fields.0.type", + "args", + "version", + ]) { + expect(isInlineErrorPath(path)).toBe(false); + } + }); +}); diff --git a/nt-fe/features/proposal-templates/error-map.ts b/nt-fe/features/proposal-templates/error-map.ts new file mode 100644 index 000000000..8b8eaca43 --- /dev/null +++ b/nt-fe/features/proposal-templates/error-map.ts @@ -0,0 +1,46 @@ +/** + * Look up a validation error by its path and return a display-ready message. Errors arrive as + * `path: message` lines (from `manifestErrorMessages`), and the zod messages tend to repeat the + * field name ("binding.receiver_id must not be blank"). The visual builder shows each message right + * under its input, so we strip that leading field token — it reads "Must not be blank" beside the + * field it's already visually attached to. Code mode keeps the full pathful lines. + */ +export function errorFor(errors: string[], path: string): string | undefined { + const line = errors.find((entry) => entry.startsWith(`${path}:`)); + if (!line) { + return undefined; + } + return cleanMessage(line.slice(path.length + 1).trim()); +} + +/** + * Whether an error path is rendered inline under a specific input in the visual builder. The + * routing uses this so anything NOT shown inline (cross-field refines like `fields.N.validation` + * bounds or `fields.N.required`, the unique-names rule, etc.) still reaches a visible catch-all + * list — no error can be silently dropped while Save stays disabled. + */ +export function isInlineErrorPath(path: string): boolean { + if (["id", "title", "description", "summary"].includes(path)) { + return true; + } + if (/^binding\.(receiver_id|method_name|deposit|gas)$/.test(path)) { + return true; + } + return /^fields\.\d+\.(name|label|default|options|validation\.(min|max|pattern))$/.test( + path, + ); +} + +// Leading tokens in this schema's messages are always a field path — a dotted token +// (`binding.receiver_id`, `validation.min`) or a top-level meta key. Strip only those: a +// deterministic rule that can't mis-strip an ordinary word, unlike a verb-list heuristic. +const META_KEYS = ["id", "title", "description", "summary", "icon"]; + +/** Drop a leading field-path token ("title must …", "binding.x is …") and capitalize. */ +function cleanMessage(message: string): string { + const [first, ...rest] = message.split(" "); + const isFieldToken = + rest.length > 0 && (first.includes(".") || META_KEYS.includes(first)); + const body = isFieldToken ? rest.join(" ") : message; + return body.length > 0 ? body[0].toUpperCase() + body.slice(1) : body; +} diff --git a/nt-fe/features/proposal-templates/form-schema.test.ts b/nt-fe/features/proposal-templates/form-schema.test.ts new file mode 100644 index 000000000..7414a775b --- /dev/null +++ b/nt-fe/features/proposal-templates/form-schema.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "bun:test"; +import { buildFormSchema, defaultValuesFor } from "./form-schema"; +import { + type Manifest, + manifestErrorMessages, + parseManifest, +} from "./manifest"; + +function manifest(raw: unknown): Manifest { + const result = parseManifest(raw); + if (!result.success) { + throw new Error(manifestErrorMessages(result.error).join("; ")); + } + return result.data; +} + +/** A minimal valid manifest carrying the given fields (args stays `{}` so the cross-check passes). */ +function withFields(fields: unknown[]): Manifest { + return manifest({ + version: 1, + id: "t", + title: "T", + binding: { + receiver_id: "x.near", + method_name: "m", + deposit: "1", + gas: "1", + }, + fields, + args: {}, + }); +} + +function schemaFor(field: unknown) { + return buildFormSchema(withFields([field])); +} + +describe("buildFormSchema", () => { + it("requires a required field but accepts a valid value", () => { + const schema = schemaFor({ + name: "amount", + label: "Amount", + type: "uint", + required: true, + }); + expect(schema.safeParse({ amount: "" }).success).toBe(false); + expect(schema.safeParse({ amount: "100" }).success).toBe(true); + }); + + it("requires a required account field", () => { + const schema = schemaFor({ + name: "acct", + label: "Account", + type: "account", + required: true, + }); + expect(schema.safeParse({ acct: "" }).success).toBe(false); + expect(schema.safeParse({ acct: "alice.near" }).success).toBe(true); + }); + + it("requires a required select field", () => { + const schema = schemaFor({ + name: "net", + label: "Net", + type: "select", + options: ["eth", "base"], + required: true, + }); + expect(schema.safeParse({ net: "" }).success).toBe(false); + expect(schema.safeParse({ net: "eth" }).success).toBe(true); + }); + + it("lets an optional field be empty", () => { + const schema = schemaFor({ name: "memo", label: "Memo", type: "text" }); + expect(schema.safeParse({ memo: "" }).success).toBe(true); + }); + + it("rejects a non-integer for a uint field", () => { + const schema = schemaFor({ + name: "amount", + label: "Amount", + type: "uint", + }); + expect(schema.safeParse({ amount: "1.5" }).success).toBe(false); + expect(schema.safeParse({ amount: "101021" }).success).toBe(true); + }); + + it("validates a NEAR account field", () => { + const schema = schemaFor({ + name: "acct", + label: "Account", + type: "account", + }); + expect(schema.safeParse({ acct: "alice.near" }).success).toBe(true); + expect(schema.safeParse({ acct: "Bad Account" }).success).toBe(false); + }); + + it("restricts a select field to its options", () => { + const schema = schemaFor({ + name: "net", + label: "Net", + type: "select", + options: ["eth", "base"], + }); + expect(schema.safeParse({ net: "base" }).success).toBe(true); + expect(schema.safeParse({ net: "sol" }).success).toBe(false); + }); + + it("requires a bool field to be a boolean", () => { + const schema = schemaFor({ name: "flag", label: "Flag", type: "bool" }); + expect(schema.safeParse({ flag: true }).success).toBe(true); + expect(schema.safeParse({ flag: "yes" }).success).toBe(false); + }); + + it("rejects invalid JSON for a json field", () => { + const schema = schemaFor({ name: "cfg", label: "Cfg", type: "json" }); + expect(schema.safeParse({ cfg: '{"a":1}' }).success).toBe(true); + expect(schema.safeParse({ cfg: "{not json" }).success).toBe(false); + }); + + it("enforces a validation.pattern on a text field", () => { + const schema = schemaFor({ + name: "code", + label: "Code", + type: "text", + validation: { pattern: "^[A-Z]{3}$" }, + }); + expect(schema.safeParse({ code: "ABC" }).success).toBe(true); + expect(schema.safeParse({ code: "abc" }).success).toBe(false); + }); + + it("enforces validation.min/max with Big (precise past 2^53)", () => { + const schema = schemaFor({ + name: "amount", + label: "Amount", + type: "uint", + validation: { min: "10", max: "100" }, + }); + expect(schema.safeParse({ amount: "50" }).success).toBe(true); + expect(schema.safeParse({ amount: "5" }).success).toBe(false); + expect(schema.safeParse({ amount: "101" }).success).toBe(false); + + const huge = schemaFor({ + name: "amount", + label: "Amount", + type: "uint", + validation: { min: "9007199254740993" }, // 2^53 + 1 + }); + expect(huge.safeParse({ amount: "9007199254740994" }).success).toBe( + true, + ); + expect(huge.safeParse({ amount: "9007199254740992" }).success).toBe( + false, + ); + }); +}); + +describe("defaultValuesFor", () => { + it("coerces each field's default to its form representation, else a typed empty", () => { + const m = withFields([ + { name: "a", label: "A", type: "text" }, + { name: "b", label: "B", type: "bool" }, + { name: "c", label: "C", type: "number", default: 5 }, + { name: "d", label: "D", type: "bool", default: true }, + { name: "e", label: "E", type: "json", default: { k: 1 } }, + { name: "f", label: "F", type: "uint", default: "100" }, + ]); + expect(defaultValuesFor(m)).toEqual({ + a: "", + b: false, + c: "5", + d: true, + e: '{"k":1}', + f: "100", + }); + }); + + it("coerces non-bool fields without a default to an empty string", () => { + const m = withFields([ + { name: "a", label: "A", type: "text" }, + { name: "b", label: "B", type: "account" }, + { name: "c", label: "C", type: "select", options: ["x", "y"] }, + ]); + expect(defaultValuesFor(m)).toEqual({ a: "", b: "", c: "" }); + }); +}); diff --git a/nt-fe/features/proposal-templates/form-schema.ts b/nt-fe/features/proposal-templates/form-schema.ts new file mode 100644 index 000000000..00e09b568 --- /dev/null +++ b/nt-fe/features/proposal-templates/form-schema.ts @@ -0,0 +1,178 @@ +/** + * Derive a react-hook-form validation schema — and matching initial values — from a validated + * manifest, so the rendered form checks each field by its declared `type`, `required` flag, and + * `validation` rules before the engine ever builds a proposal. Pure (no React), so the + * field-type -> validator mapping unit-tests in isolation, like the engine. + * + * Form value representation: every field is a string in form state except `bool` (a real boolean). + * Amounts therefore stay strings and are bound-checked with `Big`, never a JS number, so u128 + * limits hold past 2^53. Empty string means "not filled" — only a `required` field rejects it. + */ + +import { z } from "zod"; +import Big from "@/lib/big"; +import type { FieldValues } from "./build-proposal"; +import type { Manifest, ManifestField } from "./manifest"; + +// NEAR account-id shape (sync; on-chain existence is the input component's concern). +const NEAR_ACCOUNT_RE = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; +const INTEGER_RE = /^\d+$/; + +/** + * Wrap a check so an empty (unfilled) value passes; `required` is enforced separately. Accepts + * `unknown` because later refines run on a widened (`z.ZodTypeAny`) schema whose value type is no + * longer `string`; the `typeof` guard narrows it back before the inner check. + */ +const allowEmpty = + (check: (value: string) => boolean) => + (value: unknown): boolean => + typeof value !== "string" || value === "" || check(value); + +function isParseableJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +function safeRegExp(pattern: string): RegExp | null { + try { + return new RegExp(pattern); + } catch { + return null; + } +} + +/** Whether a non-empty value matches the field's `type`. */ +function passesType(field: ManifestField, value: string): boolean { + switch (field.type) { + case "account": + return NEAR_ACCOUNT_RE.test(value); + case "uint": + case "amount": + return INTEGER_RE.test(value); + case "number": + return Number.isFinite(Number(value)); + case "select": + return (field.options ?? []).includes(value); + case "json": + return isParseableJson(value); + default: + // token, text: any non-empty string is accepted. + return true; + } +} + +function typeMessage(field: ManifestField): string { + switch (field.type) { + case "account": + return `${field.label} must be a valid NEAR account`; + case "uint": + case "amount": + return `${field.label} must be a whole number`; + case "number": + return `${field.label} must be a number`; + case "select": + return `${field.label}: choose a listed option`; + case "json": + return `${field.label} must be valid JSON`; + default: + return `${field.label} is invalid`; + } +} + +/** Numeric bound check via Big; a non-numeric value just skips (its type check already failed). */ +function meetsBound(value: string, bound: string, atLeast: boolean): boolean { + try { + const cmp = Big(value).cmp(Big(bound)); + return atLeast ? cmp >= 0 : cmp <= 0; + } catch { + return true; + } +} + +function fieldValueSchema(field: ManifestField): z.ZodTypeAny { + if (field.type === "bool") { + return z.boolean(); + } + + const required = field.required === true; + let schema: z.ZodTypeAny = z + .string() + .refine((value) => value !== "" || !required, { + message: `${field.label} is required`, + }) + .refine( + allowEmpty((value) => passesType(field, value)), + { + message: typeMessage(field), + }, + ); + + const pattern = field.validation?.pattern + ? safeRegExp(field.validation.pattern) + : null; + if (pattern) { + schema = schema.refine( + allowEmpty((value) => pattern.test(value)), + { + message: `${field.label} does not match the required pattern`, + }, + ); + } + + const min = field.validation?.min; + if (min !== undefined) { + schema = schema.refine( + allowEmpty((value) => meetsBound(value, min, true)), + { message: `${field.label} must be at least ${min}` }, + ); + } + + const max = field.validation?.max; + if (max !== undefined) { + schema = schema.refine( + allowEmpty((value) => meetsBound(value, max, false)), + { message: `${field.label} must be at most ${max}` }, + ); + } + + return schema; +} + +/** Build the react-hook-form zod schema that validates a manifest's filled values. */ +export function buildFormSchema(manifest: Manifest) { + const shape: Record<string, z.ZodTypeAny> = {}; + for (const field of manifest.fields) { + shape[field.name] = fieldValueSchema(field); + } + return z.object(shape); +} + +/** A field's `default` coerced to its form representation (string state, except `bool`). */ +function defaultValueFor(field: ManifestField): unknown { + if (field.type === "bool") { + return typeof field.default === "boolean" ? field.default : false; + } + if (field.default === undefined) { + return ""; + } + if (typeof field.default === "string") { + return field.default; + } + // A `number` default (e.g. 5) -> "5"; a `json` default (object) -> its JSON text. + return field.type === "json" + ? JSON.stringify(field.default) + : String(field.default); +} + +/** Initial form values: each field's coerced `default`, else a type-appropriate empty. */ +export function defaultValuesFor(manifest: Manifest): FieldValues { + const values: FieldValues = {}; + for (const field of manifest.fields) { + values[field.name] = defaultValueFor(field); + } + return values; +} diff --git a/nt-fe/features/proposal-templates/hooks/use-custom-requests-enabled.ts b/nt-fe/features/proposal-templates/hooks/use-custom-requests-enabled.ts new file mode 100644 index 000000000..e73265e60 --- /dev/null +++ b/nt-fe/features/proposal-templates/hooks/use-custom-requests-enabled.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTreasury } from "@/hooks/use-treasury"; +import { useNear } from "@/stores/near-store"; +import { getCustomRequestsEnabled, setCustomRequestsEnabled } from "../api"; + +function customRequestsKey( + treasuryId: string | undefined, + accountId: string | null | undefined, +) { + return ["custom-requests-enabled", treasuryId, accountId] as const; +} + +/** + * Whether the Custom Requests feature is on for the current treasury. Gates the sidebar's Request + * Templates section and the Developer settings card. Same enable-conditions as the templates query + * (signed-in, non-guest treasury). + */ +export function useCustomRequestsEnabled() { + const { accountId } = useNear(); + const { treasuryId, isGuestTreasury } = useTreasury(); + const enabled = !!treasuryId && !!accountId && !isGuestTreasury; + + return useQuery({ + queryKey: customRequestsKey(treasuryId, accountId), + queryFn: () => getCustomRequestsEnabled(treasuryId as string), + enabled, + staleTime: 1000 * 30, + }); +} + +/** Flip the Custom Requests flag (ChangePolicy-gated server-side), then refresh the cached value. */ +export function useSetCustomRequestsEnabled() { + const { accountId } = useNear(); + const { treasuryId } = useTreasury(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (nextEnabled: boolean) => + setCustomRequestsEnabled(treasuryId as string, nextEnabled), + onSuccess: (resolvedEnabled) => { + queryClient.setQueryData( + customRequestsKey(treasuryId, accountId), + resolvedEnabled, + ); + }, + }); +} diff --git a/nt-fe/features/proposal-templates/hooks/use-proposal-template-mutations.ts b/nt-fe/features/proposal-templates/hooks/use-proposal-template-mutations.ts new file mode 100644 index 000000000..8899f1295 --- /dev/null +++ b/nt-fe/features/proposal-templates/hooks/use-proposal-template-mutations.ts @@ -0,0 +1,66 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTreasury } from "@/hooks/use-treasury"; +import { useNear } from "@/stores/near-store"; +import { + type CreateProposalTemplateInput, + createProposalTemplate, + deleteProposalTemplate, + type UpdateProposalTemplateInput, + updateProposalTemplate, +} from "../api"; + +/** + * Invalidate the template list after a mutation, using the exact queryKey `useProposalTemplates` + * reads with (`["proposal-templates", treasuryId, accountId]`) so the invalidation is targeted + * rather than a prefix sweep of every account's cache for the treasury. + */ +function useInvalidateTemplates() { + const queryClient = useQueryClient(); + const { treasuryId } = useTreasury(); + const { accountId } = useNear(); + return () => + queryClient.invalidateQueries({ + queryKey: ["proposal-templates", treasuryId, accountId], + }); +} + +/** Create a proposal template for the current treasury, invalidating the list on success. */ +export function useCreateProposalTemplate() { + const { treasuryId } = useTreasury(); + const invalidate = useInvalidateTemplates(); + + return useMutation({ + mutationFn: (input: CreateProposalTemplateInput) => + createProposalTemplate(treasuryId as string, input), + onSuccess: invalidate, + }); +} + +/** Update a template by id, invalidating the list on success. */ +export function useUpdateProposalTemplate() { + const { treasuryId } = useTreasury(); + const invalidate = useInvalidateTemplates(); + + return useMutation({ + mutationFn: ({ + id, + input, + }: { + id: string; + input: UpdateProposalTemplateInput; + }) => updateProposalTemplate(treasuryId as string, id, input), + onSuccess: invalidate, + }); +} + +/** Delete a template by id, invalidating the list on success. */ +export function useDeleteProposalTemplate() { + const { treasuryId } = useTreasury(); + const invalidate = useInvalidateTemplates(); + + return useMutation({ + mutationFn: (id: string) => + deleteProposalTemplate(treasuryId as string, id), + onSuccess: invalidate, + }); +} diff --git a/nt-fe/features/proposal-templates/hooks/use-proposal-templates.ts b/nt-fe/features/proposal-templates/hooks/use-proposal-templates.ts new file mode 100644 index 000000000..e4b8840ac --- /dev/null +++ b/nt-fe/features/proposal-templates/hooks/use-proposal-templates.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTreasury } from "@/hooks/use-treasury"; +import { useNear } from "@/stores/near-store"; +import { getProposalTemplates } from "../api"; + +/** Fetch the current treasury's proposal templates (gated on a signed-in, non-guest treasury). */ +export function useProposalTemplates() { + const { accountId } = useNear(); + const { treasuryId, isGuestTreasury } = useTreasury(); + const enabled = !!treasuryId && !!accountId && !isGuestTreasury; + + return useQuery({ + queryKey: ["proposal-templates", treasuryId, accountId], + queryFn: () => getProposalTemplates(treasuryId as string), + enabled, + staleTime: 1000 * 10, + }); +} diff --git a/nt-fe/features/proposal-templates/manifest.test.ts b/nt-fe/features/proposal-templates/manifest.test.ts new file mode 100644 index 000000000..ef6ea16a4 --- /dev/null +++ b/nt-fe/features/proposal-templates/manifest.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it } from "bun:test"; +import { + manifestErrorMessages, + manifestPlaceholders, + parseManifest, + validateManifestText, +} from "./manifest"; + +const validManifest = { + version: 1, + id: "guestbook-tip", + title: "Guestbook Tip", + binding: { + receiver_id: "guestbook.near", + method_name: "add_message", + deposit: "1", + gas: "30000000000000", + }, + fields: [{ name: "amount", label: "Amount", type: "uint", required: true }], + args: { amount: "{{amount}}" }, + summary: "Tip {{amount}}", +}; + +describe("parseManifest", () => { + it("accepts a valid manifest and trims its string fields", () => { + const result = parseManifest({ + ...validManifest, + id: " guestbook-tip ", + title: " Guestbook Tip ", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe("guestbook-tip"); + expect(result.data.title).toBe("Guestbook Tip"); + } + }); + + it("rejects a non-object", () => { + expect(parseManifest("not an object").success).toBe(false); + }); + + it("rejects a blank id", () => { + expect(parseManifest({ ...validManifest, id: " " }).success).toBe( + false, + ); + }); + + it("rejects a missing binding field", () => { + const result = parseManifest({ + ...validManifest, + binding: { method_name: "add_message", deposit: "1", gas: "1" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects a non-integer deposit (u128 must be an integer string)", () => { + const result = parseManifest({ + ...validManifest, + binding: { ...validManifest.binding, deposit: "1.5" }, + }); + expect(result.success).toBe(false); + }); + + it("rejects an unknown field type", () => { + const result = parseManifest({ + ...validManifest, + fields: [{ name: "x", label: "X", type: "bogus" }], + }); + expect(result.success).toBe(false); + }); + + it("rejects a non-integer validation.min on a field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "uint", + validation: { min: "1.5" }, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects args that is not an object", () => { + expect( + parseManifest({ ...validManifest, args: ["not", "a", "record"] }) + .success, + ).toBe(false); + }); + + it("attributes a dangling args placeholder to `args`, not `summary`", () => { + const result = parseManifest({ + ...validManifest, + args: { amount: "{{missing}}" }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + expect(messages.some((m) => m.startsWith("args:"))).toBe(true); + } + }); + + it("attributes a dangling summary placeholder to `summary`, not `args`", () => { + const result = parseManifest({ + ...validManifest, + summary: "Tip {{amount}} to {{missing}}", + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + expect(messages.some((m) => m.startsWith("summary:"))).toBe(true); + } + }); + + it("rejects a default that does not match the field type", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "uint", + default: "banana", + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + expect( + messages.some((m) => m.startsWith("fields.0.default:")), + ).toBe(true); + } + }); + + it("rejects an empty-string default on a text field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { name: "amount", label: "Amount", type: "text", default: "" }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects a select field without options", () => { + const result = parseManifest({ + ...validManifest, + fields: [{ name: "amount", label: "Amount", type: "select" }], + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + expect( + messages.some((m) => m.startsWith("fields.0.options:")), + ).toBe(true); + } + }); + + it("rejects (without crashing) a select field that has a default but no options", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "select", + default: "x", + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects options on a non-select field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "text", + options: ["a"], + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + expect( + messages.some((m) => m.startsWith("fields.0.options:")), + ).toBe(true); + } + }); + + it("rejects an id that is not a tag-safe slug", () => { + expect(parseManifest({ ...validManifest, id: "bad id]" }).success).toBe( + false, + ); + }); + + it("rejects a reserved route slug as the id", () => { + expect(parseManifest({ ...validManifest, id: "create" }).success).toBe( + false, + ); + }); + + it("rejects a field name that is not a {{placeholder}}-safe identifier", () => { + const result = parseManifest({ + ...validManifest, + fields: [{ name: "user-id", label: "User", type: "text" }], + args: {}, + summary: "Static summary", + }); + expect(result.success).toBe(false); + }); + + it("rejects duplicate field names", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { name: "amount", label: "Amount", type: "uint" }, + { name: "amount", label: "Amount 2", type: "text" }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects an unsupported manifest version", () => { + expect(parseManifest({ ...validManifest, version: 2 }).success).toBe( + false, + ); + }); + + it("accepts a validation.pattern on a text field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "text", + validation: { pattern: "^a" }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("rejects a validation.pattern on a non-text/number field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "uint", + validation: { pattern: "^a" }, + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + // The refine is on the field schema, so the path is rooted at the field index. + expect( + messages.some((m) => + m.startsWith("fields.0.validation.pattern:"), + ), + ).toBe(true); + } + }); + + it("rejects a validation.pattern that is not a valid regex", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "text", + validation: { pattern: "[abc" }, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects validation.min/max on a non-numeric field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "text", + validation: { min: "5" }, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects required: true on a bool field", () => { + const result = parseManifest({ + ...validManifest, + fields: [ + { + name: "amount", + label: "Amount", + type: "bool", + required: true, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("accepts a type-appropriate default for each field type", () => { + const fields = [ + { name: "amount", label: "Amount", type: "bool", default: true }, + { name: "amount", label: "Amount", type: "number", default: 5 }, + { + name: "amount", + label: "Amount", + type: "json", + default: { a: 1 }, + }, + { + name: "amount", + label: "Amount", + type: "select", + options: ["a", "b"], + default: "a", + }, + ]; + for (const field of fields) { + const result = parseManifest({ ...validManifest, fields: [field] }); + expect(result.success).toBe(true); + } + }); +}); + +describe("manifestErrorMessages", () => { + it("returns `path: message` lines for each issue", () => { + const result = parseManifest({ ...validManifest, id: "" }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = manifestErrorMessages(result.error); + expect(messages.length).toBeGreaterThan(0); + expect(messages.some((m) => m.startsWith("id:"))).toBe(true); + } + }); +}); + +describe("manifestPlaceholders", () => { + it("extracts {{name}} from object values, arrays, and nested structures", () => { + const found = manifestPlaceholders({ + a: "{{x}}", + b: "static text", + c: ["{{y}}", { d: "{{z}}" }], + }); + expect([...found].sort()).toEqual(["x", "y", "z"]); + }); + + it("ignores escaped {{{{literal}}}} sequences", () => { + expect(manifestPlaceholders("a {{{{literal}}}} b").size).toBe(0); + }); + + it("tolerates undefined / non-string / null values", () => { + expect(manifestPlaceholders(undefined).size).toBe(0); + expect(manifestPlaceholders({ n: 5, b: true, z: null }).size).toBe(0); + expect([...manifestPlaceholders(["{{x}}"])]).toEqual(["x"]); + }); +}); + +describe("validateManifestText", () => { + it("treats empty / whitespace-only input as pristine (no manifest, no errors)", () => { + expect(validateManifestText("")).toEqual({ errors: [] }); + expect(validateManifestText(" \n ")).toEqual({ errors: [] }); + }); + + it("reports invalid JSON without throwing", () => { + const result = validateManifestText("{ not json"); + expect(result.manifest).toBeUndefined(); + expect(result.errors).toEqual(["Manifest is not valid JSON"]); + }); + + it("surfaces schema errors as `path: message` lines for valid JSON", () => { + const result = validateManifestText( + JSON.stringify({ ...validManifest, id: "" }), + ); + expect(result.manifest).toBeUndefined(); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((m) => m.startsWith("id:"))).toBe(true); + }); + + it("returns the parsed (and trimmed) manifest on success", () => { + const result = validateManifestText( + JSON.stringify({ ...validManifest, id: " demo-tip " }), + ); + expect(result.errors).toEqual([]); + expect(result.manifest?.id).toBe("demo-tip"); + }); +}); diff --git a/nt-fe/features/proposal-templates/manifest.ts b/nt-fe/features/proposal-templates/manifest.ts new file mode 100644 index 000000000..82f9e2b82 --- /dev/null +++ b/nt-fe/features/proposal-templates/manifest.ts @@ -0,0 +1,391 @@ +/** + * Manifest schema for the custom-proposal framework (frontend source of truth). + * + * A *manifest* is a JSON form definition: a techy DAO member authors it, regular members fill the + * rendered form to file a generic SputnikDAO `FunctionCall` proposal. This module is the single + * validated shape both the authoring UI (validate before save) and the form engine (interpret the + * fields) build on. + * + * It mirrors — and deliberately extends — the backend `validate_manifest` (nt-be + * `handlers/proposal_templates.rs`): the backend enforces only the structural shape and stores a + * normalized copy, while this validator additionally (a) checks each field's `type`, (b) requires + * the amount strings `binding.deposit`/`gas` and per-field `validation.min`/`max` to be digit + * strings, and (c) cross-checks that every `{{field}}` placeholder in `args`/`summary` is declared + * in `fields`. A manifest that passes here therefore also passes the backend. + * + * u128 safety: those amount strings are validated as digit strings, never `number`, so NEAR + * amounts (which exceed 2^53) survive untruncated. + */ +import { z } from "zod"; + +/** Field input types a manifest may declare; each maps to a trezu form component. */ +export const MANIFEST_FIELD_TYPES = [ + "account", + "token", + "uint", + "amount", + "text", + "number", + "select", + "bool", + "json", +] as const; + +export const manifestFieldTypeSchema = z.enum(MANIFEST_FIELD_TYPES); +export type ManifestFieldType = z.infer<typeof manifestFieldTypeSchema>; + +const nonBlankString = (label: string) => + z.string().trim().min(1, `${label} must not be blank`); + +const INTEGER_RE = /^\d+$/; + +const integerString = (label: string) => + z.string().regex(INTEGER_RE, `${label} must be an integer string`); + +// The manifest id is wrapped in the `[trezu-tmpl:<id>]` description tag, so it must be tag-safe. +const TAG_SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/; + +/** + * Slugs reserved for static routes under `/custom-templates/` — a template id can't claim one, or + * its `/custom-templates/<id>` page would be shadowed by the static route and become unreachable. + * Keep this in sync with the backend `validate_manifest` reserved list. + */ +export const RESERVED_TEMPLATE_SLUGS = ["create", "new", "about"]; + +const tagSafeId = z + .string() + .trim() + .regex( + TAG_SAFE_ID_RE, + "id must be a tag-safe slug ([A-Za-z0-9_-]) so the [trezu-tmpl:<id>] tag stays parseable", + ) + .refine((id) => !RESERVED_TEMPLATE_SLUGS.includes(id.toLowerCase()), { + message: `id must not be a reserved route slug (${RESERVED_TEMPLATE_SLUGS.join(", ")})`, + }); + +// Field names must match the {{placeholder}} charset, so every field is referenceable from args. +const FIELD_NAME_RE = /^[a-zA-Z0-9_]+$/; +const fieldName = z + .string() + .trim() + .regex( + FIELD_NAME_RE, + "field.name must be a {{placeholder}}-safe identifier ([A-Za-z0-9_])", + ); + +/** Whether `pattern` compiles as a regex — a typo'd pattern must fail authoring, not drop silently. */ +function isValidRegExp(pattern: string): boolean { + try { + return new RegExp(pattern) instanceof RegExp; + } catch { + return false; + } +} + +/** Optional per-field constraints. min/max are digit strings to stay u128/BigInt-safe. */ +export const manifestFieldValidationSchema = z + .object({ + min: integerString("validation.min").optional(), + max: integerString("validation.max").optional(), + pattern: z.string().optional(), + }) + .refine( + (validation) => + validation.pattern === undefined || + isValidRegExp(validation.pattern), + { + message: "validation.pattern is not a valid regular expression", + path: ["pattern"], + }, + ); + +const manifestFieldBase = z.object({ + name: fieldName, + label: nonBlankString("field.label"), + type: manifestFieldTypeSchema, + required: z.boolean().optional(), + // zod v4: z.unknown() accepts a missing key, so no `.optional()` is needed (the key may be + // omitted entirely); its shape is tied to `type` by the refinements below, not left arbitrary. + default: z.unknown(), + help: z.string().optional(), + /** Choices for a `select` field (required there, forbidden elsewhere). */ + options: z.array(z.string()).optional(), + validation: manifestFieldValidationSchema.optional(), +}); + +type ManifestFieldBase = z.infer<typeof manifestFieldBase>; + +/** `options` is required for `select` and forbidden on every other type. */ +function optionsMatchType(field: ManifestFieldBase): boolean { + return field.type === "select" + ? Array.isArray(field.options) && field.options.length > 0 + : field.options === undefined; +} + +/** `validation.pattern` (a regex) only applies to free-text inputs. */ +function patternMatchesType(field: ManifestFieldBase): boolean { + return ( + field.validation?.pattern === undefined || + field.type === "text" || + field.type === "number" + ); +} + +/** A field's `default`, when present, must match its declared `type`. */ +function defaultMatchesType(field: ManifestFieldBase): boolean { + const value = field.default; + if (value === undefined) { + return true; + } + switch (field.type) { + case "account": + case "token": + case "text": + // A provided default must be meaningful, not empty ("" == no default). + return typeof value === "string" && value.length > 0; + case "uint": + case "amount": + return typeof value === "string" && INTEGER_RE.test(value); + case "number": + return typeof value === "number"; + case "bool": + return typeof value === "boolean"; + case "select": + // `options` is not guaranteed here: zod runs every refine, so a `select` field + // missing `options` still reaches this branch even though `optionsMatchType` flagged + // it — dropping the guard would throw on `{ type: 'select', default: 'x' }`. + return ( + typeof value === "string" && + (field.options ?? []).includes(value) + ); + case "json": + // json defaults are opaque — z.unknown() already admits any value, nothing to check. + return true; + default: + return false; + } +} + +/** `validation.min`/`max` (numeric bounds) only apply to numeric fields. */ +function boundsMatchType(field: ManifestFieldBase): boolean { + const hasBound = + field.validation?.min !== undefined || + field.validation?.max !== undefined; + return ( + !hasBound || + field.type === "uint" || + field.type === "amount" || + field.type === "number" + ); +} + +/** `required` is meaningless on a `bool` (a toggle always submits true or false). */ +function requiredAllowed(field: ManifestFieldBase): boolean { + return !(field.type === "bool" && field.required === true); +} + +export const manifestFieldSchema = manifestFieldBase + .refine(optionsMatchType, { + message: + "`options` is required for a `select` field and forbidden otherwise", + path: ["options"], + }) + .refine(patternMatchesType, { + message: + "`validation.pattern` is only valid on `text` or `number` fields", + path: ["validation", "pattern"], + }) + .refine(boundsMatchType, { + message: + "`validation.min`/`max` are only valid on numeric fields (uint, amount, number)", + path: ["validation"], + }) + .refine(requiredAllowed, { + message: "`required` has no meaning on a `bool` field", + path: ["required"], + }) + .refine(defaultMatchesType, { + message: "`default` does not match the field's `type`", + path: ["default"], + }); +export type ManifestField = z.infer<typeof manifestFieldSchema>; + +/** The on-chain call the template produces. Fixed in v1 (no user-driven binding). */ +export const manifestBindingSchema = z.object({ + receiver_id: nonBlankString("binding.receiver_id"), + method_name: nonBlankString("binding.method_name"), + deposit: integerString("binding.deposit"), + gas: integerString("binding.gas"), +}); +export type ManifestBinding = z.infer<typeof manifestBindingSchema>; + +/** + * The shape of one `{{name}}` placeholder, kept as a source string so extraction and substitution + * build their regexes from a single definition (a charset tweak can't leave one behind). The + * look-arounds skip escaped `{{{{literal}}}}` sequences so a literal `{{` is never mistaken for a + * placeholder; capture group 1 is the field name. + */ +const PLACEHOLDER_PATTERN = + "(?<!\\{)\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}(?!\\})"; + +/** Extraction: matches every placeholder (group 1 = name). */ +const PLACEHOLDER_RE = new RegExp(PLACEHOLDER_PATTERN, "g"); + +/** Substitution: an escaped `{{{{...}}}}` (group 1) OR a placeholder (group 2 = name). */ +const SUBSTITUTE_RE = new RegExp( + `\\{\\{\\{\\{([\\s\\S]*?)\\}\\}\\}\\}|${PLACEHOLDER_PATTERN}`, + "g", +); + +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === "string") { + out.push(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out); + return; + } + if (value !== null && typeof value === "object") { + for (const item of Object.values(value)) collectStrings(item, out); + } +} + +/** + * Field names referenced via `{{name}}` anywhere in `value` — a manifest's `args` object or its + * `summary` string. Exported so the form engine interpolates against the exact same extraction + * this validator checks. Called per source so a dangling token is attributable to args vs summary. + */ +export function manifestPlaceholders(value: unknown): Set<string> { + const strings: string[] = []; + collectStrings(value, strings); + const names = new Set<string>(); + for (const text of strings) { + for (const match of text.matchAll(PLACEHOLDER_RE)) { + const name = match[1]; + if (name) { + names.add(name); + } + } + } + return names; +} + +/** + * Replace each `{{name}}` in `text` with `resolve(name)`. An escaped `{{{{...}}}}` collapses to a + * literal `{{...}}` and is never treated as a placeholder — the same escape `manifestPlaceholders` + * skips — so validation (extraction) and the form engine (substitution) never disagree on what a + * placeholder is. + */ +export function substitutePlaceholders( + text: string, + resolve: (name: string) => string, +): string { + return text.replace( + SUBSTITUTE_RE, + (_full, escaped: string | undefined, name: string | undefined) => + escaped === undefined ? resolve(name ?? "") : `{{${escaped}}}`, + ); +} + +/** Whether every `{{placeholder}}` in `source` is declared as a field `name`. */ +function placeholdersDeclared( + fields: ReadonlyArray<{ name: string }>, + source: unknown, +): boolean { + const declared = new Set(fields.map((field) => field.name)); + return [...manifestPlaceholders(source)].every((name) => + declared.has(name), + ); +} + +export const manifestSchema = z + .object({ + // v1 is the only shape this validator describes; a future version needs its own schema. + version: z.literal(1), + id: tagSafeId, + title: nonBlankString("title"), + description: nonBlankString("description").optional(), + icon: nonBlankString("icon").optional(), + binding: manifestBindingSchema, + fields: z.array(manifestFieldSchema), + // `args` must be a plain object (z.record admits arrays in zod v4; z.object does not). + args: z.object({}).catchall(z.unknown()), + summary: nonBlankString("summary").optional(), + }) + .refine( + (manifest) => placeholdersDeclared(manifest.fields, manifest.args), + { + message: "references a {{placeholder}} that no field declares", + path: ["args"], + }, + ) + .refine( + (manifest) => placeholdersDeclared(manifest.fields, manifest.summary), + { + message: "references a {{placeholder}} that no field declares", + path: ["summary"], + }, + ) + .refine( + (manifest) => { + const names = manifest.fields.map((field) => field.name); + return new Set(names).size === names.length; + }, + { + message: "field `name`s must be unique within a manifest", + path: ["fields"], + }, + ); +export type Manifest = z.infer<typeof manifestSchema>; + +/** Validate an authored manifest (e.g. pasted JSON). Returns zod's safe-parse result. */ +export function parseManifest(input: unknown) { + return manifestSchema.safeParse(input); +} + +/** + * A manifest's `id` (its route slug + `[trezu-tmpl:<id>]` tag) read without full validation — for + * listing templates and resolving `/custom-templates/<slug>` to a template. Mirrors the backend's + * `manifest_id` generated column (`manifest->>'id'`). Returns undefined if the shape is off. + */ +export function manifestIdOf(manifest: unknown): string | undefined { + if (manifest && typeof manifest === "object" && "id" in manifest) { + const id = (manifest as { id: unknown }).id; + return typeof id === "string" ? id : undefined; + } + return undefined; +} + +/** Flatten a manifest validation error into `path: message` lines for the authoring UI. */ +export function manifestErrorMessages(error: z.ZodError): string[] { + return error.issues.map((issue) => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }); +} + +/** + * Validate manifest JSON *text* (the authoring textarea): parse the JSON, then validate the shape. + * Returns the validated manifest on success, or human-readable error lines (invalid JSON, or schema + * issues). Empty/whitespace input yields no manifest and no errors — a pristine form, not an error. + */ +export function validateManifestText(text: string): { + manifest?: Manifest; + errors: string[]; +} { + if (!text.trim()) { + return { errors: [] }; + } + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return { errors: ["Manifest is not valid JSON"] }; + } + const result = parseManifest(json); + if (!result.success) { + return { errors: manifestErrorMessages(result.error) }; + } + return { manifest: result.data, errors: [] }; +} diff --git a/nt-fe/features/proposal-templates/types.ts b/nt-fe/features/proposal-templates/types.ts new file mode 100644 index 000000000..7d138087a --- /dev/null +++ b/nt-fe/features/proposal-templates/types.ts @@ -0,0 +1,19 @@ +/** + * A stored proposal template as returned by the backend + * (`GET /api/treasury/{dao_id}/proposal-templates`, serialized camelCase). + * + * `manifest` is the raw JSON the author saved — validate it with `parseManifest` before handing it + * to the form (`buildFormSchema`) or the engine (`buildTemplateProposal`). + */ +export interface ProposalTemplate { + id: string; + daoId: string; + name: string; + description: string | null; + manifest: unknown; + enabled: boolean; + pinned: boolean; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/skills/trezu-custom-proposal-template/README.md b/skills/trezu-custom-proposal-template/README.md new file mode 100644 index 000000000..d1233b863 --- /dev/null +++ b/skills/trezu-custom-proposal-template/README.md @@ -0,0 +1,41 @@ +# trezu custom-proposal-template skill + +A portable AI **skill** that helps you build a custom proposal template for +[trezu](https://trezu.app) (a SputnikDAO treasury UI on NEAR) by describing what you +want in plain English. The assistant produces the template **manifest** (JSON) you paste +into trezu — no coding. + +Everything the assistant needs is in [`SKILL.md`](SKILL.md); it's self-contained, so it +works without access to the trezu codebase. + +## Install / use it + +**Claude Code** +- Copy this folder into your skills directory: + - all projects: `~/.claude/skills/trezu-custom-proposal-template/` + - one project: `<project>/.claude/skills/trezu-custom-proposal-template/` +- Then just ask: *"Build a trezu custom proposal template that pays USDC to a recipient + each month."* Claude matches the skill by its description and follows it. + +**Claude.ai / Claude in your workspace (Skills)** +- Where Skills can be uploaded, add this folder (or `SKILL.md`) as a custom skill, then + ask the same way. + +**Claude chat (no Skills feature) / Codex / other assistants** +- Paste the contents of `SKILL.md` into the chat (or your assistant's custom-instructions + / system-prompt slot), then describe the proposal you want. The file is plain Markdown + and works as a self-contained prompt anywhere. + +## What you'll get, and what to do with it + +The assistant asks for the contract + method, which arguments are fixed vs member-filled, +and the deposit/gas — then returns a manifest like: + +```json +{ "version": 1, "id": "usdc-payout", "title": "USDC Payout", "...": "..." } +``` + +In trezu: open your DAO → **Custom → New template → Code tab**, paste the manifest, and +**Save** (use the **Visual** tab to review it as a form first). Authoring a template +requires your DAO's `ChangePolicy` permission — if you don't have it, hand the manifest +to a member who does. diff --git a/skills/trezu-custom-proposal-template/SKILL.md b/skills/trezu-custom-proposal-template/SKILL.md new file mode 100644 index 000000000..4f8db46e2 --- /dev/null +++ b/skills/trezu-custom-proposal-template/SKILL.md @@ -0,0 +1,141 @@ +--- +name: trezu-custom-proposal-template +description: Build a custom proposal template for trezu / a SputnikDAO on NEAR — a JSON "manifest" that becomes a reusable form members fill to file a FunctionCall proposal. Use when someone wants a repeatable proposal form (recurring payment, token mint, contract call, set a value) and needs the manifest JSON to paste into trezu. Self-contained; no repo access needed. +--- + +# Build a trezu custom proposal template + +trezu (a SputnikDAO treasury UI on NEAR) lets a DAO turn a recurring on-chain action +into a **reusable form**. The form is defined by a JSON **manifest**. Your job: from a +plain-English description of what the DAO wants to do, produce a valid manifest the user +pastes into trezu (**New template → Code tab → paste → Save**, then optionally tweak in +the Visual tab). Authoring needs the DAO's `ChangePolicy` permission; if the user lacks +it, they hand the manifest to someone who does. + +You do **not** need any code or repo — this file is the complete spec. + +## How to work with the user + +Ask for (and infer sensible defaults where you can): + +1. **The on-chain call** — which contract (`receiver_id`) and method (`method_name`)? + e.g. `guestbook.near` / `set_greeting`, `usdc.near` / `ft_transfer`. +2. **The arguments that method takes**, and for each: **fixed** (same every time) or + **filled by the member** each time? Member-filled ones become form inputs. +3. **deposit / gas** — yoctoNEAR attached deposit and gas. Default `deposit: "0"`, + `gas: "30000000000000"` (30 Tgas) if they don't know; a method that needs storage + often wants `deposit: "1250000000000000000000"` (~0.00125 N). + +Then produce the manifest and briefly explain what each member-filled input is. + +## The manifest + +```json +{ + "version": 1, + "id": "guestbook-tip", // slug [A-Za-z0-9_-], unique in the DAO; not create/new/about + "title": "Guestbook Tip", + "summary": "Tip {{amount}}", // optional one-liner; may use {{fields}} + "binding": { // the FIXED on-chain call + "receiver_id": "guestbook.near", + "method_name": "add_message", + "deposit": "1", // yoctoNEAR, INTEGER STRING + "gas": "30000000000000" // gas, INTEGER STRING + }, + "fields": [ // the form inputs members fill + { "name": "amount", "label": "Amount", "type": "uint", "required": true } + ], + "args": { "amount": "{{amount}}" } // method args; {{name}} -> that field's value +} +``` + +### Fields + +`{ "name", "label", "type", ... }`: + +- `name` — `[A-Za-z0-9_]` only (used as a `{{placeholder}}`), unique in the manifest. +- `label` — shown above the input. +- `type` — one of: + - `account` — a NEAR account id + - `token` — an omni token id like `base-0x…` + - `uint` / `amount` — a whole-number string in **base units**, u128-safe + - `number` — a plain number (for counts/ratios, **not** token amounts) + - `text` — free text + - `select` — dropdown (requires `options: ["a","b"]`) + - `bool` — a toggle + - `json` — JSON text +- optional: `required` (bool — **not allowed on `bool`**), `default` (must match the + type), `help` (hint text), `options` (required on `select`, forbidden elsewhere), + `validation: { min, max, pattern }` (`min`/`max` integer strings, numeric types only; + `pattern` a regex, `text`/`number` only). + +### Args and placeholders + +`args` is the literal arguments object. In any string value, `{{name}}` is replaced with +that field's value before filing: + +- **Direct**: `"amount": "{{amount}}"` +- **Static (fixed)**: `"app": "trezu"` +- **Composed / concatenated**: `"recipient": "{{first}}.{{last}}"` → `"alice.near"` +- **Field inside a JSON-string argument**: + `"msg": "{\"receiver_id\":\"{{receiver}}\"}"` + +Every `{{placeholder}}` must reference a declared field. `{{{{x}}}}` is an escaped +literal `{{x}}`, not a placeholder. + +### Rules (produce manifests that satisfy these) + +- `version` is `1`. +- `deposit`, `gas`, `validation.min`/`max`, and `uint`/`amount` values are **integer + strings in base units — no decimals**. NEAR amounts exceed 2^53, so never use JSON + numbers. `1.1 NEAR` = `"1100000000000000000000000"` yocto; FT amounts use the token's + smallest unit (USDC has 6 decimals → micro-USDC). +- Every `{{placeholder}}` (in `args` or `summary`) references a declared field. +- `select` needs `options`; `required` is invalid on `bool`; `default` matches `type`. + +## Worked examples + +**Simple — set a greeting (one input):** + +```json +{ + "version": 1, + "id": "set-greeting", + "title": "Set Greeting", + "binding": { "receiver_id": "guestbook.near", "method_name": "set_greeting", + "deposit": "0", "gas": "30000000000000" }, + "fields": [ { "name": "greeting", "label": "Greeting", "type": "text", "required": true } ], + "args": { "greeting": "{{greeting}}" }, + "summary": "Set greeting to {{greeting}}" +} +``` + +**Composed — a token transfer with a memo (mix of fixed + member inputs):** + +```json +{ + "version": 1, + "id": "usdc-payout", + "title": "USDC Payout", + "binding": { "receiver_id": "usdc.near", "method_name": "ft_transfer", + "deposit": "1", "gas": "30000000000000" }, + "fields": [ + { "name": "recipient", "label": "Recipient", "type": "account", "required": true }, + { "name": "amount", "label": "Amount (micro-USDC)", "type": "amount", "required": true }, + { "name": "note", "label": "Memo", "type": "text" } + ], + "args": { + "receiver_id": "{{recipient}}", + "amount": "{{amount}}", + "memo": "Payout: {{note}}" + }, + "summary": "Pay {{amount}} USDC to {{recipient}}" +} +``` + +## Deliver it + +Give the user the manifest JSON in a code block, name each member-filled input, and tell +them: open trezu → the DAO → **Custom → New template → Code tab**, paste, then **Save** +(switch to the **Visual** tab first if they want to review/edit it as a form). Remind +them authoring needs the DAO's `ChangePolicy` permission. From 2291ca02d206d9ee5bd3089443c3933f5ce88513 Mon Sep 17 00:00:00 2001 From: Bohdan Khorolets <bogdan@khorolets.com> Date: Mon, 29 Jun 2026 19:14:33 +0300 Subject: [PATCH 2/3] i18n: localize custom proposal templates; address PR #993 review - Translate the whole custom-templates feature (customTemplates namespace, 13 locales) instead of leaving it English-only - Tour copy via PageTourContent/pageTours.requestTemplates (was hardcoded JSX) - Sidebar pinned templates use a real <Link> href, not onClick-only - build-proposal docstring: correct the string-substitution vs typed-args contract - Drop the agent/portable skills; the DSL is maintained in docs/ --- .../author-custom-proposal-template/SKILL.md | 66 --------- CLAUDE.md | 2 +- docs/CUSTOM_PROPOSAL_TEMPLATES.md | 8 +- .../trezu-custom-proposal-template/README.md | 0 .../trezu-custom-proposal-template/SKILL.md | 0 nt-be/src/handlers/proposal_templates.rs | 23 +++ .../custom-templates/[slug]/edit/page.tsx | 40 ++--- .../custom-templates/[slug]/page.tsx | 16 +- .../custom-templates/create/page.tsx | 16 +- .../[treasuryId]/custom-templates/layout.tsx | 4 +- .../[treasuryId]/custom-templates/page.tsx | 73 ++++----- .../settings/components/developer-tab.tsx | 14 +- .../(treasury)/[treasuryId]/settings/page.tsx | 4 +- nt-fe/components/sidebar.tsx | 69 +++++---- .../features/onboarding/steps/page-tours.tsx | 10 +- .../proposal-templates/build-proposal.ts | 10 +- .../components/args-tree-editor.tsx | 41 +++-- .../components/fields-builder.tsx | 41 ++--- .../components/manifest-form.tsx | 9 +- .../components/template-editor.tsx | 32 ++-- .../components/visual-builder.tsx | 32 ++-- .../proposal-templates/form-schema.ts | 15 +- nt-fe/features/proposal-templates/manifest.ts | 38 ++++- nt-fe/messages/de.json | 140 +++++++++++++++++- nt-fe/messages/en.json | 140 +++++++++++++++++- nt-fe/messages/es.json | 140 +++++++++++++++++- nt-fe/messages/fr.json | 140 +++++++++++++++++- nt-fe/messages/he.json | 140 +++++++++++++++++- nt-fe/messages/id.json | 140 +++++++++++++++++- nt-fe/messages/ja.json | 140 +++++++++++++++++- nt-fe/messages/ko.json | 140 +++++++++++++++++- nt-fe/messages/pt.json | 140 +++++++++++++++++- nt-fe/messages/tr.json | 140 +++++++++++++++++- nt-fe/messages/uk.json | 140 +++++++++++++++++- nt-fe/messages/vi.json | 140 +++++++++++++++++- nt-fe/messages/zh.json | 140 +++++++++++++++++- 36 files changed, 2104 insertions(+), 279 deletions(-) delete mode 100644 .agents/skills/author-custom-proposal-template/SKILL.md rename {skills => docs}/trezu-custom-proposal-template/README.md (100%) rename {skills => docs}/trezu-custom-proposal-template/SKILL.md (100%) diff --git a/.agents/skills/author-custom-proposal-template/SKILL.md b/.agents/skills/author-custom-proposal-template/SKILL.md deleted file mode 100644 index aa422ac17..000000000 --- a/.agents/skills/author-custom-proposal-template/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: author-custom-proposal-template -description: Author or edit a trezu custom-proposal template manifest — the JSON DSL that renders a form members fill to file a SputnikDAO FunctionCall proposal. Use when writing a manifest, wiring fields to args with {{placeholders}}, picking field types, or fixing "must be a tag-safe slug" / "references a {{placeholder}} that no field declares" errors. ---- - -# Authoring a custom-proposal template manifest - -A manifest is JSON that defines a form. Members fill it; trezu files one SputnikDAO -`FunctionCall` proposal from it. Full reference: `docs/CUSTOM_PROPOSAL_TEMPLATES.md`. -Validator: `nt-fe/features/proposal-templates/manifest.ts` (`parseManifest`) — produce -manifests that pass it. - -## Shape - -```json -{ - "version": 1, - "id": "guestbook-tip", // tag-safe slug [A-Za-z0-9_-], unique per DAO, - // NOT create/new/about - "title": "Guestbook Tip", - "summary": "Tip {{amount}}", // optional; may use {{fields}} - "binding": { // the fixed on-chain call - "receiver_id": "guestbook.near", - "method_name": "add_message", - "deposit": "1", // yoctoNEAR, INTEGER STRING - "gas": "30000000000000" // INTEGER STRING - }, - "fields": [ // the form inputs - { "name": "amount", "label": "Amount", "type": "uint", "required": true } - ], - "args": { "amount": "{{amount}}" } // method args; {{name}} -> field value -} -``` - -## Rules that trip authors up - -- **`id`**: `[A-Za-z0-9_-]` only, and not a reserved route slug (`create`/`new`/`about`). -- **field `name`**: `[A-Za-z0-9_]` only (placeholder-safe), unique within the manifest. -- **`deposit`/`gas`/`validation.min`/`max` and `uint`/`amount` values are integer - strings** in base units — no decimals, never JSON numbers (NEAR amounts exceed 2^53). - `1.1 NEAR` is `"1100000000000000000000000"`. -- **Every `{{placeholder}}` in `args` or `summary` must reference a declared field**, or - parse fails ("references a {{placeholder}} that no field declares"). -- `required` is invalid on a `bool` field. `options` is required on `select`, forbidden - elsewhere. A `default` must match the field `type`. `validation.pattern` only on - `text`/`number`; `min`/`max` only on `uint`/`amount`/`number`. - -## Field types - -`account` · `token` (omni id like `base-0x…`) · `uint` · `amount` (both u128 integer -strings) · `number` (f64, for counts/ratios, not amounts) · `text` · `select` (needs -`options`) · `bool` · `json`. - -## Wiring fields into args - -- **Direct value**: `"amount": "{{amount}}"`. -- **Composed / concatenated**: `"recipient": "{{first}}.{{last}}"` → `"alice.near"`. -- **Field inside a JSON-string arg**: `"msg": "{\"receiver_id\":\"{{receiver}}\"}"`. -- **Static value**: a literal with no placeholder (`"app": "trezu"`). -- An input you collect but don't send anywhere (no arg, no summary) is valid but rare — - use only for acknowledgment toggles or forward-compat. - -## Output - -Emit the manifest JSON, then sanity-check it against the rules above (or `parseManifest` -if you can run it). For a worked non-trivial example, see the doc. diff --git a/CLAUDE.md b/CLAUDE.md index e9912ac74..cce8d8112 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,4 +10,4 @@ All coding guidelines, testing practices, and project conventions are documented - **Subscription System (AI Guide):** [docs/AI_SUBSCRIPTION_GUIDE.md](docs/AI_SUBSCRIPTION_GUIDE.md) - Architecture, key files, payment flows, fee calculations, and implementation details - **Pricing Reference:** [docs/PRICING.md](docs/PRICING.md) - Plan tiers, features, and database schema -- **Custom Proposal Templates:** [docs/CUSTOM_PROPOSAL_TEMPLATES.md](docs/CUSTOM_PROPOSAL_TEMPLATES.md) - the manifest DSL, architecture, args-first authoring, and ChangePolicy gating. An authoring agent skill lives at [.claude/skills/author-custom-proposal-template](.claude/skills/author-custom-proposal-template/SKILL.md) +- **Custom Proposal Templates:** [docs/CUSTOM_PROPOSAL_TEMPLATES.md](docs/CUSTOM_PROPOSAL_TEMPLATES.md) - the manifest DSL, architecture, args-first authoring, and ChangePolicy gating. diff --git a/docs/CUSTOM_PROPOSAL_TEMPLATES.md b/docs/CUSTOM_PROPOSAL_TEMPLATES.md index 85de58b0d..4db03d221 100644 --- a/docs/CUSTOM_PROPOSAL_TEMPLATES.md +++ b/docs/CUSTOM_PROPOSAL_TEMPLATES.md @@ -11,8 +11,7 @@ template produces still passes the DAO's normal permissions and approvals, so a never grants authority by itself. This doc is the canonical reference for the manifest DSL, the architecture, and the -authoring UI. The in-app `/<dao>/custom-templates/about` page and the -`author-custom-proposal-template` agent skill both derive from it. +authoring UI. The in-app `/<dao>/custom-templates/about` page derives from it. ## What it produces @@ -181,10 +180,9 @@ with blanks); Visual → Code serializes the draft back. Non-developers can generate a manifest by describing the proposal in plain English. The self-contained, distributable skill at -[`skills/trezu-custom-proposal-template/`](../skills/trezu-custom-proposal-template/SKILL.md) +[`docs/trezu-custom-proposal-template/`](./trezu-custom-proposal-template/SKILL.md) installs into Claude Code / Claude.ai / Codex (see its `README`) and produces a manifest -to paste into the **Code** tab — it needs no repo access. (The `.claude/skills/` skill, -by contrast, is for agents working *on* the trezu codebase.) +to paste into the **Code** tab — it needs no repo access. ## Permissions diff --git a/skills/trezu-custom-proposal-template/README.md b/docs/trezu-custom-proposal-template/README.md similarity index 100% rename from skills/trezu-custom-proposal-template/README.md rename to docs/trezu-custom-proposal-template/README.md diff --git a/skills/trezu-custom-proposal-template/SKILL.md b/docs/trezu-custom-proposal-template/SKILL.md similarity index 100% rename from skills/trezu-custom-proposal-template/SKILL.md rename to docs/trezu-custom-proposal-template/SKILL.md diff --git a/nt-be/src/handlers/proposal_templates.rs b/nt-be/src/handlers/proposal_templates.rs index 65b42da91..8b7133469 100644 --- a/nt-be/src/handlers/proposal_templates.rs +++ b/nt-be/src/handlers/proposal_templates.rs @@ -16,6 +16,7 @@ use near_api::AccountId; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; +use std::str::FromStr; use std::sync::Arc; use uuid::Uuid; @@ -155,6 +156,25 @@ fn validate_manifest(manifest: &Value) -> Result<Value, String> { let receiver_id = trimmed(binding, "receiver_id", "binding.receiver_id")?; let method_name = trimmed(binding, "method_name", "binding.method_name")?; + // `receiver_id`/`method_name` go straight into the on-chain FunctionCall, so reject free text + // (e.g. "I m the receiver" / "the method") that would file an un-callable proposal. Mirrors the + // frontend manifest validator (NEAR_ACCOUNT_RE + method-name rule in nt-fe manifest.ts). + AccountId::from_str(&receiver_id) + .map_err(|_| "manifest.binding.receiver_id must be a valid NEAR account id".to_string())?; + let method_ok = method_name + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && method_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'); + if !method_ok { + return Err( + "manifest.binding.method_name must be a valid contract method name ([A-Za-z0-9_], no spaces)" + .to_string(), + ); + } + if !obj.get("fields").map(Value::is_array).unwrap_or(false) { return Err("manifest.fields must be an array".to_string()); } @@ -966,6 +986,9 @@ mod tests { json!({ "id": "x", "title": "t", "binding": { "receiver_id": "a.near" }, "fields": [] }), json!({ "id": "x", "title": "t", "binding": binding.clone() }), // missing fields json!({ "id": "x", "title": "t", "binding": binding.clone(), "fields": {} }), // fields not array + // free-text receiver / method that can't name a real account or method + json!({ "id": "x", "title": "t", "binding": { "receiver_id": "I m the receiver", "method_name": "m" }, "fields": [] }), + json!({ "id": "x", "title": "t", "binding": { "receiver_id": "a.near", "method_name": "the method" }, "fields": [] }), ]; for case in rejected { assert!( diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx index d11ee8837..86b97c871 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx @@ -6,6 +6,7 @@ * `ChangePolicy`-gated update endpoint. Delete is confirmed in a dialog and returns to the index. A * non-author hitting save/delete gets the backend's 403 as a toast. */ +import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; @@ -31,6 +32,7 @@ import { manifestIdOf } from "@/features/proposal-templates/manifest"; import { useTreasury } from "@/hooks/use-treasury"; export default function EditTemplatePage() { + const t = useTranslations("customTemplates"); const router = useRouter(); const params = useParams(); const slug = params?.slug as string | undefined; @@ -59,12 +61,12 @@ export default function EditTemplatePage() { id: template.id, input: { name, manifest }, }); - toast.success("Template saved"); + toast.success(t("edit.toastSaved")); router.push( `/${treasuryId}/custom-templates/${manifestIdOf(updated.manifest)}`, ); } catch (error) { - toast.error(apiErrorMessage(error, "Failed to save template")); + toast.error(apiErrorMessage(error, t("edit.errSave"))); } } @@ -75,29 +77,29 @@ export default function EditTemplatePage() { setConfirmingDelete(false); try { await deleteTemplate.mutateAsync(template.id); - toast.success("Template deleted"); + toast.success(t("toastDeleted")); router.push(`/${treasuryId}/custom-templates`); } catch (error) { - toast.error(apiErrorMessage(error, "Failed to delete template")); + toast.error(apiErrorMessage(error, t("errDelete"))); } } return ( <PageComponentLayout - title="Request Templates" - description="Build reusable templates for custom request types." + title={t("pageTitle")} + description={t("pageDescription")} > <div className="mx-auto flex w-full max-w-4xl flex-col gap-4"> {isLoading ? ( <PageCard> <p className="text-muted-foreground text-sm"> - Loading… + {t("loading")} </p> </PageCard> ) : template ? ( <TemplateEditor key={template.id} - title="Edit Template" + title={t("edit.title")} onBack={() => router.push(`/${treasuryId}/custom-templates`) } @@ -107,7 +109,7 @@ export default function EditTemplatePage() { null, 2, )} - submitLabel="Save Changes" + submitLabel={t("edit.submit")} submitting={updateTemplate.isPending} onSubmit={handleUpdate} footer={ @@ -121,14 +123,14 @@ export default function EditTemplatePage() { } onClick={() => setConfirmingDelete(true)} > - Delete Template + {t("edit.deleteButton")} </Button> } /> ) : ( <PageCard> <p className="text-muted-foreground text-sm"> - Template not found. + {t("edit.notFound")} </p> </PageCard> )} @@ -140,13 +142,15 @@ export default function EditTemplatePage() { > <DialogContent className="max-w-md gap-4"> <DialogHeader> - <DialogTitle>Delete template</DialogTitle> + <DialogTitle>{t("deleteDialog.title")}</DialogTitle> </DialogHeader> <DialogDescription> - Delete{" "} - <span className="font-semibold">{template?.name}</span>? - Members will no longer be able to file proposals from - it. This cannot be undone. + {t.rich("deleteDialog.body", { + name: template?.name ?? "", + b: (chunks) => ( + <span className="font-semibold">{chunks}</span> + ), + })} </DialogDescription> <DialogFooter> <Button @@ -155,7 +159,9 @@ export default function EditTemplatePage() { disabled={deleteTemplate.isPending} onClick={handleDelete} > - {deleteTemplate.isPending ? "Deleting…" : "Delete"} + {deleteTemplate.isPending + ? t("deleteDialog.deleting") + : t("deleteDialog.confirm")} </Button> </DialogFooter> </DialogContent> diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx index 5aab220f2..ef7f8a5a4 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx @@ -7,6 +7,7 @@ * same gasless-relayer route the core proposals use, so a custom request behaves like a built-in one. */ import { ArrowLeft } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { PageCard } from "@/components/card"; @@ -27,6 +28,7 @@ import { useTreasuryPolicy } from "@/hooks/use-treasury-queries"; import { useNear } from "@/stores/near-store"; export default function CustomTemplatePage() { + const t = useTranslations("customTemplates"); const params = useParams(); const router = useRouter(); const slug = params?.slug as string | undefined; @@ -73,7 +75,7 @@ export default function CustomTemplatePage() { parsed.data, values, ); - await createProposal("Request filed", { + await createProposal(t("fill.proposalFiled"), { treasuryId, proposal: { description, kind }, proposalBond: policy?.proposal_bond ?? "0", @@ -86,26 +88,26 @@ export default function CustomTemplatePage() { return ( <PageComponentLayout - title="Request Templates" - description="Build reusable templates for custom request types." + title={t("pageTitle")} + description={t("pageDescription")} > <div className="mx-auto flex w-full max-w-[600px] flex-col gap-4"> {isLoading ? ( <PageCard> <p className="text-muted-foreground text-sm"> - Loading… + {t("loading")} </p> </PageCard> ) : !template ? ( <PageCard> <p className="text-muted-foreground text-sm"> - No template found for “{slug}”. + {t("fill.noTemplate", { slug: slug ?? "" })} </p> </PageCard> ) : parsed && !parsed.success ? ( <PageCard> <div className="text-destructive text-sm"> - <p>This template's manifest is invalid:</p> + <p>{t("fill.invalidManifest")}</p> <ul className="list-disc pl-5"> {manifestErrorMessages(parsed.error).map( (message) => ( @@ -125,7 +127,7 @@ export default function CustomTemplatePage() { `/${treasuryId}/custom-templates`, ) } - aria-label="Back" + aria-label={t("back")} className="text-muted-foreground transition-colors hover:text-foreground" > <ArrowLeft className="size-5" /> diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx index 4c0021e8e..fbdd43f1a 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx @@ -5,6 +5,7 @@ * Code tab for raw manifest JSON) validates live, then saves via the `ChangePolicy`-gated create * endpoint. Lives at the reserved `create` slug. */ +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { PageComponentLayout } from "@/components/page-component-layout"; @@ -15,6 +16,7 @@ import { manifestIdOf } from "@/features/proposal-templates/manifest"; import { useTreasury } from "@/hooks/use-treasury"; export default function CreateTemplatePage() { + const t = useTranslations("customTemplates"); const router = useRouter(); const { treasuryId } = useTreasury(); const createTemplate = useCreateProposalTemplate(); @@ -34,28 +36,28 @@ export default function CreateTemplatePage() { name, manifest, }); - toast.success("Template created"); + toast.success(t("create.toastCreated")); router.push( `/${treasuryId}/custom-templates/${manifestIdOf(created.manifest)}`, ); } catch (error) { - toast.error(apiErrorMessage(error, "Failed to create template")); + toast.error(apiErrorMessage(error, t("create.errCreate"))); } } return ( <PageComponentLayout - title="Request Templates" - description="Build reusable templates for custom request types." + title={t("pageTitle")} + description={t("pageDescription")} > <div className="mx-auto flex w-full max-w-4xl flex-col gap-4"> <TemplateEditor - title="New Template" + title={t("create.title")} onBack={() => router.push(`/${treasuryId}/custom-templates`) } - submitLabel="Create Template" - submittingLabel="Creating…" + submitLabel={t("create.submit")} + submittingLabel={t("create.submitting")} submitting={createTemplate.isPending} onSubmit={handleCreate} /> diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx index d7a08e617..1c7cd042a 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise<Metadata> { - return { title: "Request Templates" }; + const t = await getTranslations("customTemplates"); + return { title: t("pageTitle") }; } export default function CustomTemplatesLayout({ diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx index dec435e84..aa6d7347e 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx @@ -16,6 +16,7 @@ import { Plus, Trash2, } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; @@ -55,21 +56,20 @@ const HOW_IT_WORKS_URL = const MENU_ITEM_CLASS = "gap-3 px-3 py-2.5"; function HowItWorksLink() { + const t = useTranslations("customTemplates"); return ( - <Button - variant="ghost" - size="sm" - className="text-muted-foreground hover:text-foreground" - asChild - > + // Outline + default size so it reads as a real button and matches the height of the primary + // it sits beside (the empty-state "Create Template" and the list-header "Add New"). + <Button variant="outline" asChild> <a href={HOW_IT_WORKS_URL} target="_blank" rel="noreferrer"> - <CircleHelp className="size-4" /> How It Works + <CircleHelp className="size-4" /> {t("howItWorks")} </a> </Button> ); } function EmptyState({ onCreate }: { onCreate: () => void }) { + const t = useTranslations("customTemplates"); return ( <PageCard className="items-center gap-3 py-16 text-center"> <div className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground"> @@ -77,17 +77,16 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { </div> <div className="flex flex-col gap-1"> <h2 className="font-semibold text-base"> - Create your first template + {t("index.emptyTitle")} </h2> <p className="max-w-sm text-muted-foreground text-sm"> - Make a template for requests you send often. Your team - reuses it in one click. + {t("index.emptyDescription")} </p> </div> <div className="flex items-center gap-2"> <HowItWorksLink /> <Button onClick={onCreate}> - <Plus className="size-4" /> Create Template + <Plus className="size-4" /> {t("index.createTemplate")} </Button> </div> </PageCard> @@ -109,6 +108,7 @@ function TemplateRow({ onTogglePin, onDelete, }: TemplateRowProps) { + const t = useTranslations("customTemplates"); return ( <div className="group flex min-h-[75px] items-center justify-between gap-3 rounded-xl bg-[#FAFAF9] p-4 dark:bg-muted"> <div className="flex min-w-0 flex-col gap-0.5"> @@ -127,7 +127,7 @@ function TemplateRow({ <Button variant="ghost" size="icon-sm" - aria-label="Template actions" + aria-label={t("index.actions")} className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100" > <EllipsisVertical className="size-4" /> @@ -138,7 +138,7 @@ function TemplateRow({ onClick={onEdit} className={MENU_ITEM_CLASS} > - <Pencil className="size-4" /> Edit + <Pencil className="size-4" /> {t("index.edit")} </DropdownMenuItem> <DropdownMenuItem onClick={onTogglePin} @@ -146,12 +146,12 @@ function TemplateRow({ > {template.pinned ? ( <> - <PinOff className="size-4" /> Unpin Template + <PinOff className="size-4" />{" "} + {t("index.unpin")} </> ) : ( <> - <Pin className="size-4" /> Pin to the - Sidebar + <Pin className="size-4" /> {t("index.pin")} </> )} </DropdownMenuItem> @@ -159,17 +159,20 @@ function TemplateRow({ onClick={onDelete} className={MENU_ITEM_CLASS} > - <Trash2 className="size-4" /> Delete + <Trash2 className="size-4" /> {t("index.delete")} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> - <Button onClick={onCreateRequest}>Create Request</Button> + <Button onClick={onCreateRequest}> + {t("index.createRequest")} + </Button> </div> </div> ); } export default function CustomTemplatesIndexPage() { + const t = useTranslations("customTemplates"); const router = useRouter(); const { treasuryId } = useTreasury(); const { data: templates, isLoading } = useProposalTemplates(); @@ -190,7 +193,7 @@ export default function CustomTemplatesIndexPage() { { id: template.id, input: { pinned: !template.pinned } }, { onError: (error) => - toast.error(apiErrorMessage(error, "Could not update pin")), + toast.error(apiErrorMessage(error, t("index.errPin"))), }, ); } @@ -203,22 +206,22 @@ export default function CustomTemplatesIndexPage() { setConfirmingDelete(null); try { await deleteTemplate.mutateAsync(template.id); - toast.success("Template deleted"); + toast.success(t("toastDeleted")); } catch (error) { - toast.error(apiErrorMessage(error, "Failed to delete template")); + toast.error(apiErrorMessage(error, t("errDelete"))); } } return ( <PageComponentLayout - title="Request Templates" - description="Build reusable templates for custom request types." + title={t("pageTitle")} + description={t("pageDescription")} > <div className="mx-auto flex w-full max-w-4xl flex-col gap-4"> {isLoading ? ( <PageCard> <p className="text-muted-foreground text-sm"> - Loading… + {t("loading")} </p> </PageCard> ) : enabled.length === 0 ? ( @@ -227,7 +230,7 @@ export default function CustomTemplatesIndexPage() { <PageCard className="gap-4 p-5"> <div className="flex items-center justify-between gap-2"> <h2 className="font-semibold text-base"> - Templates + {t("index.heading")} </h2> <div className="flex items-center gap-2"> <HowItWorksLink /> @@ -235,7 +238,7 @@ export default function CustomTemplatesIndexPage() { variant="secondary" onClick={() => go("/create")} > - <Plus className="size-4" /> Add New + <Plus className="size-4" /> {t("index.addNew")} </Button> </div> </div> @@ -271,15 +274,15 @@ export default function CustomTemplatesIndexPage() { > <DialogContent className="max-w-md gap-4"> <DialogHeader> - <DialogTitle>Delete template</DialogTitle> + <DialogTitle>{t("deleteDialog.title")}</DialogTitle> </DialogHeader> <DialogDescription> - Delete{" "} - <span className="font-semibold"> - {confirmingDelete?.name} - </span> - ? Members will no longer be able to file proposals from - it. This cannot be undone. + {t.rich("deleteDialog.body", { + name: confirmingDelete?.name ?? "", + b: (chunks) => ( + <span className="font-semibold">{chunks}</span> + ), + })} </DialogDescription> <DialogFooter> <Button @@ -288,7 +291,9 @@ export default function CustomTemplatesIndexPage() { disabled={deleteTemplate.isPending} onClick={handleDelete} > - {deleteTemplate.isPending ? "Deleting…" : "Delete"} + {deleteTemplate.isPending + ? t("deleteDialog.deleting") + : t("deleteDialog.confirm")} </Button> </DialogFooter> </DialogContent> diff --git a/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx b/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx index 2de8b8fa4..c52659f36 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx @@ -7,6 +7,7 @@ import { CodeXml } from "lucide-react"; * the sidebar; the first time a treasury turns it on, we run the one-step onboarding tour pointing * at that freshly revealed item. */ +import { useTranslations } from "next-intl"; import { useNextStep } from "nextstepjs"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -24,6 +25,7 @@ import { import { useTreasury } from "@/hooks/use-treasury"; export function DeveloperTab() { + const t = useTranslations("customTemplates"); const { treasuryId } = useTreasury(); const { data: enabled, isLoading } = useCustomRequestsEnabled(); const setEnabled = useSetCustomRequestsEnabled(); @@ -67,7 +69,7 @@ export function DeveloperTab() { }, onError: (error) => toast.error( - apiErrorMessage(error, "Could not update the setting"), + apiErrorMessage(error, t("developer.errUpdate")), ), }); } @@ -79,11 +81,11 @@ export function DeveloperTab() { <CodeXml className="size-5" /> </div> <div className="flex flex-1 flex-col gap-1"> - <h3 className="font-semibold text-base">Custom Requests</h3> + <h3 className="font-semibold text-base"> + {t("developer.title")} + </h3> <p className="text-muted-foreground text-sm"> - Build your own request types when you need more than - payments and exchange. Add the fields you need and your - members can submit them like any other request. + {t("developer.description")} </p> </div> <Button @@ -91,7 +93,7 @@ export function DeveloperTab() { disabled={isLoading || setEnabled.isPending} onClick={() => toggle(!enabled)} > - {enabled ? "Disable" : "Enable"} + {enabled ? t("developer.disable") : t("developer.enable")} </Button> </div> </PageCard> diff --git a/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx index 62320855c..562e1a15d 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/settings/page.tsx @@ -65,7 +65,9 @@ function SettingsPageContent() { }, ] : []), - ...(showDeveloper ? [{ value: "developer", label: "Developer" }] : []), + ...(showDeveloper + ? [{ value: "developer", label: tTabs("developer") }] + : []), ]; return ( diff --git a/nt-fe/components/sidebar.tsx b/nt-fe/components/sidebar.tsx index 3c1682cca..c978882d1 100644 --- a/nt-fe/components/sidebar.tsx +++ b/nt-fe/components/sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { ChevronDown } from "lucide-react"; +import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useNextStep } from "nextstepjs"; @@ -52,6 +53,8 @@ interface NavLinkProps { badgeCount?: number; endAdornment?: React.ReactNode; onClick: () => void; + /** When set, the link renders as a real `<a>` (Next `Link`) so middle-click / open-in-new-tab / prefetch work. */ + href?: string; id?: string; showLabels?: boolean; } @@ -65,9 +68,24 @@ function NavLink({ badgeCount = 0, endAdornment, onClick, + href, id, showLabels = true, }: NavLinkProps) { + const content = ( + <div className="flex w-full min-w-0 items-center gap-2"> + <div className="flex min-w-0 items-center gap-3"> + <Icon className="size-5 shrink-0" /> + {showLabels && <span className="truncate">{label}</span>} + </div> + {showLabels && (showBadge || endAdornment) && ( + <div className="ml-auto flex shrink-0 items-center gap-2"> + {showBadge && <NumberBadge number={badgeCount} />} + {endAdornment} + </div> + )} + </div> + ); return ( <AnimateIcon animateOnHover="default" asChild> <Button @@ -78,6 +96,7 @@ function NavLink({ } side="right" onClick={onClick} + asChild={!!href} className={cn( "flex relative items-center group justify-between gap-3 text-sm font-medium transition-colors", showLabels ? "px-3 py-[5.5px]" : "px-3 justify-center", @@ -86,20 +105,7 @@ function NavLink({ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground", )} > - <div className="flex w-full min-w-0 items-center gap-2"> - <div className="flex min-w-0 items-center gap-3"> - <Icon className="size-5 shrink-0" /> - {showLabels && ( - <span className="truncate">{label}</span> - )} - </div> - {showLabels && (showBadge || endAdornment) && ( - <div className="ml-auto flex shrink-0 items-center gap-2"> - {showBadge && <NumberBadge number={badgeCount} />} - {endAdornment} - </div> - )} - </div> + {href ? <Link href={href}>{content}</Link> : content} </Button> </AnimateIcon> ); @@ -186,6 +192,7 @@ export function Sidebar({ onClose }: SidebarProps) { const tNav = useTranslations("nav"); const tPages = useTranslations("pages"); const tCommon = useTranslations("common"); + const tCustom = useTranslations("customTemplates"); const { currentTour } = useNextStep(); const { @@ -382,20 +389,15 @@ export function Sidebar({ onClose }: SidebarProps) { <Button id="request-templates-nav" variant="link" + asChild // Collapsed sidebar is icon-only, so restore the hover label // there (and keep the tour selector id on this element). tooltipContent={ showLabels ? undefined - : "Request Templates" + : tCustom("pageTitle") } side="right" - onClick={() => { - router.push( - `/${treasuryId}/custom-templates`, - ); - if (isMobile) onClose(); - }} className={cn( // `justify-start` overrides the Button's base // `justify-center`, which would otherwise centre the icon @@ -406,12 +408,19 @@ export function Sidebar({ onClose }: SidebarProps) { : "justify-center px-3", )} > - <CodeXml className="size-5 shrink-0" /> - {showLabels && ( - <span className="truncate"> - Request Templates - </span> - )} + <Link + href={`/${treasuryId}/custom-templates`} + onClick={() => { + if (isMobile) onClose(); + }} + > + <CodeXml className="size-5 shrink-0" /> + {showLabels && ( + <span className="truncate"> + {tCustom("pageTitle")} + </span> + )} + </Link> </Button> </AnimateIcon> {showLabels && pinnedTemplates.length > 0 && ( @@ -419,8 +428,8 @@ export function Sidebar({ onClose }: SidebarProps) { type="button" aria-label={ templatesExpanded - ? "Collapse pinned templates" - : "Expand pinned templates" + ? tCustom("collapsePinned") + : tCustom("expandPinned") } aria-expanded={templatesExpanded} onClick={() => @@ -454,8 +463,8 @@ export function Sidebar({ onClose }: SidebarProps) { icon={Bookmark} label={template.name} showLabels={showLabels} + href={href} onClick={() => { - router.push(href); if (isMobile) onClose(); }} /> diff --git a/nt-fe/features/onboarding/steps/page-tours.tsx b/nt-fe/features/onboarding/steps/page-tours.tsx index ef9d49ce9..fec9d7ada 100644 --- a/nt-fe/features/onboarding/steps/page-tours.tsx +++ b/nt-fe/features/onboarding/steps/page-tours.tsx @@ -20,7 +20,8 @@ type PageTourKey = | "exchangeSettings" | "membersPending" | "guestSaveIntro" - | "guestSaveAction"; + | "guestSaveAction" + | "requestTemplates"; function PageTourContent({ k }: { k: PageTourKey }) { const t = useTranslations("pageTours"); @@ -188,12 +189,7 @@ export const REQUEST_TEMPLATES_TOUR: Tour = { steps: [ { ...defaultStepProps, - content: ( - <> - Custom Requests is ready. Open it here to build your first - request type. - </> - ), + content: <PageTourContent k="requestTemplates" />, selector: PAGE_TOUR_SELECTORS.REQUEST_TEMPLATES_NAV, side: "right", }, diff --git a/nt-fe/features/proposal-templates/build-proposal.ts b/nt-fe/features/proposal-templates/build-proposal.ts index c206e8061..ad37cf7e3 100644 --- a/nt-fe/features/proposal-templates/build-proposal.ts +++ b/nt-fe/features/proposal-templates/build-proposal.ts @@ -5,10 +5,12 @@ * * Pure and framework-free, so it unit-tests without React or a wallet, and it reuses the manifest * module's `substitutePlaceholders` so the args it emits can never drift from what the validator - * checked. v1 substitution is string-based: every `{{field}}` resolves to a string (NEAR amounts - * stay digit strings, so u128 values never round-trip through a JS number). Typed JSON injection - * (a `bool`/`json` field landing as a raw value rather than a string) is a deliberate non-goal here - * — every v1 binding expresses its args as strings. + * checked. Two distinct rules govern the `args` tree: (1) placeholder substitution happens only + * inside string values — a `{{field}}` is replaced in-place and its value is always stringified + * (NEAR amounts stay digit strings, so u128 values never round-trip through a JS number); (2) any + * static non-string node in the template — a number, boolean, or nested object/array literal — + * passes through verbatim, keeping its JSON type. So a manifest can emit typed JSON args; what's + * string-only is the substitution of placeholders, not the args themselves. */ import type { FunctionCallKind } from "@/lib/proposals-api"; import { encodeToMarkdown, jsonToBase64 } from "@/lib/utils"; diff --git a/nt-fe/features/proposal-templates/components/args-tree-editor.tsx b/nt-fe/features/proposal-templates/components/args-tree-editor.tsx index 96b9f8b8a..5f3b49b15 100644 --- a/nt-fe/features/proposal-templates/components/args-tree-editor.tsx +++ b/nt-fe/features/proposal-templates/components/args-tree-editor.tsx @@ -9,6 +9,7 @@ * inside them is a composed `{{placeholder}}` configured under "Other inputs". */ import { Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { Fragment } from "react"; import { Button } from "@/components/button"; import { Input } from "@/components/ui/input"; @@ -67,6 +68,7 @@ export function ArgsTreeEditor({ errors, onChange, }: ArgsTreeEditorProps) { + const t = useTranslations("customTemplates"); return ( <div className="flex flex-col gap-4"> {args.map((entry, index) => ( @@ -91,7 +93,7 @@ export function ArgsTreeEditor({ onChange({ args: [...args, makeArgEntry()], fields }) } > - <Plus className="size-4" /> Add Argument + <Plus className="size-4" /> {t("args.addArgument")} </Button> </div> ); @@ -114,6 +116,7 @@ function TopEntryRow({ errors, onChange, }: TopEntryRowProps) { + const t = useTranslations("customTemplates"); const entry = args[index]; const dynamic = isDynamicArg(entry); const fieldIndex = fields.findIndex((field) => field.name === entry.key); @@ -197,7 +200,9 @@ function TopEntryRow({ return ( <div className="flex flex-col gap-3"> <div className="flex items-center justify-between"> - <h4 className="font-semibold text-sm">Argument {index + 1}</h4> + <h4 className="font-semibold text-sm"> + {t("args.argumentHeading", { number: index + 1 })} + </h4> <Button type="button" variant="ghost" @@ -205,19 +210,19 @@ function TopEntryRow({ className="text-muted-foreground hover:text-foreground" onClick={removeEntry} > - <Trash2 className="size-4" /> Remove + <Trash2 className="size-4" /> {t("args.remove")} </Button> </div> <div className="grid gap-3 sm:grid-cols-2"> <LabeledInput - label="Argument name" + label={t("args.argumentNameLabel")} value={entry.key} onChange={setKey} - placeholder="argument" + placeholder={t("args.argumentNamePlaceholder")} error={keyError} /> - <Labeled label="Type"> + <Labeled label={t("args.typeLabel")}> <Select value={dynamic ? "dynamic" : "static"} onValueChange={(value) => @@ -228,12 +233,14 @@ function TopEntryRow({ <SelectValue /> </SelectTrigger> <SelectContent> - <SelectItem value="static">Static</SelectItem> + <SelectItem value="static"> + {t("args.modeStatic")} + </SelectItem> <SelectItem value="dynamic" disabled={entry.key === ""} > - Member input + {t("args.modeMemberInput")} </SelectItem> </SelectContent> </Select> @@ -251,7 +258,7 @@ function TopEntryRow({ {!dynamic ? ( <div className="grid gap-3 sm:grid-cols-2"> - <Labeled label="Value type"> + <Labeled label={t("args.valueTypeLabel")}> <Select value={staticTypeOf(entry.value)} onValueChange={(value) => @@ -274,7 +281,7 @@ function TopEntryRow({ </Select> </Labeled> <Labeled - label="Value" + label={t("args.valueLabel")} action={ staticStringValue !== null && fieldNames.length > 0 ? ( @@ -369,6 +376,7 @@ function StaticLeaf({ fieldNames: string[]; onChange: (node: ArgNode) => void; }) { + const t = useTranslations("customTemplates"); if (node.kind === "string") { return ( <div className="flex flex-1 items-center gap-2"> @@ -378,7 +386,7 @@ function StaticLeaf({ onChange={(event) => onChange({ kind: "string", value: event.target.value }) } - placeholder="text or {{field}}" + placeholder={t("args.stringValuePlaceholder")} /> {fieldNames.length > 0 ? ( <FieldInserter @@ -431,10 +439,11 @@ function FieldInserter({ fieldNames: string[]; onInsert: (name: string) => void; }) { + const t = useTranslations("customTemplates"); return ( <Select value="" onValueChange={onInsert}> <SelectTrigger className="h-auto w-auto gap-1 border-0 bg-transparent p-0 text-muted-foreground text-xs shadow-none hover:text-foreground focus:ring-0 focus-visible:ring-0"> - + field + {t("args.insertField")} </SelectTrigger> <SelectContent> {fieldNames.map((name) => ( @@ -457,6 +466,7 @@ function StaticEntriesEditor({ fieldNames: string[]; onChange: (entries: ArgEntry[]) => void; }) { + const t = useTranslations("customTemplates"); return ( <div className="flex flex-col gap-2 border-l pl-3"> {entries.map((entry, index) => ( @@ -477,7 +487,7 @@ function StaticEntriesEditor({ ), ) } - placeholder="key" + placeholder={t("args.keyPlaceholder")} /> <StaticValue type={staticTypeOf(entry.value)} @@ -572,7 +582,7 @@ function StaticEntriesEditor({ className="self-start" onClick={() => onChange([...entries, makeArgEntry()])} > - <Plus className="size-4" /> Add Key + <Plus className="size-4" /> {t("args.addKey")} </Button> </div> ); @@ -588,6 +598,7 @@ function StaticItemsEditor({ fieldNames: string[]; onChange: (items: ArgItem[]) => void; }) { + const t = useTranslations("customTemplates"); function addItem() { const last = items.at(-1)?.value; const seed = last @@ -655,7 +666,7 @@ function StaticItemsEditor({ className="self-start" onClick={addItem} > - <Plus className="size-4" /> Add Item + <Plus className="size-4" /> {t("args.addItem")} </Button> </div> ); diff --git a/nt-fe/features/proposal-templates/components/fields-builder.tsx b/nt-fe/features/proposal-templates/components/fields-builder.tsx index 0fe6208b7..7b372ac57 100644 --- a/nt-fe/features/proposal-templates/components/fields-builder.tsx +++ b/nt-fe/features/proposal-templates/components/fields-builder.tsx @@ -10,6 +10,7 @@ * (after it's touched); `draftToField` drops type-incompatible extras on serialize. */ import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { Fragment, useId, useState } from "react"; import { Button } from "@/components/button"; import { Textarea } from "@/components/textarea"; @@ -60,6 +61,8 @@ export function FieldsBuilder({ hideNames, onChange, }: FieldsBuilderProps) { + const t = useTranslations("customTemplates"); + function updateField(index: number, patch: Partial<FieldDraft>) { onChange( fields.map((field, i) => @@ -115,7 +118,7 @@ export function FieldsBuilder({ className="self-start px-0 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => onChange([...fields, makeFieldDraft()])} > - <Plus className="size-4" /> Add Field + <Plus className="size-4" /> {t("fields.addField")} </Button> </div> ); @@ -147,10 +150,13 @@ function FieldRow({ onRemove, onMove, }: FieldRowProps) { + const t = useTranslations("customTemplates"); return ( <div className="flex flex-col gap-3"> <div className="flex items-center justify-between gap-2"> - <h4 className="font-semibold text-sm">Input {index + 1}</h4> + <h4 className="font-semibold text-sm"> + {t("fields.inputHeading", { number: index + 1 })} + </h4> <div className="flex items-center gap-1"> <Button type="button" @@ -177,13 +183,13 @@ function FieldRow({ className="text-muted-foreground hover:text-foreground" onClick={onRemove} > - <Trash2 className="size-4" /> Remove + <Trash2 className="size-4" /> {t("fields.remove")} </Button> </div> </div> <LabeledInput - label="Name" + label={t("fields.nameLabel")} value={field.name} onChange={(value) => onChange({ name: value })} placeholder="greeting" @@ -192,7 +198,7 @@ function FieldRow({ {used ? null : ( <p className="text-amber-600 text-xs dark:text-amber-500"> - {`Unused — no argument uses {{${field.name}}}; members fill it but it isn't sent.`} + {t("fields.unused", { name: field.name })} </p> )} @@ -218,6 +224,7 @@ export function FieldConfigFields({ errors: string[]; onChange: (patch: Partial<FieldDraft>) => void; }) { + const t = useTranslations("customTemplates"); const requiredId = useId(); // The options textarea isn't a LabeledInput, so it tracks its own touched state to match the // per-field "no error until touched" behavior. @@ -258,13 +265,13 @@ export function FieldConfigFields({ <div className="flex flex-col gap-3"> <div className="grid gap-2 sm:grid-cols-2"> <LabeledInput - label="Label" + label={t("fields.label")} value={field.label} onChange={(value) => onChange({ label: value })} placeholder="Greeting" error={errorFor(errors, `${path}.label`)} /> - <Labeled label="Type"> + <Labeled label={t("fields.typeLabel")}> <Select value={field.type} onValueChange={(value) => @@ -286,7 +293,7 @@ export function FieldConfigFields({ <SelectContent> {MANIFEST_FIELD_TYPES.map((type) => ( <SelectItem key={type} value={type}> - {type} + {t(`fieldTypes.${type}`)} </SelectItem> ))} </SelectContent> @@ -304,13 +311,13 @@ export function FieldConfigFields({ } /> <Label htmlFor={requiredId} className="text-sm"> - Required + {t("fields.required")} </Label> </div> ) : null} {field.type === "select" ? ( - <Labeled label="Options (one per line)"> + <Labeled label={t("fields.optionsLabel")}> <Textarea rows={3} className="font-mono text-xs" @@ -352,22 +359,22 @@ export function FieldConfigFields({ ) : ( <ChevronDown className="size-3.5" /> )} - Advanced options + {t("fields.advancedOptions")} </button> {showAdvanced ? ( <div className="flex flex-col gap-3 border-t pt-3"> <LabeledInput - label="Help (optional)" + label={t("fields.helpLabel")} value={field.help} onChange={(value) => onChange({ help: value })} - placeholder="Shown under the input" + placeholder={t("fields.helpPlaceholder")} /> {isNumeric ? ( <div className="grid gap-3 sm:grid-cols-2"> <LabeledInput - label="Min (optional)" + label={t("fields.minLabel")} value={field.validation.min} onChange={(value) => setValidation({ min: value }) @@ -376,7 +383,7 @@ export function FieldConfigFields({ error={minError} /> <LabeledInput - label="Max (optional)" + label={t("fields.maxLabel")} value={field.validation.max} onChange={(value) => setValidation({ max: value }) @@ -388,7 +395,7 @@ export function FieldConfigFields({ {allowsPattern ? ( <LabeledInput - label="Pattern (regex, optional)" + label={t("fields.patternLabel")} value={field.validation.pattern} onChange={(value) => setValidation({ pattern: value }) @@ -400,7 +407,7 @@ export function FieldConfigFields({ {showDefault ? ( <LabeledInput - label="Default (optional)" + label={t("fields.defaultLabel")} value={String(field.default ?? "")} onChange={setDefault} error={defaultError} diff --git a/nt-fe/features/proposal-templates/components/manifest-form.tsx b/nt-fe/features/proposal-templates/components/manifest-form.tsx index 885a746af..bbe0149be 100644 --- a/nt-fe/features/proposal-templates/components/manifest-form.tsx +++ b/nt-fe/features/proposal-templates/components/manifest-form.tsx @@ -13,6 +13,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { type ControllerRenderProps, useForm } from "react-hook-form"; import { Button } from "@/components/button"; @@ -65,6 +66,7 @@ function FieldControlInput({ field: ManifestField; control: FieldControl; }) { + const t = useTranslations("customTemplates"); switch (field.type) { case "bool": return ( @@ -86,7 +88,9 @@ function FieldControlInput({ className={SELECT_TRIGGER_CLASS} onBlur={control.onBlur} > - <SelectValue placeholder="Choose an option" /> + <SelectValue + placeholder={t("form.selectPlaceholder")} + /> </SelectTrigger> <SelectContent> {(field.options ?? []).map((option) => ( @@ -137,6 +141,7 @@ export function ManifestForm({ submitting, submitLabel, }: ManifestFormProps) { + const t = useTranslations("customTemplates"); const form = useForm<FieldValues>({ resolver: zodResolver(buildFormSchema(manifest)), defaultValues: defaultValuesFor(manifest), @@ -158,7 +163,7 @@ export function ManifestForm({ setFormKey((key) => key + 1); }; - const label = submitLabel ?? "File Proposal"; + const label = submitLabel ?? t("form.fileProposal"); return ( <Form {...form}> diff --git a/nt-fe/features/proposal-templates/components/template-editor.tsx b/nt-fe/features/proposal-templates/components/template-editor.tsx index 64c63683e..16a313064 100644 --- a/nt-fe/features/proposal-templates/components/template-editor.tsx +++ b/nt-fe/features/proposal-templates/components/template-editor.tsx @@ -11,6 +11,7 @@ * JSON); Visual → Code serializes the draft back to text. The active mode is the source of truth. */ import { ArrowLeft } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/button"; @@ -133,6 +134,7 @@ export function TemplateEditor({ submitting = false, footer, }: TemplateEditorProps) { + const t = useTranslations("customTemplates"); const [name, setName] = useState(initialName); const [manifestText, setManifestText] = useState(initialManifestText); // Default to Visual; fall back to Code only when editing a manifest that doesn't parse. @@ -169,9 +171,7 @@ export function TemplateEditor({ try { parsed = manifestText.trim() ? JSON.parse(manifestText) : {}; } catch { - toast.error( - "Fix the invalid JSON before switching to the visual builder", - ); + toast.error(t("editor.invalidJsonToast")); return; } setDraft(normalizeFields(jsonToDraft(parsed))); @@ -189,7 +189,7 @@ export function TemplateEditor({ <button type="button" onClick={onBack} - aria-label="Back" + aria-label={t("back")} className="text-muted-foreground transition-colors hover:text-foreground" > <ArrowLeft className="size-5" /> @@ -197,29 +197,39 @@ export function TemplateEditor({ <h2 className="font-semibold text-base">{title}</h2> </div> - <InputBlock title="Name" invalid={nameTouched && nameMissing}> + <InputBlock + title={t("editor.nameLabel")} + invalid={nameTouched && nameMissing} + > <LargeInput borderless - aria-label="Name" + aria-label={t("editor.nameLabel")} value={name} onChange={(event) => setName(event.target.value)} onBlur={() => setNameTouched(true)} - placeholder="Set Greeting" + placeholder={t("editor.namePlaceholder")} /> {nameTouched && nameMissing ? ( - <p className="text-destructive text-sm">Name is required</p> + <p className="text-destructive text-sm"> + {t("editor.nameRequired")} + </p> ) : null} </InputBlock> <Tabs value={mode} onValueChange={switchMode}> <TabsList> - <TabsTrigger value="visual">Visual</TabsTrigger> - <TabsTrigger value="code">Code</TabsTrigger> + <TabsTrigger value="visual"> + {t("editor.tabVisual")} + </TabsTrigger> + <TabsTrigger value="code">{t("editor.tabCode")}</TabsTrigger> </TabsList> </Tabs> {mode === "code" ? ( - <InputBlock title="Manifest (JSON)" invalid={codeShowErrors}> + <InputBlock + title={t("editor.manifestLabel")} + invalid={codeShowErrors} + > <Textarea borderless rows={16} diff --git a/nt-fe/features/proposal-templates/components/visual-builder.tsx b/nt-fe/features/proposal-templates/components/visual-builder.tsx index d7069cee7..06c0e8f3d 100644 --- a/nt-fe/features/proposal-templates/components/visual-builder.tsx +++ b/nt-fe/features/proposal-templates/components/visual-builder.tsx @@ -7,6 +7,7 @@ * `args` placeholder issues — show at their section's foot. `icon` isn't surfaced but rides along * in the draft, so round-tripping never drops it. */ +import { useTranslations } from "next-intl"; import { dynamicArgNames } from "../args-node"; import { type ManifestDraft, usedFieldNames } from "../draft"; import { errorFor, isInlineErrorPath } from "../error-map"; @@ -93,6 +94,7 @@ export function VisualBuilder({ showSectionErrors, onChange, }: VisualBuilderProps) { + const t = useTranslations("customTemplates"); const update = (patch: Partial<ManifestDraft>) => onChange({ ...draft, ...patch }); const updateBinding = (patch: Partial<ManifestDraft["binding"]>) => @@ -102,17 +104,17 @@ export function VisualBuilder({ return ( <div className="flex flex-col gap-4"> - <Section title="Details"> + <Section title={t("visual.detailsTitle")}> <div className="grid gap-3 sm:grid-cols-2"> <LabeledInput - label="ID (slug)" + label={t("visual.idLabel")} value={draft.id} onChange={(value) => update({ id: value })} placeholder="set-greeting" error={errorFor(errors, "id")} /> <LabeledInput - label="Title" + label={t("visual.titleLabel")} value={draft.title} onChange={(value) => update({ title: value })} placeholder="Set Greeting" @@ -120,13 +122,13 @@ export function VisualBuilder({ /> </div> <LabeledInput - label="Description (optional)" + label={t("visual.descriptionLabel")} value={draft.description} onChange={(value) => update({ description: value })} error={errorFor(errors, "description")} /> <LabeledInput - label="Summary (optional, supports {{fields}})" + label={t("visual.summaryLabel")} value={draft.summary} onChange={(value) => update({ summary: value })} placeholder="Set greeting to {{greeting}}" @@ -135,12 +137,12 @@ export function VisualBuilder({ </Section> <Section - title="On-chain call" - description="The fixed FunctionCall this template files." + title={t("visual.onChainCallTitle")} + description={t("visual.onChainCallDescription")} > <div className="grid gap-3 sm:grid-cols-2"> <LabeledInput - label="Receiver (contract)" + label={t("visual.receiverLabel")} value={draft.binding.receiver_id} onChange={(value) => updateBinding({ receiver_id: value }) @@ -149,7 +151,7 @@ export function VisualBuilder({ error={errorFor(errors, "binding.receiver_id")} /> <LabeledInput - label="Method" + label={t("visual.methodLabel")} value={draft.binding.method_name} onChange={(value) => updateBinding({ method_name: value }) @@ -158,14 +160,14 @@ export function VisualBuilder({ error={errorFor(errors, "binding.method_name")} /> <LabeledInput - label="Deposit (yoctoNEAR)" + label={t("visual.depositLabel")} value={draft.binding.deposit} onChange={(value) => updateBinding({ deposit: value })} placeholder="0" error={errorFor(errors, "binding.deposit")} /> <LabeledInput - label="Gas" + label={t("visual.gasLabel")} value={draft.binding.gas} onChange={(value) => updateBinding({ gas: value })} placeholder="30000000000000" @@ -175,8 +177,8 @@ export function VisualBuilder({ </Section> <Section - title="Arguments" - description="The arguments sent to the contract. Each is Static (a fixed value) or a Member input (dynamic) — for the latter, the input's config appears inline." + title={t("visual.argumentsTitle")} + description={t("visual.argumentsDescription")} > <ArgsTreeEditor args={draft.args} @@ -193,8 +195,8 @@ export function VisualBuilder({ </Section> <Section - title="Other inputs" - description="Inputs used only inside a composed value (not a direct member-input argument), plus any you add manually. Most templates have none." + title={t("visual.otherInputsTitle")} + description={t("visual.otherInputsDescription")} > <FieldsBuilder fields={draft.fields} diff --git a/nt-fe/features/proposal-templates/form-schema.ts b/nt-fe/features/proposal-templates/form-schema.ts index 00e09b568..2be0c8f77 100644 --- a/nt-fe/features/proposal-templates/form-schema.ts +++ b/nt-fe/features/proposal-templates/form-schema.ts @@ -12,10 +12,8 @@ import { z } from "zod"; import Big from "@/lib/big"; import type { FieldValues } from "./build-proposal"; -import type { Manifest, ManifestField } from "./manifest"; +import { type Manifest, type ManifestField, NEAR_ACCOUNT_RE } from "./manifest"; -// NEAR account-id shape (sync; on-chain existence is the input component's concern). -const NEAR_ACCOUNT_RE = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; const INTEGER_RE = /^\d+$/; /** @@ -37,6 +35,12 @@ function isParseableJson(value: string): boolean { } } +// A field's `validation.pattern` is an author-supplied regex run client-side as the member types. +// Bounding the input length fed to `.test` limits catastrophic-backtracking (ReDoS) blow-up — input +// length is the lever that turns an accidentally bad pattern into a frozen tab. Pattern length is +// capped at parse time (manifest.ts); real pattern-validated fields (codes, ids) are short anyway. +const MAX_PATTERN_INPUT = 256; + function safeRegExp(pattern: string): RegExp | null { try { return new RegExp(pattern); @@ -116,7 +120,10 @@ function fieldValueSchema(field: ManifestField): z.ZodTypeAny { : null; if (pattern) { schema = schema.refine( - allowEmpty((value) => pattern.test(value)), + allowEmpty( + (value) => + value.length <= MAX_PATTERN_INPUT && pattern.test(value), + ), { message: `${field.label} does not match the required pattern`, }, diff --git a/nt-fe/features/proposal-templates/manifest.ts b/nt-fe/features/proposal-templates/manifest.ts index 82f9e2b82..d7794955c 100644 --- a/nt-fe/features/proposal-templates/manifest.ts +++ b/nt-fe/features/proposal-templates/manifest.ts @@ -73,6 +73,32 @@ const fieldName = z "field.name must be a {{placeholder}}-safe identifier ([A-Za-z0-9_])", ); +// `binding.receiver_id`/`method_name` go straight into the on-chain FunctionCall, so they must be a +// real account and a real method — not free text. Without this, "I m the receiver" / "the method" +// pass and the template files an un-callable proposal. The account rule is shared with the `account` +// field type (form-schema imports NEAR_ACCOUNT_RE) so the two can't drift. +export const NEAR_ACCOUNT_RE = + /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; +const nearAccountString = (label: string) => + nonBlankString(label).regex( + NEAR_ACCOUNT_RE, + `${label} must be a valid NEAR account id (lowercase, e.g. usdc.near)`, + ); + +// Contract method names are identifiers in practice (ft_transfer, set_greeting); reject whitespace +// and other free text that could never name a real method. +const METHOD_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const methodNameString = (label: string) => + nonBlankString(label).regex( + METHOD_NAME_RE, + `${label} must be a valid contract method name ([A-Za-z0-9_], no spaces)`, + ); + +// Cap an author-supplied pattern's source length. The pattern is compiled and run client-side as +// members type, so an overlong one is both a likely mistake and a catastrophic-backtracking (ReDoS) +// lever; no legitimate field constraint needs more. Pairs with the input-length cap in form-schema. +const MAX_PATTERN_LENGTH = 200; + /** Whether `pattern` compiles as a regex — a typo'd pattern must fail authoring, not drop silently. */ function isValidRegExp(pattern: string): boolean { try { @@ -87,7 +113,13 @@ export const manifestFieldValidationSchema = z .object({ min: integerString("validation.min").optional(), max: integerString("validation.max").optional(), - pattern: z.string().optional(), + pattern: z + .string() + .max( + MAX_PATTERN_LENGTH, + `validation.pattern must be at most ${MAX_PATTERN_LENGTH} characters`, + ) + .optional(), }) .refine( (validation) => @@ -212,8 +244,8 @@ export type ManifestField = z.infer<typeof manifestFieldSchema>; /** The on-chain call the template produces. Fixed in v1 (no user-driven binding). */ export const manifestBindingSchema = z.object({ - receiver_id: nonBlankString("binding.receiver_id"), - method_name: nonBlankString("binding.method_name"), + receiver_id: nearAccountString("binding.receiver_id"), + method_name: methodNameString("binding.method_name"), deposit: integerString("binding.deposit"), gas: integerString("binding.gas"), }); diff --git a/nt-fe/messages/de.json b/nt-fe/messages/de.json index 517ccf9dc..f96d2da1b 100644 --- a/nt-fe/messages/de.json +++ b/nt-fe/messages/de.json @@ -854,7 +854,8 @@ "general": "Allgemein", "voting": "Abstimmung", "preferences": "Präferenzen", - "integrations": "Integrationen" + "integrations": "Integrationen", + "developer": "Entwickler" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Sie sind Gast dieser Treasury. Sie können nur die Daten einsehen.", "guestSaveAction": "Sie können diese Gast-Treasury in Ihrer Treasury-Liste speichern.", "newFeatureRich": "Neu! 🎉 Staken Sie Ihre Assets mit Earn und verdienen Sie Belohnungen. Lassen Sie Ihr Kapital muehelos fuer sich arbeiten.", - "newFeatureCta": "Ausprobieren" + "newFeatureCta": "Ausprobieren", + "requestTemplates": "Benutzerdefinierte Anträge sind bereit. Öffnen Sie sie hier, um Ihren ersten Antragstyp zu erstellen." }, "statusDate": { "expires": "Läuft ab", @@ -2187,5 +2189,139 @@ "videoTitle": "Demo ansehen", "videoDescription": "Sehen Sie ein kurzes Video, das zeigt, wie Trezu funktioniert." } + }, + "customTemplates": { + "pageTitle": "Antragsvorlagen", + "pageDescription": "Erstellen Sie wiederverwendbare Vorlagen für benutzerdefinierte Antragstypen.", + "loading": "Wird geladen…", + "back": "Zurück", + "howItWorks": "So funktioniert es", + "toastDeleted": "Vorlage gelöscht", + "errDelete": "Vorlage konnte nicht gelöscht werden", + "deleteDialog": { + "title": "Vorlage löschen", + "body": "<b>{name}</b> löschen? Mitglieder können daraus keine Anträge mehr einreichen. Dies kann nicht rückgängig gemacht werden.", + "confirm": "Löschen", + "deleting": "Wird gelöscht…" + }, + "editor": { + "nameLabel": "Name", + "namePlaceholder": "Begrüßung festlegen", + "nameRequired": "Name ist erforderlich", + "tabVisual": "Visuell", + "tabCode": "Code", + "manifestLabel": "Manifest (JSON)", + "invalidJsonToast": "Beheben Sie das ungültige JSON, bevor Sie zum visuellen Builder wechseln" + }, + "index": { + "emptyTitle": "Erstellen Sie Ihre erste Vorlage", + "emptyDescription": "Erstellen Sie eine Vorlage für Anträge, die Sie häufig stellen. Ihr Team verwendet sie mit einem Klick wieder.", + "createTemplate": "Vorlage erstellen", + "heading": "Vorlagen", + "addNew": "Neu hinzufügen", + "createRequest": "Antrag erstellen", + "actions": "Vorlagenaktionen", + "edit": "Bearbeiten", + "pin": "An die Seitenleiste anheften", + "unpin": "Vorlage lösen", + "delete": "Löschen", + "errPin": "Anheften konnte nicht aktualisiert werden" + }, + "create": { + "title": "Neue Vorlage", + "submit": "Vorlage erstellen", + "submitting": "Wird erstellt…", + "toastCreated": "Vorlage erstellt", + "errCreate": "Vorlage konnte nicht erstellt werden" + }, + "edit": { + "title": "Vorlage bearbeiten", + "submit": "Änderungen speichern", + "deleteButton": "Vorlage löschen", + "toastSaved": "Vorlage gespeichert", + "errSave": "Vorlage konnte nicht gespeichert werden", + "notFound": "Vorlage nicht gefunden." + }, + "fill": { + "noTemplate": "Keine Vorlage für „{slug}“ gefunden.", + "invalidManifest": "Das Manifest dieser Vorlage ist ungültig:", + "proposalFiled": "Antrag eingereicht" + }, + "developer": { + "title": "Benutzerdefinierte Anträge", + "description": "Erstellen Sie Ihre eigenen Antragstypen, wenn Sie mehr als Zahlungen und Tausch benötigen. Fügen Sie die benötigten Felder hinzu, und Ihre Mitglieder können sie wie jeden anderen Antrag einreichen.", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "errUpdate": "Die Einstellung konnte nicht aktualisiert werden" + }, + "visual": { + "detailsTitle": "Details", + "idLabel": "ID (slug)", + "titleLabel": "Titel", + "descriptionLabel": "Beschreibung (optional)", + "summaryLabel": "Zusammenfassung (optional, unterstützt '{{'fields'}}')", + "onChainCallTitle": "On-Chain-Aufruf", + "onChainCallDescription": "Der feste FunctionCall, den diese Vorlage einreicht.", + "receiverLabel": "Empfänger (Contract)", + "methodLabel": "Methode", + "depositLabel": "Deposit (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Argumente", + "argumentsDescription": "Die an den Contract gesendeten Argumente. Jedes ist statisch (ein fester Wert) oder eine Mitgliedereingabe (dynamisch) — bei Letzterer erscheint die Konfiguration der Eingabe direkt darunter.", + "otherInputsTitle": "Weitere Eingaben", + "otherInputsDescription": "Eingaben, die nur innerhalb eines zusammengesetzten Werts verwendet werden (kein direktes Mitgliedereingabe-Argument), sowie alle manuell hinzugefügten. Die meisten Vorlagen haben keine." + }, + "fields": { + "addField": "Feld hinzufügen", + "inputHeading": "Eingabe {number}", + "remove": "Entfernen", + "nameLabel": "Name", + "unused": "Nicht verwendet — kein Argument nutzt '{{'{name}'}}'; Mitglieder füllen es aus, aber es wird nicht gesendet.", + "label": "Bezeichnung", + "typeLabel": "Typ", + "required": "Erforderlich", + "optionsLabel": "Optionen (eine pro Zeile)", + "advancedOptions": "Erweiterte Optionen", + "helpLabel": "Hilfe (optional)", + "helpPlaceholder": "Wird unter der Eingabe angezeigt", + "minLabel": "Min (optional)", + "maxLabel": "Max (optional)", + "patternLabel": "Muster (regex, optional)", + "defaultLabel": "Standard (optional)" + }, + "fieldTypes": { + "account": "NEAR-Konto", + "token": "Token", + "uint": "Ganze Zahl", + "amount": "Token-Betrag", + "number": "Zahl", + "text": "Text", + "select": "Dropdown", + "bool": "Umschalter", + "json": "JSON" + }, + "args": { + "addArgument": "Argument hinzufügen", + "argumentHeading": "Argument {number}", + "remove": "Entfernen", + "argumentNameLabel": "Argumentname", + "argumentNamePlaceholder": "argument", + "typeLabel": "Typ", + "modeStatic": "Statisch", + "modeMemberInput": "Mitgliedereingabe", + "valueTypeLabel": "Werttyp", + "valueLabel": "Wert", + "stringValuePlaceholder": "Text oder '{{field}}'", + "insertField": "+ Feld", + "keyPlaceholder": "Schlüssel", + "addKey": "Schlüssel hinzufügen", + "addItem": "Element hinzufügen" + }, + "form": { + "selectPlaceholder": "Wählen Sie eine Option", + "fileProposal": "Antrag einreichen" + }, + "collapsePinned": "Angeheftete Vorlagen einklappen", + "expandPinned": "Angeheftete Vorlagen ausklappen" } } diff --git a/nt-fe/messages/en.json b/nt-fe/messages/en.json index 109061c62..3ead0d14e 100644 --- a/nt-fe/messages/en.json +++ b/nt-fe/messages/en.json @@ -854,7 +854,8 @@ "general": "General", "voting": "Voting", "preferences": "Preferences", - "integrations": "Integrations" + "integrations": "Integrations", + "developer": "Developer" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "You are a guest of this treasury. You can only view the data.", "guestSaveAction": "You can save this guest treasury to your treasuries list.", "newFeatureRich": "New! 🎉 Stake your assets with Earn and start earning rewards. Put your funds to work effortlessly.", - "newFeatureCta": "Try It" + "newFeatureCta": "Try It", + "requestTemplates": "Your Custom Requests section is ready. Open it here to build your first request type." }, "statusDate": { "expires": "Expires", @@ -2187,5 +2189,139 @@ "videoTitle": "Watch the walkthrough", "videoDescription": "See how Trezu works in a short video." } + }, + "customTemplates": { + "pageTitle": "Request Templates", + "pageDescription": "Build reusable templates for custom request types.", + "loading": "Loading…", + "back": "Back", + "howItWorks": "How It Works", + "toastDeleted": "Template deleted", + "errDelete": "Failed to delete template", + "deleteDialog": { + "title": "Delete template", + "body": "Delete <b>{name}</b>? Members will no longer be able to file proposals from it. This cannot be undone.", + "confirm": "Delete", + "deleting": "Deleting…" + }, + "editor": { + "nameLabel": "Name", + "namePlaceholder": "Set Greeting", + "nameRequired": "Name is required", + "tabVisual": "Visual", + "tabCode": "Code", + "manifestLabel": "Manifest (JSON)", + "invalidJsonToast": "Fix the invalid JSON before switching to the visual builder" + }, + "index": { + "emptyTitle": "Create your first template", + "emptyDescription": "Make a template for requests you send often. Your team reuses it in one click.", + "createTemplate": "Create Template", + "heading": "Templates", + "addNew": "Add New", + "createRequest": "Create Request", + "actions": "Template actions", + "edit": "Edit", + "pin": "Pin to the Sidebar", + "unpin": "Unpin Template", + "delete": "Delete", + "errPin": "Could not update pin" + }, + "create": { + "title": "New Template", + "submit": "Create Template", + "submitting": "Creating…", + "toastCreated": "Template created", + "errCreate": "Failed to create template" + }, + "edit": { + "title": "Edit Template", + "submit": "Save Changes", + "deleteButton": "Delete Template", + "toastSaved": "Template saved", + "errSave": "Failed to save template", + "notFound": "Template not found." + }, + "fill": { + "noTemplate": "No template found for “{slug}”.", + "invalidManifest": "This template’s manifest is invalid:", + "proposalFiled": "Request filed" + }, + "developer": { + "title": "Custom Requests", + "description": "Build your own request types when you need more than payments and exchange. Add the fields you need and your members can submit them like any other request.", + "enable": "Enable", + "disable": "Disable", + "errUpdate": "Could not update the setting" + }, + "visual": { + "detailsTitle": "Details", + "idLabel": "ID (slug)", + "titleLabel": "Title", + "descriptionLabel": "Description (optional)", + "summaryLabel": "Summary (optional, supports '{{'fields'}}')", + "onChainCallTitle": "On-chain call", + "onChainCallDescription": "The fixed FunctionCall this template files.", + "receiverLabel": "Receiver (contract)", + "methodLabel": "Method", + "depositLabel": "Deposit (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Arguments", + "argumentsDescription": "The arguments sent to the contract. Each is Static (a fixed value) or a Member input (dynamic) — for the latter, the input's config appears inline.", + "otherInputsTitle": "Other inputs", + "otherInputsDescription": "Inputs used only inside a composed value (not a direct member-input argument), plus any you add manually. Most templates have none." + }, + "fields": { + "addField": "Add Field", + "inputHeading": "Input {number}", + "remove": "Remove", + "nameLabel": "Name", + "unused": "Unused — no argument uses '{{'{name}'}}'; members fill it but it isn''t sent.", + "label": "Label", + "typeLabel": "Type", + "required": "Required", + "optionsLabel": "Options (one per line)", + "advancedOptions": "Advanced options", + "helpLabel": "Help (optional)", + "helpPlaceholder": "Shown under the input", + "minLabel": "Min (optional)", + "maxLabel": "Max (optional)", + "patternLabel": "Pattern (regex, optional)", + "defaultLabel": "Default (optional)" + }, + "fieldTypes": { + "account": "NEAR account", + "token": "Token", + "uint": "Whole number", + "amount": "Token amount", + "number": "Number", + "text": "Text", + "select": "Dropdown", + "bool": "Toggle", + "json": "JSON" + }, + "args": { + "addArgument": "Add Argument", + "argumentHeading": "Argument {number}", + "remove": "Remove", + "argumentNameLabel": "Argument name", + "argumentNamePlaceholder": "argument", + "typeLabel": "Type", + "modeStatic": "Static", + "modeMemberInput": "Member input", + "valueTypeLabel": "Value type", + "valueLabel": "Value", + "stringValuePlaceholder": "text or '{{field}}'", + "insertField": "+ field", + "keyPlaceholder": "key", + "addKey": "Add Key", + "addItem": "Add Item" + }, + "form": { + "selectPlaceholder": "Choose an option", + "fileProposal": "File Proposal" + }, + "collapsePinned": "Collapse pinned templates", + "expandPinned": "Expand pinned templates" } } diff --git a/nt-fe/messages/es.json b/nt-fe/messages/es.json index de9de7d12..e8edb91d0 100644 --- a/nt-fe/messages/es.json +++ b/nt-fe/messages/es.json @@ -854,7 +854,8 @@ "general": "General", "voting": "Votación", "preferences": "Preferencias", - "integrations": "Integraciones" + "integrations": "Integraciones", + "developer": "Desarrollador" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Eres invitado de esta tesorería. Solo puedes ver los datos.", "guestSaveAction": "Puedes guardar esta tesorería de invitado en tu lista de tesorerías.", "newFeatureRich": "¡Nuevo! 🎉 Guarda direcciones usadas con frecuencia para pagos más rápidos y sin errores. Tus contactos quedan privados y visibles solo para tu equipo.", - "newFeatureCta": "Pruébalo" + "newFeatureCta": "Pruébalo", + "requestTemplates": "Solicitudes personalizadas está listo. Ábrelo aquí para crear tu primer tipo de solicitud." }, "statusDate": { "expires": "Caduca", @@ -2187,5 +2189,139 @@ "videoTitle": "Mira la demo", "videoDescription": "Mira un vídeo corto que muestra cómo funciona Trezu." } + }, + "customTemplates": { + "pageTitle": "Plantillas de solicitud", + "pageDescription": "Crea plantillas reutilizables para tipos de solicitud personalizados.", + "loading": "Cargando…", + "back": "Atrás", + "howItWorks": "Cómo funciona", + "toastDeleted": "Plantilla eliminada", + "errDelete": "No se pudo eliminar la plantilla", + "deleteDialog": { + "title": "Eliminar plantilla", + "body": "¿Eliminar <b>{name}</b>? Los miembros ya no podrán presentar propuestas a partir de ella. Esta acción no se puede deshacer.", + "confirm": "Eliminar", + "deleting": "Eliminando…" + }, + "editor": { + "nameLabel": "Nombre", + "namePlaceholder": "Establecer saludo", + "nameRequired": "El nombre es obligatorio", + "tabVisual": "Visual", + "tabCode": "Código", + "manifestLabel": "Manifiesto (JSON)", + "invalidJsonToast": "Corrige el JSON no válido antes de cambiar al editor visual" + }, + "index": { + "emptyTitle": "Crea tu primera plantilla", + "emptyDescription": "Crea una plantilla para las solicitudes que envías con frecuencia. Tu equipo la reutiliza con un solo clic.", + "createTemplate": "Crear plantilla", + "heading": "Plantillas", + "addNew": "Añadir nueva", + "createRequest": "Crear solicitud", + "actions": "Acciones de la plantilla", + "edit": "Editar", + "pin": "Fijar en la barra lateral", + "unpin": "Quitar plantilla de la barra", + "delete": "Eliminar", + "errPin": "No se pudo actualizar el fijado" + }, + "create": { + "title": "Nueva plantilla", + "submit": "Crear plantilla", + "submitting": "Creando…", + "toastCreated": "Plantilla creada", + "errCreate": "No se pudo crear la plantilla" + }, + "edit": { + "title": "Editar plantilla", + "submit": "Guardar cambios", + "deleteButton": "Eliminar plantilla", + "toastSaved": "Plantilla guardada", + "errSave": "No se pudo guardar la plantilla", + "notFound": "Plantilla no encontrada." + }, + "fill": { + "noTemplate": "No se encontró ninguna plantilla para “{slug}”.", + "invalidManifest": "El manifiesto de esta plantilla no es válido:", + "proposalFiled": "Solicitud presentada" + }, + "developer": { + "title": "Solicitudes personalizadas", + "description": "Crea tus propios tipos de solicitud cuando necesites algo más que pagos e intercambios. Añade los campos que necesites y tus miembros podrán enviarlas como cualquier otra solicitud.", + "enable": "Activar", + "disable": "Desactivar", + "errUpdate": "No se pudo actualizar la configuración" + }, + "visual": { + "detailsTitle": "Detalles", + "idLabel": "ID (slug)", + "titleLabel": "Título", + "descriptionLabel": "Descripción (opcional)", + "summaryLabel": "Resumen (opcional, admite '{{'fields'}}')", + "onChainCallTitle": "Llamada on-chain", + "onChainCallDescription": "El FunctionCall fijo que presenta esta plantilla.", + "receiverLabel": "Receptor (contrato)", + "methodLabel": "Método", + "depositLabel": "Depósito (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Argumentos", + "argumentsDescription": "Los argumentos enviados al contrato. Cada uno es Estático (un valor fijo) o una entrada de miembro (dinámica) — para este último, la configuración de la entrada aparece en línea.", + "otherInputsTitle": "Otras entradas", + "otherInputsDescription": "Entradas usadas únicamente dentro de un valor compuesto (no un argumento directo de entrada de miembro), más cualquiera que añadas manualmente. La mayoría de las plantillas no tienen ninguna." + }, + "fields": { + "addField": "Añadir campo", + "inputHeading": "Entrada {number}", + "remove": "Eliminar", + "nameLabel": "Nombre", + "unused": "Sin usar — ningún argumento utiliza '{{'{name}'}}'; los miembros lo rellenan pero no se envía.", + "label": "Etiqueta", + "typeLabel": "Tipo", + "required": "Obligatorio", + "optionsLabel": "Opciones (una por línea)", + "advancedOptions": "Opciones avanzadas", + "helpLabel": "Ayuda (opcional)", + "helpPlaceholder": "Se muestra debajo de la entrada", + "minLabel": "Mín. (opcional)", + "maxLabel": "Máx. (opcional)", + "patternLabel": "Patrón (regex, opcional)", + "defaultLabel": "Valor predeterminado (opcional)" + }, + "fieldTypes": { + "account": "Cuenta NEAR", + "token": "Token", + "uint": "Número entero", + "amount": "Cantidad de tokens", + "number": "Número", + "text": "Texto", + "select": "Desplegable", + "bool": "Conmutador", + "json": "JSON" + }, + "args": { + "addArgument": "Añadir argumento", + "argumentHeading": "Argumento {number}", + "remove": "Eliminar", + "argumentNameLabel": "Nombre del argumento", + "argumentNamePlaceholder": "argumento", + "typeLabel": "Tipo", + "modeStatic": "Estático", + "modeMemberInput": "Entrada de miembro", + "valueTypeLabel": "Tipo de valor", + "valueLabel": "Valor", + "stringValuePlaceholder": "texto o '{{field}}'", + "insertField": "+ campo", + "keyPlaceholder": "clave", + "addKey": "Añadir clave", + "addItem": "Añadir elemento" + }, + "form": { + "selectPlaceholder": "Elige una opción", + "fileProposal": "Presentar propuesta" + }, + "collapsePinned": "Contraer plantillas fijadas", + "expandPinned": "Expandir plantillas fijadas" } } diff --git a/nt-fe/messages/fr.json b/nt-fe/messages/fr.json index 77fed1349..d5b233487 100644 --- a/nt-fe/messages/fr.json +++ b/nt-fe/messages/fr.json @@ -854,7 +854,8 @@ "general": "Général", "voting": "Vote", "preferences": "Préférences", - "integrations": "Intégrations" + "integrations": "Intégrations", + "developer": "Développeur" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Vous êtes un invité de cette trésorerie. Vous ne pouvez consulter que les données.", "guestSaveAction": "Vous pouvez enregistrer cette trésorerie invitée dans votre liste de trésoreries.", "newFeatureRich": "Nouveau ! 🎉 Enregistrez les adresses fréquemment utilisées pour des paiements plus rapides et sans erreur. Vos contacts restent privés et visibles uniquement par votre équipe.", - "newFeatureCta": "Essayer" + "newFeatureCta": "Essayer", + "requestTemplates": "Les demandes personnalisées sont prêtes. Ouvrez-les ici pour créer votre premier type de demande." }, "statusDate": { "expires": "Expire", @@ -2187,5 +2189,139 @@ "videoTitle": "Regarder la démo", "videoDescription": "Regardez une courte vidéo montrant comment Trezu fonctionne." } + }, + "customTemplates": { + "pageTitle": "Modèles de demande", + "pageDescription": "Créez des modèles réutilisables pour des types de demande personnalisés.", + "loading": "Chargement…", + "back": "Retour", + "howItWorks": "Comment ça marche", + "toastDeleted": "Modèle supprimé", + "errDelete": "Échec de la suppression du modèle", + "deleteDialog": { + "title": "Supprimer le modèle", + "body": "Supprimer <b>{name}</b> ? Les membres ne pourront plus déposer de propositions à partir de ce modèle. Cette action est irréversible.", + "confirm": "Supprimer", + "deleting": "Suppression…" + }, + "editor": { + "nameLabel": "Nom", + "namePlaceholder": "Définir le message d'accueil", + "nameRequired": "Le nom est obligatoire", + "tabVisual": "Visuel", + "tabCode": "Code", + "manifestLabel": "Manifeste (JSON)", + "invalidJsonToast": "Corrigez le JSON invalide avant de passer au constructeur visuel" + }, + "index": { + "emptyTitle": "Créez votre premier modèle", + "emptyDescription": "Créez un modèle pour les demandes que vous envoyez souvent. Votre équipe le réutilise en un clic.", + "createTemplate": "Créer un modèle", + "heading": "Modèles", + "addNew": "Ajouter", + "createRequest": "Créer une demande", + "actions": "Actions du modèle", + "edit": "Modifier", + "pin": "Épingler à la barre latérale", + "unpin": "Désépingler le modèle", + "delete": "Supprimer", + "errPin": "Impossible de mettre à jour l'épinglage" + }, + "create": { + "title": "Nouveau modèle", + "submit": "Créer un modèle", + "submitting": "Création…", + "toastCreated": "Modèle créé", + "errCreate": "Échec de la création du modèle" + }, + "edit": { + "title": "Modifier le modèle", + "submit": "Enregistrer les modifications", + "deleteButton": "Supprimer le modèle", + "toastSaved": "Modèle enregistré", + "errSave": "Échec de l'enregistrement du modèle", + "notFound": "Modèle introuvable." + }, + "fill": { + "noTemplate": "Aucun modèle trouvé pour « {slug} ».", + "invalidManifest": "Le manifeste de ce modèle est invalide :", + "proposalFiled": "Demande déposée" + }, + "developer": { + "title": "Demandes personnalisées", + "description": "Créez vos propres types de demande lorsque vous avez besoin de plus que des paiements et des échanges. Ajoutez les champs dont vous avez besoin et vos membres peuvent les soumettre comme n'importe quelle autre demande.", + "enable": "Activer", + "disable": "Désactiver", + "errUpdate": "Impossible de mettre à jour le paramètre" + }, + "visual": { + "detailsTitle": "Détails", + "idLabel": "ID (slug)", + "titleLabel": "Titre", + "descriptionLabel": "Description (facultatif)", + "summaryLabel": "Résumé (facultatif, prend en charge les '{{'fields'}}')", + "onChainCallTitle": "Appel on-chain", + "onChainCallDescription": "Le FunctionCall fixe que ce modèle dépose.", + "receiverLabel": "Destinataire (contrat)", + "methodLabel": "Méthode", + "depositLabel": "Dépôt (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Arguments", + "argumentsDescription": "Les arguments envoyés au contrat. Chacun est Statique (une valeur fixe) ou une Saisie membre (dynamique) — pour cette dernière, la configuration de la saisie apparaît en ligne.", + "otherInputsTitle": "Autres saisies", + "otherInputsDescription": "Saisies utilisées uniquement à l'intérieur d'une valeur composée (et non comme argument de saisie membre direct), ainsi que celles que vous ajoutez manuellement. La plupart des modèles n'en ont aucune." + }, + "fields": { + "addField": "Ajouter un champ", + "inputHeading": "Saisie {number}", + "remove": "Supprimer", + "nameLabel": "Nom", + "unused": "Inutilisé — aucun argument n''utilise '{{'{name}'}}' ; les membres le remplissent mais il n''est pas envoyé.", + "label": "Libellé", + "typeLabel": "Type", + "required": "Obligatoire", + "optionsLabel": "Options (une par ligne)", + "advancedOptions": "Options avancées", + "helpLabel": "Aide (facultatif)", + "helpPlaceholder": "Affiché sous la saisie", + "minLabel": "Min (facultatif)", + "maxLabel": "Max (facultatif)", + "patternLabel": "Motif (regex, facultatif)", + "defaultLabel": "Valeur par défaut (facultatif)" + }, + "fieldTypes": { + "account": "Compte NEAR", + "token": "Token", + "uint": "Nombre entier", + "amount": "Montant de token", + "number": "Nombre", + "text": "Texte", + "select": "Liste déroulante", + "bool": "Bascule", + "json": "JSON" + }, + "args": { + "addArgument": "Ajouter un argument", + "argumentHeading": "Argument {number}", + "remove": "Supprimer", + "argumentNameLabel": "Nom de l'argument", + "argumentNamePlaceholder": "argument", + "typeLabel": "Type", + "modeStatic": "Statique", + "modeMemberInput": "Saisie membre", + "valueTypeLabel": "Type de valeur", + "valueLabel": "Valeur", + "stringValuePlaceholder": "texte ou '{{field}}'", + "insertField": "+ champ", + "keyPlaceholder": "clé", + "addKey": "Ajouter une clé", + "addItem": "Ajouter un élément" + }, + "form": { + "selectPlaceholder": "Choisissez une option", + "fileProposal": "Déposer la proposition" + }, + "collapsePinned": "Réduire les modèles épinglés", + "expandPinned": "Développer les modèles épinglés" } } diff --git a/nt-fe/messages/he.json b/nt-fe/messages/he.json index dd7803b4e..015099c83 100644 --- a/nt-fe/messages/he.json +++ b/nt-fe/messages/he.json @@ -854,7 +854,8 @@ "general": "כללי", "voting": "הצבעה", "preferences": "העדפות", - "integrations": "אינטגרציות" + "integrations": "אינטגרציות", + "developer": "מפתח" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "אתה אורח של אוצר זה. אתה יכול לראות רק את הנתונים.", "guestSaveAction": "ניתן לשמור את האוצר האורח הזה לרשימת האוצרות שלך.", "newFeatureRich": "חדש! 🎉 שמור כתובות בשימוש תדיר לתשלומים מהירים יותר וללא שגיאות. אנשי הקשר שלך נשארים פרטיים וגלויים רק לצוות שלך.", - "newFeatureCta": "נסה זאת" + "newFeatureCta": "נסה זאת", + "requestTemplates": "בקשות מותאמות אישית מוכנות. פתחו אותן כאן כדי לבנות את סוג הבקשה הראשון שלכם." }, "statusDate": { "expires": "פג תוקף", @@ -2187,5 +2189,139 @@ "videoTitle": "צפה בדמו", "videoDescription": "צפה בסרטון קצר המראה איך Trezu עובד." } + }, + "customTemplates": { + "pageTitle": "תבניות בקשה", + "pageDescription": "בנו תבניות לשימוש חוזר עבור סוגי בקשות מותאמים אישית.", + "loading": "טוען…", + "back": "חזרה", + "howItWorks": "איך זה עובד", + "toastDeleted": "התבנית נמחקה", + "errDelete": "מחיקת התבנית נכשלה", + "deleteDialog": { + "title": "מחיקת תבנית", + "body": "למחוק את <b>{name}</b>? חברים לא יוכלו עוד להגיש הצעות באמצעותה. לא ניתן לבטל פעולה זו.", + "confirm": "מחק", + "deleting": "מוחק…" + }, + "editor": { + "nameLabel": "שם", + "namePlaceholder": "הגדרת ברכה", + "nameRequired": "שם הוא שדה חובה", + "tabVisual": "חזותי", + "tabCode": "קוד", + "manifestLabel": "מניפסט (JSON)", + "invalidJsonToast": "תקנו את ה-JSON השגוי לפני המעבר לבונה החזותי" + }, + "index": { + "emptyTitle": "צרו את התבנית הראשונה שלכם", + "emptyDescription": "צרו תבנית עבור בקשות שאתם שולחים לעיתים קרובות. הצוות שלכם ישתמש בה שוב בלחיצה אחת.", + "createTemplate": "צור תבנית", + "heading": "תבניות", + "addNew": "הוסף חדש", + "createRequest": "צור בקשה", + "actions": "פעולות תבנית", + "edit": "ערוך", + "pin": "הצמד לסרגל הצד", + "unpin": "בטל הצמדת תבנית", + "delete": "מחק", + "errPin": "לא ניתן היה לעדכן את ההצמדה" + }, + "create": { + "title": "תבנית חדשה", + "submit": "צור תבנית", + "submitting": "יוצר…", + "toastCreated": "התבנית נוצרה", + "errCreate": "יצירת התבנית נכשלה" + }, + "edit": { + "title": "עריכת תבנית", + "submit": "שמור שינויים", + "deleteButton": "מחק תבנית", + "toastSaved": "התבנית נשמרה", + "errSave": "שמירת התבנית נכשלה", + "notFound": "התבנית לא נמצאה." + }, + "fill": { + "noTemplate": "לא נמצאה תבנית עבור ״{slug}״.", + "invalidManifest": "המניפסט של תבנית זו אינו תקין:", + "proposalFiled": "הבקשה הוגשה" + }, + "developer": { + "title": "בקשות מותאמות אישית", + "description": "בנו סוגי בקשות משלכם כשאתם זקוקים ליותר מתשלומים והחלפה. הוסיפו את השדות שאתם צריכים והחברים שלכם יוכלו להגיש אותם כמו כל בקשה אחרת.", + "enable": "הפעל", + "disable": "השבת", + "errUpdate": "לא ניתן היה לעדכן את ההגדרה" + }, + "visual": { + "detailsTitle": "פרטים", + "idLabel": "מזהה (slug)", + "titleLabel": "כותרת", + "descriptionLabel": "תיאור (אופציונלי)", + "summaryLabel": "סיכום (אופציונלי, תומך ב-'{{'fields'}}')", + "onChainCallTitle": "קריאה על השרשרת", + "onChainCallDescription": "ה-FunctionCall הקבועה שתבנית זו מגישה.", + "receiverLabel": "מקבל (חוזה)", + "methodLabel": "מתודה", + "depositLabel": "פיקדון (yoctoNEAR)", + "gasLabel": "דלק", + "argumentsTitle": "ארגומנטים", + "argumentsDescription": "הארגומנטים הנשלחים לחוזה. כל אחד הוא סטטי (ערך קבוע) או קלט חבר (דינמי) — עבור האחרון, תצורת הקלט מופיעה בתוך השורה.", + "otherInputsTitle": "קלטים נוספים", + "otherInputsDescription": "קלטים המשמשים רק בתוך ערך מורכב (לא ארגומנט קלט-חבר ישיר), בתוספת כל אלה שתוסיפו באופן ידני. לרוב התבניות אין כאלה." + }, + "fields": { + "addField": "הוסף שדה", + "inputHeading": "קלט {number}", + "remove": "הסר", + "nameLabel": "שם", + "unused": "לא בשימוש — אף ארגומנט אינו משתמש ב-'{{'{name}'}}'; חברים ממלאים אותו אך הוא אינו נשלח.", + "label": "תווית", + "typeLabel": "סוג", + "required": "חובה", + "optionsLabel": "אפשרויות (אחת בכל שורה)", + "advancedOptions": "אפשרויות מתקדמות", + "helpLabel": "עזרה (אופציונלי)", + "helpPlaceholder": "מוצג מתחת לקלט", + "minLabel": "מינימום (אופציונלי)", + "maxLabel": "מקסימום (אופציונלי)", + "patternLabel": "תבנית (regex, אופציונלי)", + "defaultLabel": "ברירת מחדל (אופציונלי)" + }, + "fieldTypes": { + "account": "חשבון NEAR", + "token": "טוקן", + "uint": "מספר שלם", + "amount": "כמות טוקנים", + "number": "מספר", + "text": "טקסט", + "select": "תפריט נפתח", + "bool": "מתג", + "json": "JSON" + }, + "args": { + "addArgument": "הוסף ארגומנט", + "argumentHeading": "ארגומנט {number}", + "remove": "הסר", + "argumentNameLabel": "שם הארגומנט", + "argumentNamePlaceholder": "ארגומנט", + "typeLabel": "סוג", + "modeStatic": "סטטי", + "modeMemberInput": "קלט חבר", + "valueTypeLabel": "סוג ערך", + "valueLabel": "ערך", + "stringValuePlaceholder": "טקסט או '{{field}}'", + "insertField": "+ שדה", + "keyPlaceholder": "מפתח", + "addKey": "הוסף מפתח", + "addItem": "הוסף פריט" + }, + "form": { + "selectPlaceholder": "בחרו אפשרות", + "fileProposal": "הגש הצעה" + }, + "collapsePinned": "כווץ תבניות מוצמדות", + "expandPinned": "הרחב תבניות מוצמדות" } } diff --git a/nt-fe/messages/id.json b/nt-fe/messages/id.json index c47c433f5..f90331495 100644 --- a/nt-fe/messages/id.json +++ b/nt-fe/messages/id.json @@ -854,7 +854,8 @@ "general": "Umum", "voting": "Pemungutan Suara", "preferences": "Preferensi", - "integrations": "Integrasi" + "integrations": "Integrasi", + "developer": "Pengembang" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Anda adalah tamu treasury ini. Anda hanya dapat melihat data.", "guestSaveAction": "Anda dapat menyimpan treasury tamu ini ke daftar treasury Anda.", "newFeatureRich": "Baru! 🎉 Simpan alamat yang sering digunakan untuk pembayaran lebih cepat dan bebas kesalahan. Kontak Anda tetap pribadi dan hanya terlihat oleh tim Anda.", - "newFeatureCta": "Coba Sekarang" + "newFeatureCta": "Coba Sekarang", + "requestTemplates": "Permintaan Khusus sudah siap. Buka di sini untuk membangun jenis permintaan pertama Anda." }, "statusDate": { "expires": "Kedaluwarsa", @@ -2187,5 +2189,139 @@ "videoTitle": "Tonton Demo", "videoDescription": "Tonton video singkat yang menunjukkan cara kerja Trezu." } + }, + "customTemplates": { + "pageTitle": "Templat Permintaan", + "pageDescription": "Buat templat yang dapat digunakan kembali untuk jenis permintaan khusus.", + "loading": "Memuat…", + "back": "Kembali", + "howItWorks": "Cara Kerja", + "toastDeleted": "Templat dihapus", + "errDelete": "Gagal menghapus templat", + "deleteDialog": { + "title": "Hapus templat", + "body": "Hapus <b>{name}</b>? Anggota tidak akan dapat lagi mengajukan proposal darinya. Tindakan ini tidak dapat dibatalkan.", + "confirm": "Hapus", + "deleting": "Menghapus…" + }, + "editor": { + "nameLabel": "Nama", + "namePlaceholder": "Atur Sapaan", + "nameRequired": "Nama wajib diisi", + "tabVisual": "Visual", + "tabCode": "Kode", + "manifestLabel": "Manifest (JSON)", + "invalidJsonToast": "Perbaiki JSON yang tidak valid sebelum beralih ke pembangun visual" + }, + "index": { + "emptyTitle": "Buat templat pertama Anda", + "emptyDescription": "Buat templat untuk permintaan yang sering Anda kirim. Tim Anda menggunakannya kembali dalam satu klik.", + "createTemplate": "Buat Templat", + "heading": "Templat", + "addNew": "Tambah Baru", + "createRequest": "Buat Permintaan", + "actions": "Tindakan templat", + "edit": "Sunting", + "pin": "Sematkan ke Bilah Samping", + "unpin": "Lepas Sematan Templat", + "delete": "Hapus", + "errPin": "Tidak dapat memperbarui sematan" + }, + "create": { + "title": "Templat Baru", + "submit": "Buat Templat", + "submitting": "Membuat…", + "toastCreated": "Templat dibuat", + "errCreate": "Gagal membuat templat" + }, + "edit": { + "title": "Sunting Templat", + "submit": "Simpan Perubahan", + "deleteButton": "Hapus Templat", + "toastSaved": "Templat disimpan", + "errSave": "Gagal menyimpan templat", + "notFound": "Templat tidak ditemukan." + }, + "fill": { + "noTemplate": "Tidak ada templat yang ditemukan untuk “{slug}”.", + "invalidManifest": "Manifest templat ini tidak valid:", + "proposalFiled": "Permintaan diajukan" + }, + "developer": { + "title": "Permintaan Khusus", + "description": "Bangun jenis permintaan Anda sendiri saat Anda membutuhkan lebih dari pembayaran dan pertukaran. Tambahkan kolom yang Anda perlukan dan anggota Anda dapat mengirimkannya seperti permintaan lainnya.", + "enable": "Aktifkan", + "disable": "Nonaktifkan", + "errUpdate": "Tidak dapat memperbarui pengaturan" + }, + "visual": { + "detailsTitle": "Detail", + "idLabel": "ID (slug)", + "titleLabel": "Judul", + "descriptionLabel": "Deskripsi (opsional)", + "summaryLabel": "Ringkasan (opsional, mendukung '{{'fields'}}')", + "onChainCallTitle": "Panggilan on-chain", + "onChainCallDescription": "FunctionCall tetap yang diajukan templat ini.", + "receiverLabel": "Penerima (kontrak)", + "methodLabel": "Metode", + "depositLabel": "Deposit (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Argumen", + "argumentsDescription": "Argumen yang dikirim ke kontrak. Masing-masing bersifat Statis (nilai tetap) atau input Anggota (dinamis) — untuk yang terakhir, konfigurasi input muncul secara inline.", + "otherInputsTitle": "Input lainnya", + "otherInputsDescription": "Input yang hanya digunakan di dalam nilai gabungan (bukan argumen input-anggota langsung), ditambah yang Anda tambahkan secara manual. Sebagian besar templat tidak memilikinya." + }, + "fields": { + "addField": "Tambah Kolom", + "inputHeading": "Input {number}", + "remove": "Hapus", + "nameLabel": "Nama", + "unused": "Tidak digunakan — tidak ada argumen yang memakai '{{'{name}'}}'; anggota mengisinya tetapi tidak dikirim.", + "label": "Label", + "typeLabel": "Tipe", + "required": "Wajib", + "optionsLabel": "Opsi (satu per baris)", + "advancedOptions": "Opsi lanjutan", + "helpLabel": "Bantuan (opsional)", + "helpPlaceholder": "Ditampilkan di bawah input", + "minLabel": "Min (opsional)", + "maxLabel": "Maks (opsional)", + "patternLabel": "Pola (regex, opsional)", + "defaultLabel": "Default (opsional)" + }, + "fieldTypes": { + "account": "Akun NEAR", + "token": "Token", + "uint": "Bilangan bulat", + "amount": "Jumlah token", + "number": "Angka", + "text": "Teks", + "select": "Dropdown", + "bool": "Sakelar", + "json": "JSON" + }, + "args": { + "addArgument": "Tambah Argumen", + "argumentHeading": "Argumen {number}", + "remove": "Hapus", + "argumentNameLabel": "Nama argumen", + "argumentNamePlaceholder": "argument", + "typeLabel": "Tipe", + "modeStatic": "Statis", + "modeMemberInput": "Input anggota", + "valueTypeLabel": "Tipe nilai", + "valueLabel": "Nilai", + "stringValuePlaceholder": "teks atau '{{field}}'", + "insertField": "+ kolom", + "keyPlaceholder": "kunci", + "addKey": "Tambah Kunci", + "addItem": "Tambah Item" + }, + "form": { + "selectPlaceholder": "Pilih opsi", + "fileProposal": "Ajukan Proposal" + }, + "collapsePinned": "Ciutkan templat yang disematkan", + "expandPinned": "Bentangkan templat yang disematkan" } } diff --git a/nt-fe/messages/ja.json b/nt-fe/messages/ja.json index 604a6d652..22c5cd3a2 100644 --- a/nt-fe/messages/ja.json +++ b/nt-fe/messages/ja.json @@ -854,7 +854,8 @@ "general": "一般", "voting": "投票", "preferences": "環境設定", - "integrations": "連携" + "integrations": "連携", + "developer": "開発者" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "あなたはこのトレジャリーのゲストです。データの閲覧のみ可能です。", "guestSaveAction": "このゲストトレジャリーをトレジャリー一覧に保存できます。", "newFeatureRich": "新機能!🎉 頻繁に使用するアドレスを保存して、より速く、エラーのない支払いを実現します。連絡先は非公開で、チームのみが閲覧できます。", - "newFeatureCta": "試してみる" + "newFeatureCta": "試してみる", + "requestTemplates": "カスタムリクエストの準備が整いました。ここから開いて最初のリクエストタイプを作成しましょう。" }, "statusDate": { "expires": "有効期限", @@ -2187,5 +2189,139 @@ "videoTitle": "デモを見る", "videoDescription": "Trezuの動作を示す短い動画をご覧ください。" } + }, + "customTemplates": { + "pageTitle": "リクエストテンプレート", + "pageDescription": "カスタムリクエストタイプ用の再利用可能なテンプレートを作成します。", + "loading": "読み込み中…", + "back": "戻る", + "howItWorks": "仕組み", + "toastDeleted": "テンプレートを削除しました", + "errDelete": "テンプレートの削除に失敗しました", + "deleteDialog": { + "title": "テンプレートを削除", + "body": "<b>{name}</b> を削除しますか?メンバーはこのテンプレートから提案を提出できなくなります。この操作は元に戻せません。", + "confirm": "削除", + "deleting": "削除中…" + }, + "editor": { + "nameLabel": "名前", + "namePlaceholder": "Set Greeting", + "nameRequired": "名前は必須です", + "tabVisual": "ビジュアル", + "tabCode": "コード", + "manifestLabel": "マニフェスト(JSON)", + "invalidJsonToast": "ビジュアルビルダーに切り替える前に、無効な JSON を修正してください" + }, + "index": { + "emptyTitle": "最初のテンプレートを作成しましょう", + "emptyDescription": "よく送信するリクエスト用のテンプレートを作成します。チームはワンクリックで再利用できます。", + "createTemplate": "テンプレートを作成", + "heading": "テンプレート", + "addNew": "新規追加", + "createRequest": "リクエストを作成", + "actions": "テンプレートの操作", + "edit": "編集", + "pin": "サイドバーに固定", + "unpin": "テンプレートの固定を解除", + "delete": "削除", + "errPin": "固定を更新できませんでした" + }, + "create": { + "title": "新しいテンプレート", + "submit": "テンプレートを作成", + "submitting": "作成中…", + "toastCreated": "テンプレートを作成しました", + "errCreate": "テンプレートの作成に失敗しました" + }, + "edit": { + "title": "テンプレートを編集", + "submit": "変更を保存", + "deleteButton": "テンプレートを削除", + "toastSaved": "テンプレートを保存しました", + "errSave": "テンプレートの保存に失敗しました", + "notFound": "テンプレートが見つかりません。" + }, + "fill": { + "noTemplate": "「{slug}」に対応するテンプレートが見つかりません。", + "invalidManifest": "このテンプレートのマニフェストは無効です:", + "proposalFiled": "リクエストを提出しました" + }, + "developer": { + "title": "カスタムリクエスト", + "description": "支払いや交換以上の機能が必要な場合は、独自のリクエストタイプを作成できます。必要なフィールドを追加すれば、メンバーは他のリクエストと同じように送信できます。", + "enable": "有効化", + "disable": "無効化", + "errUpdate": "設定を更新できませんでした" + }, + "visual": { + "detailsTitle": "詳細", + "idLabel": "ID(slug)", + "titleLabel": "タイトル", + "descriptionLabel": "説明(任意)", + "summaryLabel": "概要(任意、'{{'fields'}}' に対応)", + "onChainCallTitle": "オンチェーン呼び出し", + "onChainCallDescription": "このテンプレートが提出する固定の FunctionCall です。", + "receiverLabel": "受信者(コントラクト)", + "methodLabel": "メソッド", + "depositLabel": "デポジット(yoctoNEAR)", + "gasLabel": "ガス", + "argumentsTitle": "引数", + "argumentsDescription": "コントラクトに送信される引数です。それぞれ静的(固定値)またはメンバー入力(動的)のいずれかで、後者の場合は入力の設定がインラインで表示されます。", + "otherInputsTitle": "その他の入力", + "otherInputsDescription": "合成値の中でのみ使用される入力(直接のメンバー入力引数ではないもの)と、手動で追加したものです。ほとんどのテンプレートにはありません。" + }, + "fields": { + "addField": "フィールドを追加", + "inputHeading": "入力 {number}", + "remove": "削除", + "nameLabel": "名前", + "unused": "未使用 — '{{'{name}'}}' を使用する引数はありません。メンバーは入力しますが、送信されません。", + "label": "ラベル", + "typeLabel": "タイプ", + "required": "必須", + "optionsLabel": "選択肢(1行に1つ)", + "advancedOptions": "詳細オプション", + "helpLabel": "ヘルプ(任意)", + "helpPlaceholder": "入力欄の下に表示されます", + "minLabel": "最小(任意)", + "maxLabel": "最大(任意)", + "patternLabel": "パターン(regex、任意)", + "defaultLabel": "デフォルト(任意)" + }, + "fieldTypes": { + "account": "NEAR アカウント", + "token": "トークン", + "uint": "整数", + "amount": "トークン量", + "number": "数値", + "text": "テキスト", + "select": "ドロップダウン", + "bool": "トグル", + "json": "JSON" + }, + "args": { + "addArgument": "引数を追加", + "argumentHeading": "引数 {number}", + "remove": "削除", + "argumentNameLabel": "引数名", + "argumentNamePlaceholder": "argument", + "typeLabel": "タイプ", + "modeStatic": "静的", + "modeMemberInput": "メンバー入力", + "valueTypeLabel": "値のタイプ", + "valueLabel": "値", + "stringValuePlaceholder": "テキストまたは '{{field}}'", + "insertField": "+ フィールド", + "keyPlaceholder": "キー", + "addKey": "キーを追加", + "addItem": "項目を追加" + }, + "form": { + "selectPlaceholder": "オプションを選択", + "fileProposal": "提案を提出" + }, + "collapsePinned": "固定したテンプレートを折りたたむ", + "expandPinned": "固定したテンプレートを展開する" } } diff --git a/nt-fe/messages/ko.json b/nt-fe/messages/ko.json index 0206ef022..9a82555a7 100644 --- a/nt-fe/messages/ko.json +++ b/nt-fe/messages/ko.json @@ -854,7 +854,8 @@ "general": "일반", "voting": "투표", "preferences": "환경설정", - "integrations": "통합" + "integrations": "통합", + "developer": "개발자" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "이 트레저리의 게스트입니다. 데이터 보기만 가능합니다.", "guestSaveAction": "이 게스트 트레저리를 트레저리 목록에 저장할 수 있습니다.", "newFeatureRich": "신규! 🎉 자주 사용하는 주소를 저장하여 더 빠르고 오류 없는 지급을 하세요. 연락처는 비공개로 유지되며 팀에게만 표시됩니다.", - "newFeatureCta": "사용해 보기" + "newFeatureCta": "사용해 보기", + "requestTemplates": "맞춤 요청이 준비되었습니다. 여기서 열어 첫 번째 요청 유형을 만들어 보세요." }, "statusDate": { "expires": "만료 시간", @@ -2187,5 +2189,139 @@ "videoTitle": "데모 시청", "videoDescription": "Trezu 작동 방식을 보여주는 짧은 비디오를 시청하세요." } + }, + "customTemplates": { + "pageTitle": "요청 템플릿", + "pageDescription": "맞춤 요청 유형을 위한 재사용 가능한 템플릿을 만드세요.", + "loading": "불러오는 중…", + "back": "뒤로", + "howItWorks": "작동 방식", + "toastDeleted": "템플릿이 삭제되었습니다", + "errDelete": "템플릿 삭제에 실패했습니다", + "deleteDialog": { + "title": "템플릿 삭제", + "body": "<b>{name}</b> 템플릿을 삭제할까요? 멤버가 더 이상 이 템플릿으로 제안을 제출할 수 없게 됩니다. 이 작업은 되돌릴 수 없습니다.", + "confirm": "삭제", + "deleting": "삭제 중…" + }, + "editor": { + "nameLabel": "이름", + "namePlaceholder": "인사말 설정", + "nameRequired": "이름은 필수입니다", + "tabVisual": "비주얼", + "tabCode": "코드", + "manifestLabel": "매니페스트 (JSON)", + "invalidJsonToast": "비주얼 빌더로 전환하기 전에 잘못된 JSON을 수정하세요" + }, + "index": { + "emptyTitle": "첫 번째 템플릿을 만드세요", + "emptyDescription": "자주 보내는 요청을 위한 템플릿을 만드세요. 팀이 클릭 한 번으로 재사용할 수 있습니다.", + "createTemplate": "템플릿 만들기", + "heading": "템플릿", + "addNew": "새로 추가", + "createRequest": "요청 만들기", + "actions": "템플릿 작업", + "edit": "편집", + "pin": "사이드바에 고정", + "unpin": "템플릿 고정 해제", + "delete": "삭제", + "errPin": "고정 상태를 업데이트할 수 없습니다" + }, + "create": { + "title": "새 템플릿", + "submit": "템플릿 만들기", + "submitting": "생성 중…", + "toastCreated": "템플릿이 생성되었습니다", + "errCreate": "템플릿 생성에 실패했습니다" + }, + "edit": { + "title": "템플릿 편집", + "submit": "변경 사항 저장", + "deleteButton": "템플릿 삭제", + "toastSaved": "템플릿이 저장되었습니다", + "errSave": "템플릿 저장에 실패했습니다", + "notFound": "템플릿을 찾을 수 없습니다." + }, + "fill": { + "noTemplate": "“{slug}”에 해당하는 템플릿을 찾을 수 없습니다.", + "invalidManifest": "이 템플릿의 매니페스트가 유효하지 않습니다:", + "proposalFiled": "요청이 제출되었습니다" + }, + "developer": { + "title": "맞춤 요청", + "description": "결제와 교환만으로는 부족할 때 직접 요청 유형을 만드세요. 필요한 필드를 추가하면 멤버가 다른 요청과 똑같이 제출할 수 있습니다.", + "enable": "사용", + "disable": "사용 안 함", + "errUpdate": "설정을 업데이트할 수 없습니다" + }, + "visual": { + "detailsTitle": "세부 정보", + "idLabel": "ID (슬러그)", + "titleLabel": "제목", + "descriptionLabel": "설명 (선택)", + "summaryLabel": "요약 (선택, '{{'fields'}}' 지원)", + "onChainCallTitle": "온체인 호출", + "onChainCallDescription": "이 템플릿이 제출하는 고정된 FunctionCall입니다.", + "receiverLabel": "수신자 (컨트랙트)", + "methodLabel": "메서드", + "depositLabel": "예치금 (yoctoNEAR)", + "gasLabel": "가스", + "argumentsTitle": "인자", + "argumentsDescription": "컨트랙트로 전송되는 인자입니다. 각 인자는 정적(고정 값) 또는 멤버 입력(동적) 중 하나이며, 후자의 경우 입력의 구성이 인라인으로 표시됩니다.", + "otherInputsTitle": "기타 입력", + "otherInputsDescription": "조합된 값 안에서만 사용되는 입력(직접적인 멤버 입력 인자가 아님)과 수동으로 추가한 입력입니다. 대부분의 템플릿에는 없습니다." + }, + "fields": { + "addField": "필드 추가", + "inputHeading": "입력 {number}", + "remove": "제거", + "nameLabel": "이름", + "unused": "사용되지 않음 — '{{'{name}'}}'을(를) 사용하는 인자가 없습니다. 멤버가 입력하더라도 전송되지 않습니다.", + "label": "라벨", + "typeLabel": "유형", + "required": "필수", + "optionsLabel": "옵션 (한 줄에 하나씩)", + "advancedOptions": "고급 옵션", + "helpLabel": "도움말 (선택)", + "helpPlaceholder": "입력 아래에 표시됩니다", + "minLabel": "최소 (선택)", + "maxLabel": "최대 (선택)", + "patternLabel": "패턴 (regex, 선택)", + "defaultLabel": "기본값 (선택)" + }, + "fieldTypes": { + "account": "NEAR 계정", + "token": "토큰", + "uint": "정수", + "amount": "토큰 수량", + "number": "숫자", + "text": "텍스트", + "select": "드롭다운", + "bool": "토글", + "json": "JSON" + }, + "args": { + "addArgument": "인자 추가", + "argumentHeading": "인자 {number}", + "remove": "제거", + "argumentNameLabel": "인자 이름", + "argumentNamePlaceholder": "argument", + "typeLabel": "유형", + "modeStatic": "정적", + "modeMemberInput": "멤버 입력", + "valueTypeLabel": "값 유형", + "valueLabel": "값", + "stringValuePlaceholder": "텍스트 또는 '{{field}}'", + "insertField": "+ 필드", + "keyPlaceholder": "키", + "addKey": "키 추가", + "addItem": "항목 추가" + }, + "form": { + "selectPlaceholder": "옵션을 선택하세요", + "fileProposal": "제안 제출" + }, + "collapsePinned": "고정한 템플릿 접기", + "expandPinned": "고정한 템플릿 펼치기" } } diff --git a/nt-fe/messages/pt.json b/nt-fe/messages/pt.json index 8fb3636c5..39a97f210 100644 --- a/nt-fe/messages/pt.json +++ b/nt-fe/messages/pt.json @@ -854,7 +854,8 @@ "general": "Geral", "voting": "Votação", "preferences": "Preferências", - "integrations": "Integrações" + "integrations": "Integrações", + "developer": "Desenvolvedor" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Você é um convidado desta tesouraria. Você só pode ver os dados.", "guestSaveAction": "Você pode salvar esta tesouraria de convidado na sua lista de tesourarias.", "newFeatureRich": "Novo! 🎉 Salve endereços usados com frequência para pagamentos mais rápidos e sem erros. Seus contatos permanecem privados e visíveis apenas para sua equipe.", - "newFeatureCta": "Experimente" + "newFeatureCta": "Experimente", + "requestTemplates": "As Solicitações personalizadas estão prontas. Abra-as aqui para criar seu primeiro tipo de solicitação." }, "statusDate": { "expires": "Expira", @@ -2187,5 +2189,139 @@ "videoTitle": "Assista à demonstração", "videoDescription": "Assista a um vídeo curto mostrando como a Trezu funciona." } + }, + "customTemplates": { + "pageTitle": "Modelos de solicitação", + "pageDescription": "Crie modelos reutilizáveis para tipos de solicitação personalizados.", + "loading": "Carregando…", + "back": "Voltar", + "howItWorks": "Como funciona", + "toastDeleted": "Modelo excluído", + "errDelete": "Falha ao excluir o modelo", + "deleteDialog": { + "title": "Excluir modelo", + "body": "Excluir <b>{name}</b>? Os membros não poderão mais registrar propostas a partir dele. Esta ação não pode ser desfeita.", + "confirm": "Excluir", + "deleting": "Excluindo…" + }, + "editor": { + "nameLabel": "Nome", + "namePlaceholder": "Definir saudação", + "nameRequired": "O nome é obrigatório", + "tabVisual": "Visual", + "tabCode": "Código", + "manifestLabel": "Manifesto (JSON)", + "invalidJsonToast": "Corrija o JSON inválido antes de alternar para o construtor visual" + }, + "index": { + "emptyTitle": "Crie seu primeiro modelo", + "emptyDescription": "Crie um modelo para as solicitações que você envia com frequência. Sua equipe o reutiliza com um clique.", + "createTemplate": "Criar modelo", + "heading": "Modelos", + "addNew": "Adicionar novo", + "createRequest": "Criar solicitação", + "actions": "Ações do modelo", + "edit": "Editar", + "pin": "Fixar na barra lateral", + "unpin": "Desafixar modelo", + "delete": "Excluir", + "errPin": "Não foi possível atualizar a fixação" + }, + "create": { + "title": "Novo modelo", + "submit": "Criar modelo", + "submitting": "Criando…", + "toastCreated": "Modelo criado", + "errCreate": "Falha ao criar o modelo" + }, + "edit": { + "title": "Editar modelo", + "submit": "Salvar alterações", + "deleteButton": "Excluir modelo", + "toastSaved": "Modelo salvo", + "errSave": "Falha ao salvar o modelo", + "notFound": "Modelo não encontrado." + }, + "fill": { + "noTemplate": "Nenhum modelo encontrado para “{slug}”.", + "invalidManifest": "O manifesto deste modelo é inválido:", + "proposalFiled": "Solicitação registrada" + }, + "developer": { + "title": "Solicitações personalizadas", + "description": "Crie seus próprios tipos de solicitação quando precisar de mais do que pagamentos e trocas. Adicione os campos de que você precisa e seus membros poderão enviá-los como qualquer outra solicitação.", + "enable": "Ativar", + "disable": "Desativar", + "errUpdate": "Não foi possível atualizar a configuração" + }, + "visual": { + "detailsTitle": "Detalhes", + "idLabel": "ID (slug)", + "titleLabel": "Título", + "descriptionLabel": "Descrição (opcional)", + "summaryLabel": "Resumo (opcional, suporta '{{'fields'}}')", + "onChainCallTitle": "Chamada on-chain", + "onChainCallDescription": "A FunctionCall fixa que este modelo registra.", + "receiverLabel": "Destinatário (contrato)", + "methodLabel": "Método", + "depositLabel": "Deposit (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Argumentos", + "argumentsDescription": "Os argumentos enviados ao contrato. Cada um é Estático (um valor fixo) ou uma entrada de membro (dinâmico) — para este último, a configuração da entrada aparece em linha.", + "otherInputsTitle": "Outras entradas", + "otherInputsDescription": "Entradas usadas apenas dentro de um valor composto (não como argumento direto de entrada de membro), além de quaisquer que você adicione manualmente. A maioria dos modelos não tem nenhuma." + }, + "fields": { + "addField": "Adicionar campo", + "inputHeading": "Entrada {number}", + "remove": "Remover", + "nameLabel": "Nome", + "unused": "Não utilizado — nenhum argumento usa '{{'{name}'}}'; os membros o preenchem, mas ele não é enviado.", + "label": "Rótulo", + "typeLabel": "Tipo", + "required": "Obrigatório", + "optionsLabel": "Opções (uma por linha)", + "advancedOptions": "Opções avançadas", + "helpLabel": "Ajuda (opcional)", + "helpPlaceholder": "Exibido abaixo da entrada", + "minLabel": "Mín. (opcional)", + "maxLabel": "Máx. (opcional)", + "patternLabel": "Padrão (regex, opcional)", + "defaultLabel": "Padrão (opcional)" + }, + "fieldTypes": { + "account": "Conta NEAR", + "token": "Token", + "uint": "Número inteiro", + "amount": "Quantidade de Token", + "number": "Número", + "text": "Texto", + "select": "Lista suspensa", + "bool": "Alternância", + "json": "JSON" + }, + "args": { + "addArgument": "Adicionar argumento", + "argumentHeading": "Argumento {number}", + "remove": "Remover", + "argumentNameLabel": "Nome do argumento", + "argumentNamePlaceholder": "argumento", + "typeLabel": "Tipo", + "modeStatic": "Estático", + "modeMemberInput": "Entrada de membro", + "valueTypeLabel": "Tipo de valor", + "valueLabel": "Valor", + "stringValuePlaceholder": "texto ou '{{field}}'", + "insertField": "+ campo", + "keyPlaceholder": "chave", + "addKey": "Adicionar chave", + "addItem": "Adicionar item" + }, + "form": { + "selectPlaceholder": "Escolha uma opção", + "fileProposal": "Registrar proposta" + }, + "collapsePinned": "Recolher modelos fixados", + "expandPinned": "Expandir modelos fixados" } } diff --git a/nt-fe/messages/tr.json b/nt-fe/messages/tr.json index 35c547342..5a3116552 100644 --- a/nt-fe/messages/tr.json +++ b/nt-fe/messages/tr.json @@ -854,7 +854,8 @@ "general": "Genel", "voting": "Oylama", "preferences": "Tercihler", - "integrations": "Entegrasyonlar" + "integrations": "Entegrasyonlar", + "developer": "Geliştirici" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Bu hazinenin misafirisiniz. Yalnızca verileri görüntüleyebilirsiniz.", "guestSaveAction": "Bu misafir hazineyi hazinelerinize kaydedebilirsiniz.", "newFeatureRich": "Yeni! 🎉 Daha hızlı, hatasız ödemeler için sık kullanılan adresleri kaydedin. Kişileriniz gizli kalır ve yalnızca ekibinize görünür.", - "newFeatureCta": "Deneyin" + "newFeatureCta": "Deneyin", + "requestTemplates": "Özel İstekler hazır. İlk istek türünüzü oluşturmak için burada açın." }, "statusDate": { "expires": "Sona Erer", @@ -2187,5 +2189,139 @@ "videoTitle": "Demoyu İzleyin", "videoDescription": "Trezu'nun nasıl çalıştığını gösteren kısa bir video izleyin." } + }, + "customTemplates": { + "pageTitle": "İstek Şablonları", + "pageDescription": "Özel istek türleri için yeniden kullanılabilir şablonlar oluşturun.", + "loading": "Yükleniyor…", + "back": "Geri", + "howItWorks": "Nasıl Çalışır", + "toastDeleted": "Şablon silindi", + "errDelete": "Şablon silinemedi", + "deleteDialog": { + "title": "Şablonu sil", + "body": "<b>{name}</b> şablonunu silmek istiyor musunuz? Üyeler artık bundan teklif oluşturamayacak. Bu işlem geri alınamaz.", + "confirm": "Sil", + "deleting": "Siliniyor…" + }, + "editor": { + "nameLabel": "Ad", + "namePlaceholder": "Karşılama Ayarla", + "nameRequired": "Ad gerekli", + "tabVisual": "Görsel", + "tabCode": "Kod", + "manifestLabel": "Manifest (JSON)", + "invalidJsonToast": "Görsel oluşturucuya geçmeden önce geçersiz JSON'ı düzeltin" + }, + "index": { + "emptyTitle": "İlk şablonunuzu oluşturun", + "emptyDescription": "Sık gönderdiğiniz istekler için bir şablon oluşturun. Ekibiniz onu tek tıkla yeniden kullanır.", + "createTemplate": "Şablon Oluştur", + "heading": "Şablonlar", + "addNew": "Yeni Ekle", + "createRequest": "İstek Oluştur", + "actions": "Şablon işlemleri", + "edit": "Düzenle", + "pin": "Kenar Çubuğuna Sabitle", + "unpin": "Şablonu Sabitlemeyi Kaldır", + "delete": "Sil", + "errPin": "Sabitleme güncellenemedi" + }, + "create": { + "title": "Yeni Şablon", + "submit": "Şablon Oluştur", + "submitting": "Oluşturuluyor…", + "toastCreated": "Şablon oluşturuldu", + "errCreate": "Şablon oluşturulamadı" + }, + "edit": { + "title": "Şablonu Düzenle", + "submit": "Değişiklikleri Kaydet", + "deleteButton": "Şablonu Sil", + "toastSaved": "Şablon kaydedildi", + "errSave": "Şablon kaydedilemedi", + "notFound": "Şablon bulunamadı." + }, + "fill": { + "noTemplate": "“{slug}” için şablon bulunamadı.", + "invalidManifest": "Bu şablonun manifesti geçersiz:", + "proposalFiled": "İstek oluşturuldu" + }, + "developer": { + "title": "Özel İstekler", + "description": "Ödemeler ve takastan fazlasına ihtiyaç duyduğunuzda kendi istek türlerinizi oluşturun. İhtiyacınız olan alanları ekleyin; üyeleriniz onları diğer istekler gibi gönderebilir.", + "enable": "Etkinleştir", + "disable": "Devre Dışı Bırak", + "errUpdate": "Ayar güncellenemedi" + }, + "visual": { + "detailsTitle": "Ayrıntılar", + "idLabel": "Kimlik (slug)", + "titleLabel": "Başlık", + "descriptionLabel": "Açıklama (isteğe bağlı)", + "summaryLabel": "Özet (isteğe bağlı, '{{'fields'}}' destekler)", + "onChainCallTitle": "Zincir üstü çağrı", + "onChainCallDescription": "Bu şablonun oluşturduğu sabit FunctionCall.", + "receiverLabel": "Alıcı (kontrat)", + "methodLabel": "Metot", + "depositLabel": "Depozito (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Argümanlar", + "argumentsDescription": "Kontrata gönderilen argümanlar. Her biri Statik (sabit bir değer) ya da Üye girdisidir (dinamik) — ikincisinde girdinin yapılandırması satır içinde görünür.", + "otherInputsTitle": "Diğer girdiler", + "otherInputsDescription": "Yalnızca birleştirilmiş bir değerin içinde kullanılan girdiler (doğrudan bir üye-girdi argümanı değil) ve manuel olarak eklediğiniz girdiler. Çoğu şablonda hiç bulunmaz." + }, + "fields": { + "addField": "Alan Ekle", + "inputHeading": "Girdi {number}", + "remove": "Kaldır", + "nameLabel": "Ad", + "unused": "Kullanılmıyor — hiçbir argüman '{{'{name}'}}' kullanmıyor; üyeler doldurur ama gönderilmez.", + "label": "Etiket", + "typeLabel": "Tür", + "required": "Zorunlu", + "optionsLabel": "Seçenekler (her satıra bir tane)", + "advancedOptions": "Gelişmiş seçenekler", + "helpLabel": "Yardım (isteğe bağlı)", + "helpPlaceholder": "Girdinin altında gösterilir", + "minLabel": "Min (isteğe bağlı)", + "maxLabel": "Maks (isteğe bağlı)", + "patternLabel": "Desen (regex, isteğe bağlı)", + "defaultLabel": "Varsayılan (isteğe bağlı)" + }, + "fieldTypes": { + "account": "NEAR hesabı", + "token": "Token", + "uint": "Tam sayı", + "amount": "Token miktarı", + "number": "Sayı", + "text": "Metin", + "select": "Açılır liste", + "bool": "Anahtar", + "json": "JSON" + }, + "args": { + "addArgument": "Argüman Ekle", + "argumentHeading": "Argüman {number}", + "remove": "Kaldır", + "argumentNameLabel": "Argüman adı", + "argumentNamePlaceholder": "argüman", + "typeLabel": "Tür", + "modeStatic": "Statik", + "modeMemberInput": "Üye girdisi", + "valueTypeLabel": "Değer türü", + "valueLabel": "Değer", + "stringValuePlaceholder": "metin veya '{{field}}'", + "insertField": "+ alan", + "keyPlaceholder": "anahtar", + "addKey": "Anahtar Ekle", + "addItem": "Öğe Ekle" + }, + "form": { + "selectPlaceholder": "Bir seçenek seçin", + "fileProposal": "Teklif Oluştur" + }, + "collapsePinned": "Sabitlenmiş şablonları daralt", + "expandPinned": "Sabitlenmiş şablonları genişlet" } } diff --git a/nt-fe/messages/uk.json b/nt-fe/messages/uk.json index f1e8e87e6..298d43035 100644 --- a/nt-fe/messages/uk.json +++ b/nt-fe/messages/uk.json @@ -854,7 +854,8 @@ "general": "Загальні", "voting": "Голосування", "preferences": "Уподобання", - "integrations": "Інтеграції" + "integrations": "Інтеграції", + "developer": "Розробник" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Ви — гість цієї скарбниці. Можете лише переглядати дані.", "guestSaveAction": "Цю гостьову скарбницю можна зберегти у ваш список.", "newFeatureRich": "Нове! 🎉 Зберігайте часто використовувані адреси для швидших і безпомилкових виплат. Ваші контакти лишаються приватними та видимими лише вашій команді.", - "newFeatureCta": "Спробувати" + "newFeatureCta": "Спробувати", + "requestTemplates": "Власні запити готові. Відкрийте їх тут, щоб створити свій перший тип запиту." }, "statusDate": { "expires": "Закінчується", @@ -2187,5 +2189,139 @@ "videoTitle": "Переглянути демо", "videoDescription": "Перегляньте короткий відеоогляд роботи Trezu." } + }, + "customTemplates": { + "pageTitle": "Шаблони запитів", + "pageDescription": "Створюйте багаторазові шаблони для власних типів запитів.", + "loading": "Завантаження…", + "back": "Назад", + "howItWorks": "Як це працює", + "toastDeleted": "Шаблон видалено", + "errDelete": "Не вдалося видалити шаблон", + "deleteDialog": { + "title": "Видалити шаблон", + "body": "Видалити <b>{name}</b>? Учасники більше не зможуть подавати пропозиції на його основі. Цю дію не можна скасувати.", + "confirm": "Видалити", + "deleting": "Видалення…" + }, + "editor": { + "nameLabel": "Назва", + "namePlaceholder": "Встановити вітання", + "nameRequired": "Назва обовʼязкова", + "tabVisual": "Візуальний", + "tabCode": "Код", + "manifestLabel": "Маніфест (JSON)", + "invalidJsonToast": "Виправте некоректний JSON перед переходом до візуального конструктора" + }, + "index": { + "emptyTitle": "Створіть свій перший шаблон", + "emptyDescription": "Створіть шаблон для запитів, які ви надсилаєте часто. Ваша команда повторно використовує його одним кліком.", + "createTemplate": "Створити шаблон", + "heading": "Шаблони", + "addNew": "Додати новий", + "createRequest": "Створити запит", + "actions": "Дії з шаблоном", + "edit": "Редагувати", + "pin": "Закріпити на бічній панелі", + "unpin": "Відкріпити шаблон", + "delete": "Видалити", + "errPin": "Не вдалося оновити закріплення" + }, + "create": { + "title": "Новий шаблон", + "submit": "Створити шаблон", + "submitting": "Створення…", + "toastCreated": "Шаблон створено", + "errCreate": "Не вдалося створити шаблон" + }, + "edit": { + "title": "Редагувати шаблон", + "submit": "Зберегти зміни", + "deleteButton": "Видалити шаблон", + "toastSaved": "Шаблон збережено", + "errSave": "Не вдалося зберегти шаблон", + "notFound": "Шаблон не знайдено." + }, + "fill": { + "noTemplate": "Не знайдено шаблону для «{slug}».", + "invalidManifest": "Маніфест цього шаблону некоректний:", + "proposalFiled": "Запит подано" + }, + "developer": { + "title": "Власні запити", + "description": "Створюйте власні типи запитів, коли вам потрібно більше, ніж платежі та обмін. Додайте потрібні поля, і ваші учасники зможуть надсилати їх, як будь-який інший запит.", + "enable": "Увімкнути", + "disable": "Вимкнути", + "errUpdate": "Не вдалося оновити налаштування" + }, + "visual": { + "detailsTitle": "Деталі", + "idLabel": "ID (slug)", + "titleLabel": "Заголовок", + "descriptionLabel": "Опис (необовʼязково)", + "summaryLabel": "Підсумок (необовʼязково, підтримує '{{'fields'}}')", + "onChainCallTitle": "Виклик у блокчейні", + "onChainCallDescription": "Фіксований FunctionCall, який подає цей шаблон.", + "receiverLabel": "Отримувач (контракт)", + "methodLabel": "Метод", + "depositLabel": "Депозит (yoctoNEAR)", + "gasLabel": "Газ", + "argumentsTitle": "Аргументи", + "argumentsDescription": "Аргументи, що надсилаються до контракту. Кожен є Статичним (фіксоване значення) або Введенням учасника (динамічним) — для останнього налаштування введення відображається поряд.", + "otherInputsTitle": "Інші введення", + "otherInputsDescription": "Введення, що використовуються лише всередині складеного значення (не як прямий аргумент введення учасника), а також будь-які додані вручну. Більшість шаблонів їх не мають." + }, + "fields": { + "addField": "Додати поле", + "inputHeading": "Введення {number}", + "remove": "Видалити", + "nameLabel": "Назва", + "unused": "Не використовується — жоден аргумент не використовує '{{'{name}'}}'; учасники заповнюють його, але воно не надсилається.", + "label": "Підпис", + "typeLabel": "Тип", + "required": "Обовʼязкове", + "optionsLabel": "Варіанти (по одному в рядку)", + "advancedOptions": "Розширені налаштування", + "helpLabel": "Підказка (необовʼязково)", + "helpPlaceholder": "Відображається під полем введення", + "minLabel": "Мін (необовʼязково)", + "maxLabel": "Макс (необовʼязково)", + "patternLabel": "Шаблон (regex, необовʼязково)", + "defaultLabel": "Значення за замовчуванням (необовʼязково)" + }, + "fieldTypes": { + "account": "Обліковий запис NEAR", + "token": "Токен", + "uint": "Ціле число", + "amount": "Кількість токенів", + "number": "Число", + "text": "Текст", + "select": "Випадний список", + "bool": "Перемикач", + "json": "JSON" + }, + "args": { + "addArgument": "Додати аргумент", + "argumentHeading": "Аргумент {number}", + "remove": "Видалити", + "argumentNameLabel": "Назва аргументу", + "argumentNamePlaceholder": "аргумент", + "typeLabel": "Тип", + "modeStatic": "Статичний", + "modeMemberInput": "Введення учасника", + "valueTypeLabel": "Тип значення", + "valueLabel": "Значення", + "stringValuePlaceholder": "текст або '{{field}}'", + "insertField": "+ поле", + "keyPlaceholder": "ключ", + "addKey": "Додати ключ", + "addItem": "Додати елемент" + }, + "form": { + "selectPlaceholder": "Виберіть варіант", + "fileProposal": "Подати пропозицію" + }, + "collapsePinned": "Згорнути закріплені шаблони", + "expandPinned": "Розгорнути закріплені шаблони" } } diff --git a/nt-fe/messages/vi.json b/nt-fe/messages/vi.json index b0c295e85..6fea20967 100644 --- a/nt-fe/messages/vi.json +++ b/nt-fe/messages/vi.json @@ -854,7 +854,8 @@ "general": "Chung", "voting": "Bỏ phiếu", "preferences": "Tùy chọn", - "integrations": "Tích hợp" + "integrations": "Tích hợp", + "developer": "Nhà phát triển" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "Bạn là khách của ngân quỹ này. Bạn chỉ có thể xem dữ liệu.", "guestSaveAction": "Bạn có thể lưu ngân quỹ khách này vào danh sách ngân quỹ của bạn.", "newFeatureRich": "Mới! 🎉 Lưu các địa chỉ thường dùng để chi trả nhanh hơn và không có lỗi. Liên hệ của bạn được giữ riêng tư và chỉ đội của bạn nhìn thấy.", - "newFeatureCta": "Hãy thử" + "newFeatureCta": "Hãy thử", + "requestTemplates": "Yêu cầu tùy chỉnh đã sẵn sàng. Mở nó tại đây để tạo loại yêu cầu đầu tiên của bạn." }, "statusDate": { "expires": "Hết hạn", @@ -2187,5 +2189,139 @@ "videoTitle": "Xem demo", "videoDescription": "Xem video ngắn cho thấy cách Trezu hoạt động." } + }, + "customTemplates": { + "pageTitle": "Mẫu yêu cầu", + "pageDescription": "Tạo các mẫu có thể tái sử dụng cho các loại yêu cầu tùy chỉnh.", + "loading": "Đang tải…", + "back": "Quay lại", + "howItWorks": "Cách hoạt động", + "toastDeleted": "Đã xóa mẫu", + "errDelete": "Không thể xóa mẫu", + "deleteDialog": { + "title": "Xóa mẫu", + "body": "Xóa <b>{name}</b>? Thành viên sẽ không còn có thể nộp đề xuất từ mẫu này. Thao tác này không thể hoàn tác.", + "confirm": "Xóa", + "deleting": "Đang xóa…" + }, + "editor": { + "nameLabel": "Tên", + "namePlaceholder": "Đặt lời chào", + "nameRequired": "Tên là bắt buộc", + "tabVisual": "Trực quan", + "tabCode": "Mã", + "manifestLabel": "Manifest (JSON)", + "invalidJsonToast": "Hãy sửa JSON không hợp lệ trước khi chuyển sang trình tạo trực quan" + }, + "index": { + "emptyTitle": "Tạo mẫu đầu tiên của bạn", + "emptyDescription": "Tạo một mẫu cho các yêu cầu bạn gửi thường xuyên. Nhóm của bạn tái sử dụng nó chỉ với một cú nhấp.", + "createTemplate": "Tạo mẫu", + "heading": "Mẫu", + "addNew": "Thêm mới", + "createRequest": "Tạo yêu cầu", + "actions": "Hành động với mẫu", + "edit": "Chỉnh sửa", + "pin": "Ghim vào thanh bên", + "unpin": "Bỏ ghim mẫu", + "delete": "Xóa", + "errPin": "Không thể cập nhật ghim" + }, + "create": { + "title": "Mẫu mới", + "submit": "Tạo mẫu", + "submitting": "Đang tạo…", + "toastCreated": "Đã tạo mẫu", + "errCreate": "Không thể tạo mẫu" + }, + "edit": { + "title": "Chỉnh sửa mẫu", + "submit": "Lưu thay đổi", + "deleteButton": "Xóa mẫu", + "toastSaved": "Đã lưu mẫu", + "errSave": "Không thể lưu mẫu", + "notFound": "Không tìm thấy mẫu." + }, + "fill": { + "noTemplate": "Không tìm thấy mẫu cho “{slug}”.", + "invalidManifest": "Manifest của mẫu này không hợp lệ:", + "proposalFiled": "Đã nộp yêu cầu" + }, + "developer": { + "title": "Yêu cầu tùy chỉnh", + "description": "Tạo các loại yêu cầu của riêng bạn khi bạn cần nhiều hơn thanh toán và trao đổi. Thêm các trường bạn cần và các thành viên của bạn có thể gửi chúng như bất kỳ yêu cầu nào khác.", + "enable": "Bật", + "disable": "Tắt", + "errUpdate": "Không thể cập nhật cài đặt" + }, + "visual": { + "detailsTitle": "Chi tiết", + "idLabel": "ID (slug)", + "titleLabel": "Tiêu đề", + "descriptionLabel": "Mô tả (tùy chọn)", + "summaryLabel": "Tóm tắt (tùy chọn, hỗ trợ '{{'fields'}}')", + "onChainCallTitle": "Lời gọi on-chain", + "onChainCallDescription": "FunctionCall cố định mà mẫu này nộp.", + "receiverLabel": "Người nhận (hợp đồng)", + "methodLabel": "Phương thức", + "depositLabel": "Tiền gửi (yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "Đối số", + "argumentsDescription": "Các đối số được gửi đến hợp đồng. Mỗi đối số là Tĩnh (một giá trị cố định) hoặc Đầu vào của thành viên (động) — đối với loại thứ hai, cấu hình của đầu vào sẽ hiển thị nội tuyến.", + "otherInputsTitle": "Đầu vào khác", + "otherInputsDescription": "Các đầu vào chỉ được sử dụng bên trong một giá trị tổng hợp (không phải là đối số đầu vào trực tiếp của thành viên), cùng với bất kỳ đầu vào nào bạn thêm thủ công. Hầu hết các mẫu không có đầu vào nào." + }, + "fields": { + "addField": "Thêm trường", + "inputHeading": "Đầu vào {number}", + "remove": "Xóa", + "nameLabel": "Tên", + "unused": "Không sử dụng — không có đối số nào dùng '{{'{name}'}}'; thành viên điền nó nhưng nó không được gửi đi.", + "label": "Nhãn", + "typeLabel": "Loại", + "required": "Bắt buộc", + "optionsLabel": "Tùy chọn (mỗi dòng một mục)", + "advancedOptions": "Tùy chọn nâng cao", + "helpLabel": "Trợ giúp (tùy chọn)", + "helpPlaceholder": "Hiển thị bên dưới đầu vào", + "minLabel": "Tối thiểu (tùy chọn)", + "maxLabel": "Tối đa (tùy chọn)", + "patternLabel": "Mẫu (regex, tùy chọn)", + "defaultLabel": "Mặc định (tùy chọn)" + }, + "fieldTypes": { + "account": "Tài khoản NEAR", + "token": "Token", + "uint": "Số nguyên", + "amount": "Số lượng token", + "number": "Số", + "text": "Văn bản", + "select": "Danh sách thả xuống", + "bool": "Công tắc", + "json": "JSON" + }, + "args": { + "addArgument": "Thêm đối số", + "argumentHeading": "Đối số {number}", + "remove": "Xóa", + "argumentNameLabel": "Tên đối số", + "argumentNamePlaceholder": "đối số", + "typeLabel": "Loại", + "modeStatic": "Tĩnh", + "modeMemberInput": "Đầu vào của thành viên", + "valueTypeLabel": "Loại giá trị", + "valueLabel": "Giá trị", + "stringValuePlaceholder": "văn bản hoặc '{{field}}'", + "insertField": "+ trường", + "keyPlaceholder": "khóa", + "addKey": "Thêm khóa", + "addItem": "Thêm mục" + }, + "form": { + "selectPlaceholder": "Chọn một tùy chọn", + "fileProposal": "Nộp đề xuất" + }, + "collapsePinned": "Thu gọn mẫu đã ghim", + "expandPinned": "Mở rộng mẫu đã ghim" } } diff --git a/nt-fe/messages/zh.json b/nt-fe/messages/zh.json index 249c4c008..2aaf12146 100644 --- a/nt-fe/messages/zh.json +++ b/nt-fe/messages/zh.json @@ -854,7 +854,8 @@ "general": "常规", "voting": "投票", "preferences": "偏好设置", - "integrations": "集成" + "integrations": "集成", + "developer": "开发者" }, "general": { "validation": { @@ -1918,7 +1919,8 @@ "guestSaveIntro": "您是此金库的访客。您只能查看数据。", "guestSaveAction": "您可以将此访客金库保存到您的金库列表。", "newFeatureRich": "新功能!🎉 保存常用地址,实现更快、零错误的付款。 您的联系人保持私密,仅对您的团队可见。", - "newFeatureCta": "试用一下" + "newFeatureCta": "试用一下", + "requestTemplates": "自定义请求已就绪。在此打开它,构建你的第一个请求类型。" }, "statusDate": { "expires": "过期", @@ -2187,5 +2189,139 @@ "videoTitle": "观看演示", "videoDescription": "观看一段简短视频,了解 Trezu 的工作方式。" } + }, + "customTemplates": { + "pageTitle": "请求模板", + "pageDescription": "为自定义请求类型构建可复用的模板。", + "loading": "加载中…", + "back": "返回", + "howItWorks": "工作原理", + "toastDeleted": "模板已删除", + "errDelete": "删除模板失败", + "deleteDialog": { + "title": "删除模板", + "body": "确定删除 <b>{name}</b>?成员将无法再使用它发起提案。此操作无法撤销。", + "confirm": "删除", + "deleting": "正在删除…" + }, + "editor": { + "nameLabel": "名称", + "namePlaceholder": "设置问候语", + "nameRequired": "名称为必填项", + "tabVisual": "可视化", + "tabCode": "代码", + "manifestLabel": "清单(JSON)", + "invalidJsonToast": "切换到可视化构建器前,请先修正无效的 JSON" + }, + "index": { + "emptyTitle": "创建你的第一个模板", + "emptyDescription": "为你经常发送的请求创建一个模板。你的团队可以一键复用它。", + "createTemplate": "创建模板", + "heading": "模板", + "addNew": "新增", + "createRequest": "创建请求", + "actions": "模板操作", + "edit": "编辑", + "pin": "固定到侧边栏", + "unpin": "取消固定模板", + "delete": "删除", + "errPin": "无法更新固定状态" + }, + "create": { + "title": "新建模板", + "submit": "创建模板", + "submitting": "正在创建…", + "toastCreated": "模板已创建", + "errCreate": "创建模板失败" + }, + "edit": { + "title": "编辑模板", + "submit": "保存更改", + "deleteButton": "删除模板", + "toastSaved": "模板已保存", + "errSave": "保存模板失败", + "notFound": "未找到模板。" + }, + "fill": { + "noTemplate": "未找到与“{slug}”对应的模板。", + "invalidManifest": "此模板的清单无效:", + "proposalFiled": "请求已提交" + }, + "developer": { + "title": "自定义请求", + "description": "当你需要的不只是付款和兑换时,可构建你自己的请求类型。添加你需要的字段,你的成员便能像提交其他请求一样提交它们。", + "enable": "启用", + "disable": "停用", + "errUpdate": "无法更新此设置" + }, + "visual": { + "detailsTitle": "详情", + "idLabel": "ID(slug)", + "titleLabel": "标题", + "descriptionLabel": "描述(可选)", + "summaryLabel": "摘要(可选,支持 '{{'fields'}}')", + "onChainCallTitle": "链上调用", + "onChainCallDescription": "此模板提交的固定 FunctionCall。", + "receiverLabel": "接收方(合约)", + "methodLabel": "方法", + "depositLabel": "存入金额(yoctoNEAR)", + "gasLabel": "Gas", + "argumentsTitle": "参数", + "argumentsDescription": "发送给合约的参数。每个参数为静态值(固定值)或成员输入(动态)——后者的配置会内联显示。", + "otherInputsTitle": "其他输入", + "otherInputsDescription": "仅在组合值内部使用的输入(而非直接作为成员输入参数),以及你手动添加的任何输入。大多数模板没有此类输入。" + }, + "fields": { + "addField": "添加字段", + "inputHeading": "输入 {number}", + "remove": "移除", + "nameLabel": "名称", + "unused": "未使用 —— 没有参数使用 '{{'{name}'}}';成员会填写它,但不会被发送。", + "label": "标签", + "typeLabel": "类型", + "required": "必填", + "optionsLabel": "选项(每行一个)", + "advancedOptions": "高级选项", + "helpLabel": "帮助说明(可选)", + "helpPlaceholder": "显示在输入框下方", + "minLabel": "最小值(可选)", + "maxLabel": "最大值(可选)", + "patternLabel": "格式(regex,可选)", + "defaultLabel": "默认值(可选)" + }, + "fieldTypes": { + "account": "NEAR 账户", + "token": "代币", + "uint": "整数", + "amount": "代币数量", + "number": "数字", + "text": "文本", + "select": "下拉菜单", + "bool": "开关", + "json": "JSON" + }, + "args": { + "addArgument": "添加参数", + "argumentHeading": "参数 {number}", + "remove": "移除", + "argumentNameLabel": "参数名称", + "argumentNamePlaceholder": "参数", + "typeLabel": "类型", + "modeStatic": "静态值", + "modeMemberInput": "成员输入", + "valueTypeLabel": "值类型", + "valueLabel": "值", + "stringValuePlaceholder": "文本或 '{{field}}'", + "insertField": "+ 字段", + "keyPlaceholder": "键", + "addKey": "添加键", + "addItem": "添加项" + }, + "form": { + "selectPlaceholder": "请选择一个选项", + "fileProposal": "提交提案" + }, + "collapsePinned": "折叠已固定的模板", + "expandPinned": "展开已固定的模板" } } From bccf04baddbbe3fce15c0587878fed04ec63ff46 Mon Sep 17 00:00:00 2001 From: Bohdan Khorolets <bogdan@khorolets.com> Date: Mon, 29 Jun 2026 22:11:26 +0300 Subject: [PATCH 3/3] fix(custom-templates): serialize bool/number/json inputs as typed JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member-filled value reached the contract as a string: a `{{field}}` placeholder always stringified, so a bool toggle serialized as {"approved":"true"} and a number as {"count":"5"} — which serde-based contracts reject. That made the `bool` and `number` field types effectively unusable for the methods they imply. A lone `{{field}}` bound to a bool/number/json field now resolves to its typed JSON value (true / 5 / parsed object), while u128 uint/amount and any composed value stay strings. Covered by build-proposal tests. Also folds in the review fixes made alongside it: - validate binding.receiver_id (NEAR account) and method_name (identifier) in the manifest validator and backend validate_manifest - drop the RegExp lookbehind in the placeholder matcher (Safari <16.4 throws at module load); handle escaped {{{{...}}}} via alternation instead - cap the author regex pattern and the input fed to pattern.test (ReDoS) - localize the Developer tab, the Request Templates sidebar entry, and member-facing form-validation messages across all 13 locales - both sidebar navigations use a real <Link> href - move the distributable skill from skills/ to docs/ - How It Works empty-state button -> outline; tour copy grammar; wrap the touched-gating SSR test in an intl provider --- .../custom-templates/[slug]/edit/page.tsx | 2 +- .../custom-templates/[slug]/page.tsx | 2 +- .../custom-templates/create/page.tsx | 2 +- .../[treasuryId]/custom-templates/page.tsx | 5 +- .../settings/components/developer-tab.tsx | 4 +- .../proposal-templates/build-proposal.test.ts | 55 +++++++++++ .../proposal-templates/build-proposal.ts | 96 ++++++++++++++++--- .../components/manifest-form.tsx | 23 ++++- .../components/template-editor.tsx | 4 +- .../components/touched-gating.test.tsx | 16 +++- .../proposal-templates/form-schema.test.ts | 22 ++++- .../proposal-templates/form-schema.ts | 57 ++++++++--- nt-fe/features/proposal-templates/manifest.ts | 27 +++--- nt-fe/messages/de.json | 14 ++- nt-fe/messages/en.json | 14 ++- nt-fe/messages/es.json | 14 ++- nt-fe/messages/fr.json | 14 ++- nt-fe/messages/he.json | 14 ++- nt-fe/messages/id.json | 14 ++- nt-fe/messages/ja.json | 14 ++- nt-fe/messages/ko.json | 14 ++- nt-fe/messages/pt.json | 14 ++- nt-fe/messages/tr.json | 14 ++- nt-fe/messages/uk.json | 14 ++- nt-fe/messages/vi.json | 14 ++- nt-fe/messages/zh.json | 14 ++- 26 files changed, 429 insertions(+), 68 deletions(-) diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx index 86b97c871..c9da69014 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/edit/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useParams, useRouter } from "next/navigation"; /** * Edit or delete an existing template. Reuses TemplateEditor (Visual constructor by default, Code * tab for raw JSON) pre-filled with the template's current name + manifest, saving via the @@ -7,7 +8,6 @@ * non-author hitting save/delete gets the backend's 403 as a toast. */ import { useTranslations } from "next-intl"; -import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/button"; diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx index ef7f8a5a4..c2772adf4 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/[slug]/page.tsx @@ -7,8 +7,8 @@ * same gasless-relayer route the core proposals use, so a custom request behaves like a built-in one. */ import { ArrowLeft } from "lucide-react"; -import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { PageCard } from "@/components/card"; import { PageComponentLayout } from "@/components/page-component-layout"; diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx index fbdd43f1a..c4bd01192 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/create/page.tsx @@ -1,12 +1,12 @@ "use client"; +import { useRouter } from "next/navigation"; /** * Author a new proposal template: the shared TemplateEditor (Visual constructor by default, with a * Code tab for raw manifest JSON) validates live, then saves via the `ChangePolicy`-gated create * endpoint. Lives at the reserved `create` slug. */ import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { PageComponentLayout } from "@/components/page-component-layout"; import { apiErrorMessage } from "@/features/proposal-templates/api"; diff --git a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx index aa6d7347e..dc6220caa 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/custom-templates/page.tsx @@ -16,8 +16,8 @@ import { Plus, Trash2, } from "lucide-react"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/button"; @@ -238,7 +238,8 @@ export default function CustomTemplatesIndexPage() { variant="secondary" onClick={() => go("/create")} > - <Plus className="size-4" /> {t("index.addNew")} + <Plus className="size-4" />{" "} + {t("index.addNew")} </Button> </div> </div> diff --git a/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx b/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx index c52659f36..d6891a882 100644 --- a/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx +++ b/nt-fe/app/(treasury)/[treasuryId]/settings/components/developer-tab.tsx @@ -68,9 +68,7 @@ export function DeveloperTab() { } }, onError: (error) => - toast.error( - apiErrorMessage(error, t("developer.errUpdate")), - ), + toast.error(apiErrorMessage(error, t("developer.errUpdate"))), }); } diff --git a/nt-fe/features/proposal-templates/build-proposal.test.ts b/nt-fe/features/proposal-templates/build-proposal.test.ts index 2af3646d9..9bd989cd2 100644 --- a/nt-fe/features/proposal-templates/build-proposal.test.ts +++ b/nt-fe/features/proposal-templates/build-proposal.test.ts @@ -3,6 +3,7 @@ import { base64ToJson } from "@/lib/utils"; import { buildTemplateProposal, interpolateArgs } from "./build-proposal"; import { type Manifest, + type ManifestFieldType, manifestErrorMessages, parseManifest, } from "./manifest"; @@ -47,6 +48,43 @@ const guestbookValues = { tip: "5", }; +// A call whose args mix every typed-injection case: a lone bool/number/json reaches the contract +// typed, while a u128 `uint` and any composed value stay strings. +const rawTyped = { + version: 1, + id: "typed-args", + title: "Typed Args", + binding: { + receiver_id: "config.near", + method_name: "configure", + deposit: "0", + gas: "30000000000000", + }, + fields: [ + { name: "enabled", label: "Enabled", type: "bool" }, + { name: "count", label: "Count", type: "number" }, + { name: "config", label: "Config", type: "json" }, + { name: "amount", label: "Amount", type: "uint" }, + { name: "note", label: "Note", type: "text" }, + ], + args: { + enabled: "{{enabled}}", // lone bool -> boolean + count: "{{count}}", // lone number -> number + config: "{{config}}", // lone json -> parsed value + amount: "{{amount}}", // lone uint -> string (u128 safety) + note: "{{note}}", // lone text -> string + label: "n={{count}}", // composed -> string + }, +}; + +const typedValues = { + enabled: true, + count: "5", + config: '{"k":1}', + amount: "1000", + note: "hi", +}; + describe("interpolateArgs", () => { it("substitutes placeholders in nested strings, arrays, and objects", () => { const out = interpolateArgs( @@ -76,6 +114,11 @@ describe("interpolateArgs", () => { ); }); + it("does not type-resolve an escaped {{{{name}}}} (stays a string)", () => { + const types = new Map<string, ManifestFieldType>([["b", "bool"]]); + expect(interpolateArgs("{{{{b}}}}", { b: true }, types)).toBe("{{b}}"); + }); + it("returns an empty args object unchanged", () => { expect(interpolateArgs({}, {})).toEqual({}); }); @@ -135,4 +178,16 @@ describe("buildTemplateProposal", () => { ); expect(description).toContain("Guestbook Tip"); }); + + it("emits a lone bool/number/json typed, but keeps uint and composed strings", () => { + const { kind } = buildTemplateProposal(manifest(rawTyped), typedValues); + expect(base64ToJson(kind.FunctionCall.actions[0].args)).toEqual({ + enabled: true, // boolean, not "true" + count: 5, // number, not "5" + config: { k: 1 }, // parsed JSON, not the string + amount: "1000", // u128 stays a digit string + note: "hi", + label: "n=5", // composed stays a string + }); + }); }); diff --git a/nt-fe/features/proposal-templates/build-proposal.ts b/nt-fe/features/proposal-templates/build-proposal.ts index ad37cf7e3..5163059c7 100644 --- a/nt-fe/features/proposal-templates/build-proposal.ts +++ b/nt-fe/features/proposal-templates/build-proposal.ts @@ -5,16 +5,24 @@ * * Pure and framework-free, so it unit-tests without React or a wallet, and it reuses the manifest * module's `substitutePlaceholders` so the args it emits can never drift from what the validator - * checked. Two distinct rules govern the `args` tree: (1) placeholder substitution happens only - * inside string values — a `{{field}}` is replaced in-place and its value is always stringified - * (NEAR amounts stay digit strings, so u128 values never round-trip through a JS number); (2) any - * static non-string node in the template — a number, boolean, or nested object/array literal — - * passes through verbatim, keeping its JSON type. So a manifest can emit typed JSON args; what's - * string-only is the substitution of placeholders, not the args themselves. + * checked. Two distinct rules govern the `args` tree: + * (1) A value that is exactly one placeholder — `"{{field}}"` and nothing else — resolves to that + * field's *typed* JSON: a `bool` field yields a real boolean, `number` a real number, and + * `json` the parsed value, so a contract's serde accepts them. Every other type (notably the + * u128 `uint`/`amount`, which must stay digit strings) and any *composed* string + * (`"{{a}}/{{b}}"`, or a field embedded in a JSON string) stays a string. + * (2) Any static non-string node in the template — a number, boolean, or nested object/array + * literal — passes through verbatim, keeping its JSON type. + * So a member-filled `bool`/`number`/`json` lands typed; everything else, and all composition, is + * string — and amounts never round-trip through a JS number. */ import type { FunctionCallKind } from "@/lib/proposals-api"; import { encodeToMarkdown, jsonToBase64 } from "@/lib/utils"; -import { type Manifest, substitutePlaceholders } from "./manifest"; +import { + type Manifest, + type ManifestFieldType, + substitutePlaceholders, +} from "./manifest"; /** Values a member filled, keyed by field `name`. */ export type FieldValues = Record<string, unknown>; @@ -45,21 +53,84 @@ function interpolateString(text: string, values: FieldValues): string { return substitutePlaceholders(text, (name) => resolveValue(values[name])); } -/** Recursively interpolate `{{field}}` placeholders through an args template using `values`. */ +// A value that is exactly one placeholder — `{{name}}` and nothing else. The `{{{{x}}}}` escape +// (four braces) can't match (its inner `{` isn't a name char), so an escaped literal never triggers +// typed resolution; only a genuine lone reference does. +const LONE_PLACEHOLDER_RE = /^\{\{([a-zA-Z0-9_]+)\}\}$/; + +/** + * If `text` is a lone `{{field}}` for a `bool`/`number`/`json` field, return its typed JSON value + * (boxed, since the value itself may legitimately be `false`/`0`/`null`). Returns `null` to defer to + * the normal string path — for every other type, for composed strings, and when a `number`/`json` + * value is empty or unparseable (don't coerce `""` → `0` or crash on bad JSON). + */ +function typedLoneValue( + text: string, + values: FieldValues, + fieldTypes: Map<string, ManifestFieldType>, +): { value: unknown } | null { + const match = LONE_PLACEHOLDER_RE.exec(text); + if (!match) { + return null; + } + const raw = values[match[1]]; + switch (fieldTypes.get(match[1])) { + case "bool": + return { value: typeof raw === "boolean" ? raw : raw === "true" }; + case "number": + if (typeof raw === "number") { + return { value: raw }; + } + if ( + typeof raw === "string" && + raw.trim() !== "" && + Number.isFinite(Number(raw)) + ) { + return { value: Number(raw) }; + } + return null; + case "json": + if (typeof raw === "string" && raw.trim() !== "") { + try { + return { value: JSON.parse(raw) }; + } catch { + return null; + } + } + return null; + default: + return null; + } +} + +/** + * Recursively interpolate `{{field}}` placeholders through an args template using `values`. With + * `fieldTypes`, a lone `{{field}}` for a `bool`/`number`/`json` field resolves to its typed JSON + * (see `typedLoneValue`); without it, every placeholder is stringified (used for the summary line). + */ export function interpolateArgs( template: unknown, values: FieldValues, + fieldTypes?: Map<string, ManifestFieldType>, ): unknown { if (typeof template === "string") { + if (fieldTypes) { + const typed = typedLoneValue(template, values, fieldTypes); + if (typed) { + return typed.value; + } + } return interpolateString(template, values); } if (Array.isArray(template)) { - return template.map((item) => interpolateArgs(item, values)); + return template.map((item) => + interpolateArgs(item, values, fieldTypes), + ); } if (template !== null && typeof template === "object") { const out: Record<string, unknown> = {}; for (const [key, item] of Object.entries(template)) { - out[key] = interpolateArgs(item, values); + out[key] = interpolateArgs(item, values, fieldTypes); } return out; } @@ -77,7 +148,10 @@ export function buildTemplateProposal( manifest: Manifest, values: FieldValues, ): TemplateProposal { - const args = interpolateArgs(manifest.args, values); + const fieldTypes = new Map( + manifest.fields.map((field) => [field.name, field.type]), + ); + const args = interpolateArgs(manifest.args, values, fieldTypes); const kind: FunctionCallKind = { FunctionCall: { receiver_id: manifest.binding.receiver_id, diff --git a/nt-fe/features/proposal-templates/components/manifest-form.tsx b/nt-fe/features/proposal-templates/components/manifest-form.tsx index bbe0149be..1e1a535ae 100644 --- a/nt-fe/features/proposal-templates/components/manifest-form.tsx +++ b/nt-fe/features/proposal-templates/components/manifest-form.tsx @@ -14,7 +14,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Loader2 } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { type ControllerRenderProps, useForm } from "react-hook-form"; import { Button } from "@/components/button"; import { InputBlock } from "@/components/input-block"; @@ -142,8 +142,27 @@ export function ManifestForm({ submitLabel, }: ManifestFormProps) { const t = useTranslations("customTemplates"); + const tv = useTranslations("customTemplates.validation"); + // Member-facing validation errors stay localized: resolve each message (with the field's label, + // and the bound for min/max) and hand them to the pure schema builder. + const schema = useMemo( + () => + buildFormSchema(manifest, { + required: (label) => tv("required", { label }), + account: (label) => tv("account", { label }), + wholeNumber: (label) => tv("wholeNumber", { label }), + number: (label) => tv("number", { label }), + select: (label) => tv("select", { label }), + json: (label) => tv("json", { label }), + invalid: (label) => tv("invalid", { label }), + pattern: (label) => tv("pattern", { label }), + min: (label, min) => tv("min", { label, min }), + max: (label, max) => tv("max", { label, max }), + }), + [manifest, tv], + ); const form = useForm<FieldValues>({ - resolver: zodResolver(buildFormSchema(manifest)), + resolver: zodResolver(schema), defaultValues: defaultValuesFor(manifest), mode: "onBlur", }); diff --git a/nt-fe/features/proposal-templates/components/template-editor.tsx b/nt-fe/features/proposal-templates/components/template-editor.tsx index 16a313064..33ae88cb9 100644 --- a/nt-fe/features/proposal-templates/components/template-editor.tsx +++ b/nt-fe/features/proposal-templates/components/template-editor.tsx @@ -221,7 +221,9 @@ export function TemplateEditor({ <TabsTrigger value="visual"> {t("editor.tabVisual")} </TabsTrigger> - <TabsTrigger value="code">{t("editor.tabCode")}</TabsTrigger> + <TabsTrigger value="code"> + {t("editor.tabCode")} + </TabsTrigger> </TabsList> </Tabs> diff --git a/nt-fe/features/proposal-templates/components/touched-gating.test.tsx b/nt-fe/features/proposal-templates/components/touched-gating.test.tsx index c4a180826..52d3a2dcc 100644 --- a/nt-fe/features/proposal-templates/components/touched-gating.test.tsx +++ b/nt-fe/features/proposal-templates/components/touched-gating.test.tsx @@ -5,14 +5,26 @@ * The interactive half (error appears *after* blur) belongs in the Playwright e2e. */ import { describe, expect, it } from "bun:test"; +import { NextIntlClientProvider } from "next-intl"; import { renderToStaticMarkup } from "react-dom/server"; +import messages from "@/messages/en.json"; import { emptyDraft } from "../draft"; import { LabeledInput } from "./fields-builder"; import { VisualBuilder } from "./visual-builder"; +// The authoring components localize via next-intl, so static rendering needs an intl context (mirrors +// the real (treasury) layout's provider). Real en messages keep the rendered labels honest. +function renderWithIntl(node: React.ReactNode): string { + return renderToStaticMarkup( + <NextIntlClientProvider locale="en" messages={messages}> + {node} + </NextIntlClientProvider>, + ); +} + describe("LabeledInput touched gating", () => { it("does not render its error before the input is touched", () => { - const html = renderToStaticMarkup( + const html = renderWithIntl( <LabeledInput label="Receiver" value="" @@ -30,7 +42,7 @@ describe("VisualBuilder section-error gating", () => { 'args: duplicate argument key "x" — only the last is kept'; function render(showSectionErrors: boolean): string { - return renderToStaticMarkup( + return renderWithIntl( <VisualBuilder draft={emptyDraft()} errors={[sectionError]} diff --git a/nt-fe/features/proposal-templates/form-schema.test.ts b/nt-fe/features/proposal-templates/form-schema.test.ts index 7414a775b..af84ec625 100644 --- a/nt-fe/features/proposal-templates/form-schema.test.ts +++ b/nt-fe/features/proposal-templates/form-schema.test.ts @@ -1,11 +1,29 @@ import { describe, expect, it } from "bun:test"; -import { buildFormSchema, defaultValuesFor } from "./form-schema"; +import { + buildFormSchema, + defaultValuesFor, + type ValidationMessages, +} from "./form-schema"; import { type Manifest, manifestErrorMessages, parseManifest, } from "./manifest"; +// These tests assert validation behaviour, not message text — a plain English stub is enough. +const messages: ValidationMessages = { + required: (l) => `${l} is required`, + account: (l) => `${l} must be a valid NEAR account`, + wholeNumber: (l) => `${l} must be a whole number`, + number: (l) => `${l} must be a number`, + select: (l) => `${l}: choose a listed option`, + json: (l) => `${l} must be valid JSON`, + invalid: (l) => `${l} is invalid`, + pattern: (l) => `${l} does not match the required pattern`, + min: (l, min) => `${l} must be at least ${min}`, + max: (l, max) => `${l} must be at most ${max}`, +}; + function manifest(raw: unknown): Manifest { const result = parseManifest(raw); if (!result.success) { @@ -32,7 +50,7 @@ function withFields(fields: unknown[]): Manifest { } function schemaFor(field: unknown) { - return buildFormSchema(withFields([field])); + return buildFormSchema(withFields([field]), messages); } describe("buildFormSchema", () => { diff --git a/nt-fe/features/proposal-templates/form-schema.ts b/nt-fe/features/proposal-templates/form-schema.ts index 2be0c8f77..e25ae4810 100644 --- a/nt-fe/features/proposal-templates/form-schema.ts +++ b/nt-fe/features/proposal-templates/form-schema.ts @@ -69,21 +69,42 @@ function passesType(field: ManifestField, value: string): boolean { } } -function typeMessage(field: ManifestField): string { +/** + * Member-facing validation messages, injected so they stay localized: next-intl strings live in the + * UI layer, while this module stays pure (no React, no next-intl). `label` is the field's authored + * label; `min`/`max` the configured bound. + */ +export interface ValidationMessages { + required: (label: string) => string; + account: (label: string) => string; + wholeNumber: (label: string) => string; + number: (label: string) => string; + select: (label: string) => string; + json: (label: string) => string; + invalid: (label: string) => string; + pattern: (label: string) => string; + min: (label: string, min: string) => string; + max: (label: string, max: string) => string; +} + +function typeMessage( + field: ManifestField, + messages: ValidationMessages, +): string { switch (field.type) { case "account": - return `${field.label} must be a valid NEAR account`; + return messages.account(field.label); case "uint": case "amount": - return `${field.label} must be a whole number`; + return messages.wholeNumber(field.label); case "number": - return `${field.label} must be a number`; + return messages.number(field.label); case "select": - return `${field.label}: choose a listed option`; + return messages.select(field.label); case "json": - return `${field.label} must be valid JSON`; + return messages.json(field.label); default: - return `${field.label} is invalid`; + return messages.invalid(field.label); } } @@ -97,7 +118,10 @@ function meetsBound(value: string, bound: string, atLeast: boolean): boolean { } } -function fieldValueSchema(field: ManifestField): z.ZodTypeAny { +function fieldValueSchema( + field: ManifestField, + messages: ValidationMessages, +): z.ZodTypeAny { if (field.type === "bool") { return z.boolean(); } @@ -106,12 +130,12 @@ function fieldValueSchema(field: ManifestField): z.ZodTypeAny { let schema: z.ZodTypeAny = z .string() .refine((value) => value !== "" || !required, { - message: `${field.label} is required`, + message: messages.required(field.label), }) .refine( allowEmpty((value) => passesType(field, value)), { - message: typeMessage(field), + message: typeMessage(field, messages), }, ); @@ -125,7 +149,7 @@ function fieldValueSchema(field: ManifestField): z.ZodTypeAny { value.length <= MAX_PATTERN_INPUT && pattern.test(value), ), { - message: `${field.label} does not match the required pattern`, + message: messages.pattern(field.label), }, ); } @@ -134,7 +158,7 @@ function fieldValueSchema(field: ManifestField): z.ZodTypeAny { if (min !== undefined) { schema = schema.refine( allowEmpty((value) => meetsBound(value, min, true)), - { message: `${field.label} must be at least ${min}` }, + { message: messages.min(field.label, min) }, ); } @@ -142,7 +166,7 @@ function fieldValueSchema(field: ManifestField): z.ZodTypeAny { if (max !== undefined) { schema = schema.refine( allowEmpty((value) => meetsBound(value, max, false)), - { message: `${field.label} must be at most ${max}` }, + { message: messages.max(field.label, max) }, ); } @@ -150,10 +174,13 @@ function fieldValueSchema(field: ManifestField): z.ZodTypeAny { } /** Build the react-hook-form zod schema that validates a manifest's filled values. */ -export function buildFormSchema(manifest: Manifest) { +export function buildFormSchema( + manifest: Manifest, + messages: ValidationMessages, +) { const shape: Record<string, z.ZodTypeAny> = {}; for (const field of manifest.fields) { - shape[field.name] = fieldValueSchema(field); + shape[field.name] = fieldValueSchema(field, messages); } return z.object(shape); } diff --git a/nt-fe/features/proposal-templates/manifest.ts b/nt-fe/features/proposal-templates/manifest.ts index d7794955c..017ffa210 100644 --- a/nt-fe/features/proposal-templates/manifest.ts +++ b/nt-fe/features/proposal-templates/manifest.ts @@ -252,22 +252,21 @@ export const manifestBindingSchema = z.object({ export type ManifestBinding = z.infer<typeof manifestBindingSchema>; /** - * The shape of one `{{name}}` placeholder, kept as a source string so extraction and substitution - * build their regexes from a single definition (a charset tweak can't leave one behind). The - * look-arounds skip escaped `{{{{literal}}}}` sequences so a literal `{{` is never mistaken for a - * placeholder; capture group 1 is the field name. + * One matcher for both extraction and substitution, kept as a single source so a charset tweak can't + * leave one behind. It deliberately avoids a RegExp lookbehind: some runtimes (notably Safari < 16.4) + * throw a SyntaxError on lookbehind at module-load, which would break every flow that imports this + * module — not just template authoring. Instead the escaped `{{{{literal}}}}` block is matched first + * and as a whole, so its inner `{{...}}` is consumed and never seen as a placeholder. Group 1 is the + * escaped inner (rendered back as a literal `{{...}}`); group 2 is a real placeholder's field name. */ -const PLACEHOLDER_PATTERN = - "(?<!\\{)\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}(?!\\})"; +const PLACEHOLDER_SOURCE = + "\\{\\{\\{\\{([\\s\\S]*?)\\}\\}\\}\\}|\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}"; -/** Extraction: matches every placeholder (group 1 = name). */ -const PLACEHOLDER_RE = new RegExp(PLACEHOLDER_PATTERN, "g"); +/** Extraction: group 2 = name; an escaped block matches group 1 and carries no name (skipped). */ +const PLACEHOLDER_RE = new RegExp(PLACEHOLDER_SOURCE, "g"); -/** Substitution: an escaped `{{{{...}}}}` (group 1) OR a placeholder (group 2 = name). */ -const SUBSTITUTE_RE = new RegExp( - `\\{\\{\\{\\{([\\s\\S]*?)\\}\\}\\}\\}|${PLACEHOLDER_PATTERN}`, - "g", -); +/** Substitution: group 1 = escaped inner, group 2 = placeholder name. */ +const SUBSTITUTE_RE = new RegExp(PLACEHOLDER_SOURCE, "g"); function collectStrings(value: unknown, out: string[]): void { if (typeof value === "string") { @@ -294,7 +293,7 @@ export function manifestPlaceholders(value: unknown): Set<string> { const names = new Set<string>(); for (const text of strings) { for (const match of text.matchAll(PLACEHOLDER_RE)) { - const name = match[1]; + const name = match[2]; if (name) { names.add(name); } diff --git a/nt-fe/messages/de.json b/nt-fe/messages/de.json index f96d2da1b..c8735f0a8 100644 --- a/nt-fe/messages/de.json +++ b/nt-fe/messages/de.json @@ -2322,6 +2322,18 @@ "fileProposal": "Antrag einreichen" }, "collapsePinned": "Angeheftete Vorlagen einklappen", - "expandPinned": "Angeheftete Vorlagen ausklappen" + "expandPinned": "Angeheftete Vorlagen ausklappen", + "validation": { + "required": "{label} ist erforderlich", + "account": "{label} muss ein gültiges NEAR-Konto sein", + "wholeNumber": "{label} muss eine ganze Zahl sein", + "number": "{label} muss eine Zahl sein", + "select": "{label}: Bitte eine aufgeführte Option auswählen", + "json": "{label} muss gültiges JSON sein", + "invalid": "{label} ist ungültig", + "pattern": "{label} entspricht nicht dem erforderlichen Muster", + "min": "{label} muss mindestens {min} sein", + "max": "{label} darf höchstens {max} sein" + } } } diff --git a/nt-fe/messages/en.json b/nt-fe/messages/en.json index 3ead0d14e..6f9d33bcd 100644 --- a/nt-fe/messages/en.json +++ b/nt-fe/messages/en.json @@ -2322,6 +2322,18 @@ "fileProposal": "File Proposal" }, "collapsePinned": "Collapse pinned templates", - "expandPinned": "Expand pinned templates" + "expandPinned": "Expand pinned templates", + "validation": { + "required": "{label} is required", + "account": "{label} must be a valid NEAR account", + "wholeNumber": "{label} must be a whole number", + "number": "{label} must be a number", + "select": "{label}: choose a listed option", + "json": "{label} must be valid JSON", + "invalid": "{label} is invalid", + "pattern": "{label} does not match the required pattern", + "min": "{label} must be at least {min}", + "max": "{label} must be at most {max}" + } } } diff --git a/nt-fe/messages/es.json b/nt-fe/messages/es.json index e8edb91d0..63917c502 100644 --- a/nt-fe/messages/es.json +++ b/nt-fe/messages/es.json @@ -2322,6 +2322,18 @@ "fileProposal": "Presentar propuesta" }, "collapsePinned": "Contraer plantillas fijadas", - "expandPinned": "Expandir plantillas fijadas" + "expandPinned": "Expandir plantillas fijadas", + "validation": { + "required": "{label} es obligatorio", + "account": "{label} debe ser una cuenta de NEAR válida", + "wholeNumber": "{label} debe ser un número entero", + "number": "{label} debe ser un número", + "select": "{label}: elige una opción de la lista", + "json": "{label} debe ser un JSON válido", + "invalid": "{label} no es válido", + "pattern": "{label} no coincide con el patrón requerido", + "min": "{label} debe ser como mínimo {min}", + "max": "{label} debe ser como máximo {max}" + } } } diff --git a/nt-fe/messages/fr.json b/nt-fe/messages/fr.json index d5b233487..eab7ef230 100644 --- a/nt-fe/messages/fr.json +++ b/nt-fe/messages/fr.json @@ -2322,6 +2322,18 @@ "fileProposal": "Déposer la proposition" }, "collapsePinned": "Réduire les modèles épinglés", - "expandPinned": "Développer les modèles épinglés" + "expandPinned": "Développer les modèles épinglés", + "validation": { + "required": "{label} est obligatoire", + "account": "{label} doit être un compte NEAR valide", + "wholeNumber": "{label} doit être un nombre entier", + "number": "{label} doit être un nombre", + "select": "{label} : choisissez une option proposée", + "json": "{label} doit être un JSON valide", + "invalid": "{label} n''est pas valide", + "pattern": "{label} ne correspond pas au format requis", + "min": "{label} doit être au minimum {min}", + "max": "{label} doit être au maximum {max}" + } } } diff --git a/nt-fe/messages/he.json b/nt-fe/messages/he.json index 015099c83..329370f9a 100644 --- a/nt-fe/messages/he.json +++ b/nt-fe/messages/he.json @@ -2322,6 +2322,18 @@ "fileProposal": "הגש הצעה" }, "collapsePinned": "כווץ תבניות מוצמדות", - "expandPinned": "הרחב תבניות מוצמדות" + "expandPinned": "הרחב תבניות מוצמדות", + "validation": { + "required": "חובה למלא את השדה {label}", + "account": "{label} חייב להיות חשבון NEAR תקין", + "wholeNumber": "{label} חייב להיות מספר שלם", + "number": "{label} חייב להיות מספר", + "select": "{label}: יש לבחור אחת מהאפשרויות ברשימה", + "json": "{label} חייב להיות JSON תקין", + "invalid": "{label} אינו תקין", + "pattern": "{label} אינו תואם לתבנית הנדרשת", + "min": "{label} חייב להיות לפחות {min}", + "max": "{label} חייב להיות לכל היותר {max}" + } } } diff --git a/nt-fe/messages/id.json b/nt-fe/messages/id.json index f90331495..c38a9fd6e 100644 --- a/nt-fe/messages/id.json +++ b/nt-fe/messages/id.json @@ -2322,6 +2322,18 @@ "fileProposal": "Ajukan Proposal" }, "collapsePinned": "Ciutkan templat yang disematkan", - "expandPinned": "Bentangkan templat yang disematkan" + "expandPinned": "Bentangkan templat yang disematkan", + "validation": { + "required": "{label} wajib diisi", + "account": "{label} harus berupa akun NEAR yang valid", + "wholeNumber": "{label} harus berupa bilangan bulat", + "number": "{label} harus berupa angka", + "select": "{label}: pilih salah satu opsi yang tersedia", + "json": "{label} harus berupa JSON yang valid", + "invalid": "{label} tidak valid", + "pattern": "{label} tidak sesuai dengan pola yang diperlukan", + "min": "{label} minimal harus {min}", + "max": "{label} maksimal harus {max}" + } } } diff --git a/nt-fe/messages/ja.json b/nt-fe/messages/ja.json index 22c5cd3a2..86013be5c 100644 --- a/nt-fe/messages/ja.json +++ b/nt-fe/messages/ja.json @@ -2322,6 +2322,18 @@ "fileProposal": "提案を提出" }, "collapsePinned": "固定したテンプレートを折りたたむ", - "expandPinned": "固定したテンプレートを展開する" + "expandPinned": "固定したテンプレートを展開する", + "validation": { + "required": "{label}は必須です", + "account": "{label}は有効なNEARアカウントである必要があります", + "wholeNumber": "{label}は整数である必要があります", + "number": "{label}は数値である必要があります", + "select": "{label}:一覧から選択してください", + "json": "{label}は有効なJSONである必要があります", + "invalid": "{label}が無効です", + "pattern": "{label}が必要なパターンに一致しません", + "min": "{label}は{min}以上である必要があります", + "max": "{label}は{max}以下である必要があります" + } } } diff --git a/nt-fe/messages/ko.json b/nt-fe/messages/ko.json index 9a82555a7..7a90b5c4c 100644 --- a/nt-fe/messages/ko.json +++ b/nt-fe/messages/ko.json @@ -2322,6 +2322,18 @@ "fileProposal": "제안 제출" }, "collapsePinned": "고정한 템플릿 접기", - "expandPinned": "고정한 템플릿 펼치기" + "expandPinned": "고정한 템플릿 펼치기", + "validation": { + "required": "{label}은(는) 필수 항목입니다", + "account": "{label}은(는) 유효한 NEAR 계정이어야 합니다", + "wholeNumber": "{label}은(는) 정수여야 합니다", + "number": "{label}은(는) 숫자여야 합니다", + "select": "{label}: 목록에 있는 옵션을 선택하세요", + "json": "{label}은(는) 유효한 JSON이어야 합니다", + "invalid": "{label}이(가) 올바르지 않습니다", + "pattern": "{label}이(가) 요구되는 형식과 일치하지 않습니다", + "min": "{label}은(는) 최소 {min} 이상이어야 합니다", + "max": "{label}은(는) 최대 {max} 이하여야 합니다" + } } } diff --git a/nt-fe/messages/pt.json b/nt-fe/messages/pt.json index 39a97f210..e681d5a71 100644 --- a/nt-fe/messages/pt.json +++ b/nt-fe/messages/pt.json @@ -2322,6 +2322,18 @@ "fileProposal": "Registrar proposta" }, "collapsePinned": "Recolher modelos fixados", - "expandPinned": "Expandir modelos fixados" + "expandPinned": "Expandir modelos fixados", + "validation": { + "required": "{label} é obrigatório", + "account": "{label} deve ser uma conta NEAR válida", + "wholeNumber": "{label} deve ser um número inteiro", + "number": "{label} deve ser um número", + "select": "{label}: escolha uma opção da lista", + "json": "{label} deve ser um JSON válido", + "invalid": "{label} é inválido", + "pattern": "{label} não corresponde ao padrão exigido", + "min": "{label} deve ser no mínimo {min}", + "max": "{label} deve ser no máximo {max}" + } } } diff --git a/nt-fe/messages/tr.json b/nt-fe/messages/tr.json index 5a3116552..1b067cafc 100644 --- a/nt-fe/messages/tr.json +++ b/nt-fe/messages/tr.json @@ -2322,6 +2322,18 @@ "fileProposal": "Teklif Oluştur" }, "collapsePinned": "Sabitlenmiş şablonları daralt", - "expandPinned": "Sabitlenmiş şablonları genişlet" + "expandPinned": "Sabitlenmiş şablonları genişlet", + "validation": { + "required": "{label} alanı zorunludur", + "account": "{label} geçerli bir NEAR hesabı olmalıdır", + "wholeNumber": "{label} bir tam sayı olmalıdır", + "number": "{label} bir sayı olmalıdır", + "select": "{label} için listelenen seçeneklerden birini seçin", + "json": "{label} geçerli bir JSON olmalıdır", + "invalid": "{label} geçersiz", + "pattern": "{label} gerekli desenle eşleşmiyor", + "min": "{label} en az {min} olmalıdır", + "max": "{label} en fazla {max} olmalıdır" + } } } diff --git a/nt-fe/messages/uk.json b/nt-fe/messages/uk.json index 298d43035..d6fb1ed4d 100644 --- a/nt-fe/messages/uk.json +++ b/nt-fe/messages/uk.json @@ -2322,6 +2322,18 @@ "fileProposal": "Подати пропозицію" }, "collapsePinned": "Згорнути закріплені шаблони", - "expandPinned": "Розгорнути закріплені шаблони" + "expandPinned": "Розгорнути закріплені шаблони", + "validation": { + "required": "Поле {label} обовʼязкове", + "account": "{label} має бути дійсним акаунтом NEAR", + "wholeNumber": "{label} має бути цілим числом", + "number": "{label} має бути числом", + "select": "{label}: оберіть один зі списку", + "json": "{label} має бути коректним JSON", + "invalid": "{label} має некоректне значення", + "pattern": "{label} не відповідає потрібному формату", + "min": "{label} має бути не менше {min}", + "max": "{label} має бути не більше {max}" + } } } diff --git a/nt-fe/messages/vi.json b/nt-fe/messages/vi.json index 6fea20967..ff4b8e893 100644 --- a/nt-fe/messages/vi.json +++ b/nt-fe/messages/vi.json @@ -2322,6 +2322,18 @@ "fileProposal": "Nộp đề xuất" }, "collapsePinned": "Thu gọn mẫu đã ghim", - "expandPinned": "Mở rộng mẫu đã ghim" + "expandPinned": "Mở rộng mẫu đã ghim", + "validation": { + "required": "{label} là bắt buộc", + "account": "{label} phải là một tài khoản NEAR hợp lệ", + "wholeNumber": "{label} phải là số nguyên", + "number": "{label} phải là một số", + "select": "{label}: hãy chọn một tùy chọn trong danh sách", + "json": "{label} phải là JSON hợp lệ", + "invalid": "{label} không hợp lệ", + "pattern": "{label} không khớp với định dạng yêu cầu", + "min": "{label} phải lớn hơn hoặc bằng {min}", + "max": "{label} phải nhỏ hơn hoặc bằng {max}" + } } } diff --git a/nt-fe/messages/zh.json b/nt-fe/messages/zh.json index 2aaf12146..3f2e1cb45 100644 --- a/nt-fe/messages/zh.json +++ b/nt-fe/messages/zh.json @@ -2322,6 +2322,18 @@ "fileProposal": "提交提案" }, "collapsePinned": "折叠已固定的模板", - "expandPinned": "展开已固定的模板" + "expandPinned": "展开已固定的模板", + "validation": { + "required": "{label}为必填项", + "account": "{label}必须是有效的 NEAR 账户", + "wholeNumber": "{label}必须是整数", + "number": "{label}必须是数字", + "select": "{label}:请选择列表中的选项", + "json": "{label}必须是有效的 JSON", + "invalid": "{label}无效", + "pattern": "{label}与要求的格式不匹配", + "min": "{label}不得小于 {min}", + "max": "{label}不得大于 {max}" + } } }