Preserve dotted literals in form value lookups (#353)

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: Chris Tate <366502+ctate@users.noreply.github.com>
Co-authored-by: kevin <5299031+kevin9327@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-09-23 11:39:49 -05:00
committed by GitHub
co-authored by Chris Tate kevin
parent 3ad3818811
commit 3709614de0
6 changed files with 143 additions and 18 deletions
+8 -3
View File
@@ -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 `.<fieldName>`, 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.
+2
View File
@@ -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 |
+16
View File
@@ -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(
+104
View File
@@ -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] } };
+9 -15
View File
@@ -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<string, unknown>,
state?: Record<string, unknown>,
): 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];
}
}
}
+4
View File
@@ -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: