Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/web/app/(main)/docs/api/core/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
104 changes: 104 additions & 0 deletions packages/core/src/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
resolveDynamicValue,
getByPath,
findFormValue,
resolveRepeatStatePath,
resolveRepeatItemStatePath,
setByPath,
Expand All @@ -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] } };
Expand Down
24 changes: 9 additions & 15 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions skills/core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading