mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 03:54:41 +08:00
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:
co-authored by
Chris Tate
kevin
parent
3ad3818811
commit
3709614de0
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] } };
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user