From 0d8be85b61c24a22fdc44f278d70540335064ddf Mon Sep 17 00:00:00 2001 From: Chris Tate <366502+ctate@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:57:02 -0500 Subject: [PATCH] Preserve dotted literals in form value lookups Fix `findFormValue` so dotted strings supplied as direct or dotted-key parameters remain literal instead of being discarded as path references. Preserve the existing lookup order, clarify flat-key and slash-path state lookup behavior, and add coverage for dotted emails, URLs, versions, and `$state`-resolved action parameters. Factory-Run: 4f2d892359c9c4abf2a5bad40b415802 Co-authored-by: kevin <5299031+kevin9327@users.noreply.github.com> --- apps/web/app/(main)/docs/api/core/page.mdx | 11 ++- packages/core/README.md | 2 + packages/core/src/actions.test.ts | 16 ++++ packages/core/src/types.test.ts | 104 +++++++++++++++++++++ packages/core/src/types.ts | 24 ++--- skills/core/SKILL.md | 4 + 6 files changed, 143 insertions(+), 18 deletions(-) diff --git a/apps/web/app/(main)/docs/api/core/page.mdx b/apps/web/app/(main)/docs/api/core/page.mdx index 7412aedf..da0f510e 100644 --- a/apps/web/app/(main)/docs/api/core/page.mdx +++ b/apps/web/app/(main)/docs/api/core/page.mdx @@ -551,14 +551,19 @@ const name2 = resolveDynamicValue({ $state: "/user/name" }, state); // "Alice" ### findFormValue +Read a value from resolved action parameters or state. A parameter value is literal, including strings with dots such as emails, URLs, and versions. Lookup order: a defined direct parameter, a parameter key ending in `.`, a matching flat state key, then a slash-delimited path in nested state. + ```typescript import { findFormValue } from '@json-render/core'; -// Find form values regardless of path format -// Checks: params.name, params["form.name"], state["form.name"], state.form.name -const value = findFormValue("name", params, state); +findFormValue("email", { email: "john.doe@example.com" }, {}); +findFormValue("email", { "form.email": "john.doe@example.com" }, {}); +findFormValue("email", {}, { "form.email": "john.doe@example.com" }); +findFormValue("/form/email", {}, { form: { email: "john.doe@example.com" } }); ``` +For action bindings, use `{ $state: "/form/email" }` to read nested state: the action resolver passes the resulting value to the handler. A raw string like `"form.email"` in parameters is not a state reference. A bare `"email"` field name does not search `state.form.email`. + ## buildUserPrompt Build structured user prompts for AI generation, with support for refinement and state context. diff --git a/packages/core/README.md b/packages/core/README.md index 0f808627..977fb331 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -291,6 +291,8 @@ Schema options: | `ActionBinding` | Action binding with `action`, `params`, `confirm`, `preventDefault`, etc. | | `BuiltInAction` | Built-in action definition with `name` and `description` | +Use `findFormValue("email", params, state)` in action handlers to look up a defined direct param, a dotted param key (such as `"form.email"`), a matching flat state key, or a slash path (such as `"/form/email"`) in nested state. Parameter values like `"john.doe@example.com"` are literal, even if they contain dots. To bind an action parameter to nested state, use `{ $state: "/form/email" }`; the action resolver supplies its value before the handler runs. A bare `"email"` field name does not recursively search nested state. + ### Inline Mode (Mixed Streams) | Export | Purpose | diff --git a/packages/core/src/actions.test.ts b/packages/core/src/actions.test.ts index 4e6fd09f..87aa4bbf 100644 --- a/packages/core/src/actions.test.ts +++ b/packages/core/src/actions.test.ts @@ -7,6 +7,7 @@ import { ActionOnSuccessSchema, ActionOnErrorSchema, } from "./actions"; +import { findFormValue } from "./types"; describe("onSuccess/onError schemas", () => { it("keeps params on the onSuccess action form", () => { @@ -86,6 +87,21 @@ describe("resolveAction", () => { expect(resolved.params.theme).toBe("dark"); }); + it("passes a resolved dotted email to a form action handler", () => { + const state = { form: { email: "john.doe@example.com" } }; + const resolved = resolveAction( + { + action: "createCustomer", + params: { email: { $state: "/form/email" } }, + }, + state, + ); + + expect(findFormValue("email", resolved.params, state)).toBe( + "john.doe@example.com", + ); + }); + it("interpolates confirmation messages", () => { const data = { user: { name: "Alice" } }; const resolved = resolveAction( diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index 31701a95..02da48f4 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { resolveDynamicValue, getByPath, + findFormValue, resolveRepeatStatePath, resolveRepeatItemStatePath, setByPath, @@ -18,6 +19,109 @@ import { } from "./types"; import type { Spec, SpecStreamLine, StreamChunk } from "./types"; +describe("findFormValue", () => { + const dottedValues = [ + ["email", "john.doe@example.com"], + ["url", "https://example.com"], + ["version", "1.2.3"], + ]; + + it.each(dottedValues)("keeps a direct %s literal", (field, value) => { + expect(findFormValue(field, { [field]: value }, { [field]: "state" })).toBe( + value, + ); + }); + + it.each(dottedValues)("keeps a dotted-key %s literal", (field, value) => { + expect( + findFormValue( + field, + { [`form.${field}`]: value }, + { [`form.${field}`]: "state" }, + ), + ).toBe(value); + }); + + it("treats a raw dotted parameter value as literal, even with matching state", () => { + expect(findFormValue("email", { email: "form.email" })).toBe("form.email"); + expect( + findFormValue( + "email", + { email: "form.email" }, + { "form.email": "state email" }, + ), + ).toBe("form.email"); + }); + + it("prefers a direct parameter over dotted parameters and state", () => { + expect( + findFormValue( + "email", + { email: "direct", "form.email": "dotted" }, + { email: "state", "form.email": "dotted state" }, + ), + ).toBe("direct"); + }); + + it("prefers a dotted parameter over state and skips undefined direct params", () => { + expect( + findFormValue( + "email", + { email: undefined, "form.email": "dotted" }, + { "form.email": "state" }, + ), + ).toBe("dotted"); + }); + + it("keeps the first matching dotted parameter key, even if undefined", () => { + expect( + findFormValue( + "email", + { "form.email": undefined, "other.email": "later" }, + { email: "state" }, + ), + ).toBeUndefined(); + }); + + it("finds exact and dotted flat state keys", () => { + expect(findFormValue("email", undefined, { email: "exact" })).toBe("exact"); + expect( + findFormValue("email", undefined, { "form.email": "dotted state" }), + ).toBe("dotted state"); + expect( + findFormValue("email", undefined, { + email: "first", + "form.email": "second", + }), + ).toBe("first"); + }); + + it("finds nested state with slash paths but not bare field names", () => { + const state = { form: { email: "nested@example.com" } }; + + expect(findFormValue("/form/email", undefined, state)).toBe( + "nested@example.com", + ); + expect(findFormValue("form/email", undefined, state)).toBe( + "nested@example.com", + ); + expect(findFormValue("email", undefined, state)).toBeUndefined(); + }); + + it("ignores nonmatching keys and returns undefined for omitted inputs", () => { + expect( + findFormValue("email", { emailAddress: "other" }, {}), + ).toBeUndefined(); + expect(findFormValue("email")).toBeUndefined(); + }); + + it.each(["", 0, false, null])("preserves a direct %s value", (value) => { + expect(findFormValue("email", { email: value }, { email: "state" })).toBe( + value, + ); + }); +}); + describe("getByPath", () => { it("gets nested values with JSON pointer paths", () => { const data = { user: { name: "John", scores: [10, 20, 30] } }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c051449a..16884bd4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -517,41 +517,35 @@ function deepEqual(a: unknown, b: unknown): boolean { /** * Find a form value from params and/or state. * Useful in action handlers to locate form input values regardless of path format. + * Params are literal values (including dynamic bindings resolved before the + * handler runs); a dot in a parameter value does not make it a state path. * * Checks in order: - * 1. Direct param key (if not a path reference) + * 1. Defined direct param key * 2. Param keys ending with the field name - * 3. State keys ending with the field name (dot notation) - * 4. State path using getByPath (slash notation) + * 3. Matching flat state keys (direct or dot notation) + * 4. Nested state path using getByPath (slash notation) * * @example * // Find "name" from params or state * const name = findFormValue("name", params, state); * - * // Will find from: params.name, params["form.name"], state["form.name"], or getByPath(state, "name") + * // Will find from: params.name, params["form.name"], or state["form.name"] + * // Use "/form/name" for nested state, or { $state: "/form/name" } in an action binding. */ export function findFormValue( fieldName: string, params?: Record, state?: Record, ): unknown { - // Check params first (but not if it looks like a state path reference) if (params?.[fieldName] !== undefined) { - const val = params[fieldName]; - // If the value looks like a path reference (contains dots), skip it - if (typeof val !== "string" || !val.includes(".")) { - return val; - } + return params[fieldName]; } - // Check param keys that end with the field name if (params) { for (const key of Object.keys(params)) { if (key.endsWith(`.${fieldName}`)) { - const val = params[key]; - if (typeof val !== "string" || !val.includes(".")) { - return val; - } + return params[key]; } } } diff --git a/skills/core/SKILL.md b/skills/core/SKILL.md index 2b29e72b..8838e3ae 100644 --- a/skills/core/SKILL.md +++ b/skills/core/SKILL.md @@ -96,6 +96,10 @@ const { result, newPatches } = compiler.push(chunk); const finalSpec = compiler.getResult(); ``` +## Form Values in Action Handlers + +Use `findFormValue("email", params, state)` to read a direct parameter, a dotted parameter key (such as `"form.email"`), a matching flat state key, or a slash-delimited path (such as `"/form/email"`) in nested state. Parameter values are literal, so emails and URLs containing dots are preserved. For action bindings that read nested state, use `{ $state: "/form/email" }`; the resolver passes that value to the handler. A bare `"email"` field name does not search nested `state.form.email`. + ## Dynamic Prop Expressions Any prop value can be a dynamic expression resolved at render time: