diff --git a/apps/web/app/(main)/docs/computed-values/page.mdx b/apps/web/app/(main)/docs/computed-values/page.mdx new file mode 100644 index 0000000..a2fcc87 --- /dev/null +++ b/apps/web/app/(main)/docs/computed-values/page.mdx @@ -0,0 +1,119 @@ +import { pageMetadata } from "@/lib/page-metadata" +export const metadata = pageMetadata("docs/computed-values") + +# Computed Values + +Derive dynamic prop values using registered functions or string templates. + +## `$template` — String Interpolation + +Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer: + +```json +{ + "type": "Text", + "props": { + "text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." } + }, + "children": [] +} +``` + +If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages." + +Missing paths resolve to an empty string. + +## `$computed` — Registered Functions + +Use `{ "$computed": "", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.): + +```json +{ + "type": "Text", + "props": { + "text": { + "$computed": "fullName", + "args": { + "first": { "$state": "/form/firstName" }, + "last": { "$state": "/form/lastName" } + } + } + }, + "children": [] +} +``` + +### Registering Functions + +Functions are registered in the catalog and provided at runtime. + +**Catalog definition (for AI prompt generation):** + +```typescript +import { defineCatalog } from '@json-render/core'; +import { schema } from '@json-render/react/schema'; + +const catalog = defineCatalog(schema, { + components: { /* ... */ }, + functions: { + fullName: { + description: 'Combines first and last name into a full name', + }, + formatCurrency: { + description: 'Formats a number as currency', + }, + }, +}); +``` + +**Runtime implementation:** + +```tsx +import { JSONUIProvider } from '@json-render/react'; + +const functions = { + fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(), + formatCurrency: (args) => { + const value = Number(args.value ?? 0); + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: (args.currency as string) ?? 'USD', + }).format(value); + }, +}; + + + + +``` + +### Using with `createRenderer` + +```tsx +const MyRenderer = createRenderer(catalog, components); + + +``` + +## Combining Expressions + +`$computed` args can use any expression type. This example computes a total from repeat item fields: + +```json +{ + "$computed": "lineTotal", + "args": { + "price": { "$item": "price" }, + "quantity": { "$item": "quantity" } + } +} +``` + +## Next + +- [Watchers](/docs/watchers) — react to state changes with cascading actions +- [Data Binding](/docs/data-binding) — all expression types +- [Validation](/docs/validation) — validate form inputs diff --git a/apps/web/app/(main)/docs/data-binding/page.mdx b/apps/web/app/(main)/docs/data-binding/page.mdx index 9a20ba1..01b7a35 100644 --- a/apps/web/app/(main)/docs/data-binding/page.mdx +++ b/apps/web/app/(main)/docs/data-binding/page.mdx @@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition: The condition uses the same [visibility](/docs/visibility) expression format. +## Template Strings + +Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax: + +```json +{ + "type": "Text", + "props": { + "text": { "$template": "Welcome back, ${/user/name}!" } + }, + "children": [] +} +``` + +See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions. + ## Quick Reference
@@ -255,6 +271,16 @@ The condition uses the same [visibility](/docs/visibility) expression format. {'{ "$bindItem": "field" }'} Form components inside repeat + + {"$template"} + {'{ "$template": "Hello, ${/name}!" }'} + Anywhere (string props) + + + {"$computed"} + {'{ "$computed": "fn", "args": { ... } }'} + Anywhere (requires registered function) +
diff --git a/apps/web/app/(main)/docs/validation/page.mdx b/apps/web/app/(main)/docs/validation/page.mdx index 5c7ede0..673a2e3 100644 --- a/apps/web/app/(main)/docs/validation/page.mdx +++ b/apps/web/app/(main)/docs/validation/page.mdx @@ -11,11 +11,18 @@ json-render includes common validation functions: - `required` — Value must be non-empty - `email` — Valid email format -- `minLength` — Minimum string length -- `maxLength` — Maximum string length -- `pattern` — Match a regex pattern -- `min` — Minimum numeric value -- `max` — Maximum numeric value +- `minLength` — Minimum string length (args: `{ "min": N }`) +- `maxLength` — Maximum string length (args: `{ "max": N }`) +- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`) +- `min` — Minimum numeric value (args: `{ "min": N }`) +- `max` — Maximum numeric value (args: `{ "max": N }`) +- `numeric` — Value must be a number +- `url` — Valid URL format +- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`) +- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`) +- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`) +- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`) +- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`) ## Using Validation in JSON @@ -143,14 +150,114 @@ function TextField({ props, bindings }) { See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation. +## Cross-Field Validation + +Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password": + +```json +{ + "type": "Input", + "props": { + "label": "Confirm Password", + "value": { "$bindState": "/form/confirmPassword" }, + "checks": [ + { "type": "required", "message": "Please confirm your password" }, + { + "type": "matches", + "args": { "other": { "$state": "/form/password" } }, + "message": "Passwords must match" + } + ] + } +} +``` + +Other cross-field examples: + +```json +{ + "checks": [ + { + "type": "greaterThan", + "args": { "other": { "$state": "/form/startDate" } }, + "message": "End date must be after start date" + } + ] +} +``` + +```json +{ + "checks": [ + { + "type": "requiredIf", + "args": { "field": { "$state": "/form/enableNotifications" } }, + "message": "Email is required when notifications are enabled" + } + ] +} +``` + +## Conditional Validation + +Use the `enabled` field in the validation config to only run checks when a condition is met: + +```json +{ + "type": "Input", + "props": { + "label": "Company Name", + "value": { "$bindState": "/form/company" }, + "checks": [ + { "type": "required", "message": "Company name is required" } + ] + } +} +``` + +In the component implementation, you can pass `enabled` to `useFieldValidation`: + +```typescript +useFieldValidation(bindings?.value ?? "", { + checks: props.checks ?? [], + enabled: { "$state": "/form/accountType", eq: "business" }, +}); +``` + +This only validates the company name when the account type is "business". + ## Validation Timing Control when validation runs with `validateOn`: - `change` — Validate on every input change -- `blur` — Validate when field loses focus +- `blur` — Validate when field loses focus (default for Input, Textarea) - `submit` — Validate only on form submission +## Form-Level Validation + +Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding: + +```json +{ + "type": "Button", + "props": { "label": "Submit" }, + "on": { + "press": [ + { "action": "validateForm", "params": { "statePath": "/formResult" } }, + { "action": "submitForm" } + ] + }, + "children": [] +} +``` + +The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed. + +> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid. + ## Next -Learn about [generation modes](/docs/generation-modes). +- [Computed Values](/docs/computed-values) — derive dynamic prop values +- [Watchers](/docs/watchers) — react to state changes +- [Generation Modes](/docs/generation-modes) — how AI generates specs diff --git a/apps/web/app/(main)/docs/watchers/page.mdx b/apps/web/app/(main)/docs/watchers/page.mdx new file mode 100644 index 0000000..7e14362 --- /dev/null +++ b/apps/web/app/(main)/docs/watchers/page.mdx @@ -0,0 +1,150 @@ +import { pageMetadata } from "@/lib/page-metadata" +export const metadata = pageMetadata("docs/watchers") + +# Watchers + +React to state changes by triggering actions when watched paths update. + +## The `watch` Field + +Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically. + +`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`. + +```json +{ + "type": "Select", + "props": { + "label": "Country", + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada", "UK"] + }, + "watch": { + "/form/country": { + "action": "loadCities", + "params": { "country": { "$state": "/form/country" } } + } + }, + "children": [] +} +``` + +When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options. + +## Cascading Selects + +A common pattern is cascading dropdowns where selecting a value in one field loads options for another: + +```json +{ + "root": "form", + "elements": { + "form": { + "type": "Stack", + "props": { "direction": "vertical", "gap": "md" }, + "children": ["country-select", "city-select"] + }, + "country-select": { + "type": "Select", + "props": { + "label": "Country", + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada", "UK"] + }, + "watch": { + "/form/country": [ + { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }, + { "action": "setState", "params": { "statePath": "/form/city", "value": "" } } + ] + }, + "children": [] + }, + "city-select": { + "type": "Select", + "props": { + "label": "City", + "value": { "$bindState": "/form/city" }, + "options": { "$state": "/availableCities" }, + "placeholder": "Select a city" + }, + "children": [] + } + }, + "state": { + "form": { "country": "", "city": "" }, + "availableCities": [] + } +} +``` + +The watcher on `country-select` fires two actions when the country changes: +1. `loadCities` — fetches and writes city options to `/availableCities` +2. `setState` — resets the city selection + +The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded. + +### Action Handler + +```typescript +const handlers = { + loadCities: async (params) => { + const cities = await fetchCities(params.country); + // setState is called by the runtime to write the result + return cities; + }, +}; +``` + +Or with `defineRegistry`: + +```typescript +const { registry, handlers } = defineRegistry(catalog, { + components: { /* ... */ }, + actions: { + loadCities: async (params, setState) => { + const response = await fetch(`/api/cities?country=${params.country}`); + const cities = await response.json(); + setState('/availableCities', cities); + }, + }, +}); +``` + +## Multiple Watchers + +An element can watch multiple state paths. Each path maps to one or more action bindings: + +```json +{ + "watch": { + "/form/startDate": { "action": "validateDateRange" }, + "/form/endDate": { "action": "validateDateRange" }, + "/form/quantity": [ + { "action": "recalculateTotal" }, + { "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } } + ] + } +} +``` + +## Behavior + +- Watchers only fire on **value changes**, not on the initial render +- Comparison is by reference (`===`), not deep equality +- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`) +- Multiple action bindings on the same path execute sequentially + +## When to Use `watch` vs `on` + +| Mechanism | Trigger | Use Case | +|-----------|---------|----------| +| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions | +| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync | + +Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects. + +## Next + +- [Data Binding](/docs/data-binding) — connect elements to state +- [Computed Values](/docs/computed-values) — derive prop values +- [Visibility](/docs/visibility) — conditionally show or hide elements diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index ad3a17a..1f50298 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [ { title: "Schemas", href: "/docs/schemas" }, { title: "Catalog", href: "/docs/catalog" }, { title: "Data Binding", href: "/docs/data-binding" }, + { title: "Computed Values", href: "/docs/computed-values" }, { title: "Visibility", href: "/docs/visibility" }, + { title: "Watchers", href: "/docs/watchers" }, { title: "Validation", href: "/docs/validation" }, ], }, diff --git a/examples/no-ai/app/page.tsx b/examples/no-ai/app/page.tsx index 89b624c..4cef135 100644 --- a/examples/no-ai/app/page.tsx +++ b/examples/no-ai/app/page.tsx @@ -2,33 +2,31 @@ import { useState, useEffect, useCallback, type ReactNode } from "react"; import ConfettiExplosion from "react-confetti-explosion"; -import { - Renderer, - StateProvider, - VisibilityProvider, - ActionProvider, - ValidationProvider, -} from "@json-render/react"; +import { JSONUIProvider, Renderer } from "@json-render/react"; import type { Spec } from "@json-render/core"; -import { registry, actionHandlers, onConfetti } from "@/lib/render/registry"; +import { + registry, + actionHandlers, + computedFunctions, + onConfetti, +} from "@/lib/render/registry"; import { examples } from "@/lib/examples"; function SpecRenderer({ spec }: { spec: Spec }): ReactNode { return ( - - - - - - - - - + + + ); } export default function Page() { - const [selectedIndex] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(0); const selected = examples[selectedIndex]!; const [confettiKey, setConfettiKey] = useState(0); const [confettiActive, setConfettiActive] = useState(false); @@ -41,30 +39,50 @@ export default function Page() { useEffect(() => onConfetti(fireConfetti), [fireConfetti]); return ( -
-
- {confettiActive && ( -
- setConfettiActive(false)} - /> +
+ {/* Example selector */} + + + {/* Render area */} +
+
+ {confettiActive && ( +
+ setConfettiActive(false)} + /> +
+ )} +
+

+ {selected.description} +

+
- )} -
-
diff --git a/examples/no-ai/lib/examples.ts b/examples/no-ai/lib/examples.ts index 1ad6cc7..b0bff78 100644 --- a/examples/no-ai/lib/examples.ts +++ b/examples/no-ai/lib/examples.ts @@ -508,4 +508,353 @@ export const examples: Example[] = [ }, }, }, + + // ========================================================================= + // Advanced: Registration form with cross-field validation & $template + // ========================================================================= + { + name: "Registration Form", + description: + "Cross-field validation, $template preview, and validateForm action", + spec: { + root: "card", + state: { + form: { + name: "", + email: "", + password: "", + confirmPassword: "", + accountType: "personal", + company: "", + }, + result: null, + }, + elements: { + card: { + type: "Card", + props: { + title: "Create Account", + description: "Fill out the form below to register", + maxWidth: "md", + centered: null, + }, + children: ["formStack"], + }, + formStack: { + type: "Stack", + props: { + direction: "vertical", + gap: "md", + align: null, + justify: null, + }, + children: [ + "preview", + "sep0", + "nameInput", + "emailInput", + "passwordInput", + "confirmInput", + "sep1", + "accountTypeRadio", + "companyInput", + "sep2", + "actions", + "statusText", + ], + }, + + // $template live preview + preview: { + type: "Text", + props: { + text: { + $template: "Welcome, ${/form/name}! Your email: ${/form/email}", + }, + variant: "muted", + }, + visible: { $state: "/form/name", neq: "" }, + children: [], + }, + sep0: { type: "Separator", props: { orientation: null }, children: [] }, + + nameInput: { + type: "Input", + props: { + label: "Full Name", + name: "name", + type: "text", + placeholder: "Jane Doe", + value: { $bindState: "/form/name" }, + checks: [ + { type: "required", message: "Name is required" }, + { + type: "minLength", + args: { min: 2 }, + message: "Name must be at least 2 characters", + }, + ], + validateOn: "blur", + }, + children: [], + }, + emailInput: { + type: "Input", + props: { + label: "Email", + name: "email", + type: "email", + placeholder: "jane@example.com", + value: { $bindState: "/form/email" }, + checks: [ + { type: "required", message: "Email is required" }, + { type: "email", message: "Enter a valid email address" }, + ], + validateOn: "blur", + }, + children: [], + }, + passwordInput: { + type: "Input", + props: { + label: "Password", + name: "password", + type: "password", + placeholder: "At least 8 characters", + value: { $bindState: "/form/password" }, + checks: [ + { type: "required", message: "Password is required" }, + { + type: "minLength", + args: { min: 8 }, + message: "Password must be at least 8 characters", + }, + ], + validateOn: "blur", + }, + children: [], + }, + confirmInput: { + type: "Input", + props: { + label: "Confirm Password", + name: "confirmPassword", + type: "password", + placeholder: "Re-enter your password", + value: { $bindState: "/form/confirmPassword" }, + checks: [ + { type: "required", message: "Please confirm your password" }, + { + type: "matches", + args: { other: { $state: "/form/password" } }, + message: "Passwords must match", + }, + ], + validateOn: "blur", + }, + children: [], + }, + sep1: { type: "Separator", props: { orientation: null }, children: [] }, + + accountTypeRadio: { + type: "Radio", + props: { + label: "Account Type", + name: "accountType", + options: ["personal", "business"], + value: { $bindState: "/form/accountType" }, + checks: null, + validateOn: null, + }, + children: [], + }, + companyInput: { + type: "Input", + props: { + label: "Company Name", + name: "company", + type: "text", + placeholder: "Acme Inc.", + value: { $bindState: "/form/company" }, + checks: [ + { + type: "requiredIf", + args: { field: { $state: "/form/accountType" } }, + message: "Company name is required for business accounts", + }, + ], + validateOn: "blur", + }, + visible: { $state: "/form/accountType", eq: "business" }, + children: [], + }, + sep2: { type: "Separator", props: { orientation: null }, children: [] }, + + actions: { + type: "Stack", + props: { + direction: "horizontal", + gap: "sm", + align: null, + justify: "end", + }, + children: ["submitBtn"], + }, + submitBtn: { + type: "Button", + props: { label: "Register", variant: "primary", disabled: null }, + on: { + press: [ + { + action: "validateForm", + params: { statePath: "/result" }, + }, + ], + }, + children: [], + }, + + // Validation result + statusText: { + type: "Alert", + props: { + title: "Validation Result", + message: { + $cond: { $state: "/result/valid", eq: true }, + $then: "All fields are valid -- ready to submit!", + $else: "Please fix the errors above before submitting.", + }, + type: { + $cond: { $state: "/result/valid", eq: true }, + $then: "success", + $else: "error", + }, + }, + visible: { $state: "/result", neq: null }, + children: [], + }, + }, + }, + }, + + // ========================================================================= + // Advanced: Cascading selects with watchers & $computed + // ========================================================================= + { + name: "Cascading Selects", + description: + "Watchers reset dependent fields, $computed derives display values", + spec: { + root: "card", + state: { + form: { country: "", city: "" }, + availableCities: [], + }, + elements: { + card: { + type: "Card", + props: { + title: "Shipping Address", + description: "Select your country to load available cities", + maxWidth: "md", + centered: null, + }, + children: ["formStack"], + }, + formStack: { + type: "Stack", + props: { + direction: "vertical", + gap: "md", + align: null, + justify: null, + }, + children: [ + "countrySelect", + "citySelect", + "sep", + "addressPreview", + "templatePreview", + ], + }, + + countrySelect: { + type: "Select", + props: { + label: "Country", + name: "country", + options: ["US", "Canada", "UK", "Germany", "Japan"], + placeholder: "Choose a country", + value: { $bindState: "/form/country" }, + checks: [{ type: "required", message: "Country is required" }], + validateOn: "change", + }, + watch: { + "/form/country": [ + { + action: "setState", + params: { + statePath: "/availableCities", + value: { + $computed: "citiesForCountry", + args: { country: { $state: "/form/country" } }, + }, + }, + }, + { + action: "setState", + params: { statePath: "/form/city", value: "" }, + }, + ], + }, + children: [], + }, + + citySelect: { + type: "Select", + props: { + label: "City", + name: "city", + options: { $state: "/availableCities" }, + placeholder: "Select a city", + value: { $bindState: "/form/city" }, + checks: [{ type: "required", message: "City is required" }], + validateOn: "change", + }, + children: [], + }, + sep: { type: "Separator", props: { orientation: null }, children: [] }, + + // $computed formatted address + addressPreview: { + type: "Heading", + props: { + text: { + $computed: "formatAddress", + args: { + city: { $state: "/form/city" }, + country: { $state: "/form/country" }, + }, + }, + level: "h3", + }, + children: [], + }, + + // $template string interpolation + templatePreview: { + type: "Text", + props: { + text: { + $template: + "Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}", + }, + variant: "muted", + }, + visible: { $state: "/form/country", neq: "" }, + children: [], + }, + }, + }, + }, ]; diff --git a/examples/no-ai/lib/render/catalog.ts b/examples/no-ai/lib/render/catalog.ts index bcbcba4..f80bfbc 100644 --- a/examples/no-ai/lib/render/catalog.ts +++ b/examples/no-ai/lib/render/catalog.ts @@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, { description: "Fire confetti", }, }, + functions: { + formatAddress: { + description: + "Formats country and city into a single address string like 'City, Country'", + }, + citiesForCountry: { + description: "Returns an array of city names for the given country code", + }, + }, }); diff --git a/examples/no-ai/lib/render/registry.tsx b/examples/no-ai/lib/render/registry.tsx index 02864e0..ae6cfa5 100644 --- a/examples/no-ai/lib/render/registry.tsx +++ b/examples/no-ai/lib/render/registry.tsx @@ -1,6 +1,7 @@ "use client"; import { defineRegistry } from "@json-render/react"; +import type { ComputedFunction } from "@json-render/core"; import { shadcnComponents } from "@json-render/shadcn"; import { catalog } from "./catalog"; @@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) { }; } +const cityData: Record = { + US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"], + Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"], + UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"], + Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"], + Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"], +}; + export const { registry } = defineRegistry(catalog, { components: { ...shadcnComponents, @@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, { }, }); -export const actionHandlers: Record void> = { +export const actionHandlers: Record< + string, + (params: Record) => void +> = { confetti: () => confettiListener?.(), }; + +export const computedFunctions: Record = { + formatAddress: (args) => { + const city = (args.city as string) ?? ""; + const country = (args.country as string) ?? ""; + if (!city && !country) return "No location selected"; + if (!city) return country; + return `${city}, ${country}`; + }, + citiesForCountry: (args) => { + const country = (args.country as string) ?? ""; + return cityData[country] ?? []; + }, +}; diff --git a/package.json b/package.json index 6e258a2..d71255e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "scripts": { "build": "turbo run build", - "dev": "turbo run dev --concurrency 15", + "dev": "turbo run dev --concurrency 20", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx}\"", "type-check": "turbo run check-types", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f37396..acc2107 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,7 +72,11 @@ export { } from "./visibility"; // Prop Expressions -export type { PropExpression, PropResolutionContext } from "./props"; +export type { + PropExpression, + PropResolutionContext, + ComputedFunction, +} from "./props"; export { resolvePropValue, diff --git a/packages/core/src/props.test.ts b/packages/core/src/props.test.ts index aa33105..2a560dc 100644 --- a/packages/core/src/props.test.ts +++ b/packages/core/src/props.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { resolvePropValue, resolveElementProps, resolveBindings, resolveActionParam, + _resetWarnedComputedFns, + _resetWarnedTemplatePaths, } from "./props"; import type { PropResolutionContext } from "./props"; @@ -498,3 +500,165 @@ describe("resolveActionParam", () => { expect(resolveActionParam(null, ctx)).toBeNull(); }); }); + +// ============================================================================= +// $computed expressions +// ============================================================================= + +describe("$computed expressions", () => { + it("calls a registered function with resolved args", () => { + const ctx: PropResolutionContext = { + stateModel: { form: { firstName: "Jane", lastName: "Doe" } }, + functions: { + fullName: (args) => `${args.first} ${args.last}`, + }, + }; + expect( + resolvePropValue( + { + $computed: "fullName", + args: { + first: { $state: "/form/firstName" }, + last: { $state: "/form/lastName" }, + }, + }, + ctx, + ), + ).toBe("Jane Doe"); + }); + + it("calls function with no args", () => { + const ctx: PropResolutionContext = { + stateModel: {}, + functions: { + timestamp: () => 1234567890, + }, + }; + expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890); + }); + + it("returns undefined for unknown function", () => { + const ctx: PropResolutionContext = { + stateModel: {}, + functions: {}, + }; + expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined(); + }); + + it("returns undefined when no functions in context", () => { + const ctx: PropResolutionContext = { stateModel: {} }; + expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined(); + }); + + it("deduplicates warnings for the same unknown function", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ctx: PropResolutionContext = { stateModel: {}, functions: {} }; + resolvePropValue({ $computed: "dedupTest" }, ctx); + resolvePropValue({ $computed: "dedupTest" }, ctx); + const calls = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("dedupTest"), + ); + expect(calls).toHaveLength(1); + warnSpy.mockRestore(); + }); + + it("resolves nested expressions in args", () => { + const ctx: PropResolutionContext = { + stateModel: { active: true, values: { a: 10, b: 20 } }, + functions: { + conditionalSum: (args) => { + if (args.enabled) return (args.x as number) + (args.y as number); + return 0; + }, + }, + }; + expect( + resolvePropValue( + { + $computed: "conditionalSum", + args: { + enabled: { $state: "/active" }, + x: { $state: "/values/a" }, + y: { $state: "/values/b" }, + }, + }, + ctx, + ), + ).toBe(30); + }); +}); + +// ============================================================================= +// $template expressions +// ============================================================================= + +describe("$template expressions", () => { + it("interpolates state values into a string", () => { + const ctx: PropResolutionContext = { + stateModel: { user: { name: "Alice" }, count: 3 }, + }; + expect( + resolvePropValue( + { $template: "Hello, ${/user/name}! You have ${/count} messages." }, + ctx, + ), + ).toBe("Hello, Alice! You have 3 messages."); + }); + + it("replaces missing paths with empty string", () => { + const ctx: PropResolutionContext = { stateModel: {} }; + expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !"); + }); + + it("handles template with no interpolations", () => { + const ctx: PropResolutionContext = { stateModel: {} }; + expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe( + "No variables here", + ); + }); + + it("handles multiple references to the same path", () => { + const ctx: PropResolutionContext = { + stateModel: { x: "A" }, + }; + expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe( + "A and A", + ); + }); + + it("converts non-string values to strings", () => { + const ctx: PropResolutionContext = { + stateModel: { num: 42, bool: true }, + }; + expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe( + "42 is true", + ); + }); + + it("warns when path does not start with /", () => { + _resetWarnedTemplatePaths(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ctx: PropResolutionContext = { stateModel: { name: "Bob" } }; + const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx); + expect(result).toBe("Hi Bob!"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('$template path "name"'), + ); + warnSpy.mockRestore(); + _resetWarnedTemplatePaths(); + }); + + it("deduplicates warnings for the same $template path", () => { + _resetWarnedTemplatePaths(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ctx: PropResolutionContext = { stateModel: { name: "Bob" } }; + resolvePropValue({ $template: "Hi ${name}!" }, ctx); + resolvePropValue({ $template: "Hi ${name}!" }, ctx); + const calls = warnSpy.mock.calls.filter((c) => + String(c[0]).includes('$template path "name"'), + ); + expect(calls).toHaveLength(1); + warnSpy.mockRestore(); + _resetWarnedTemplatePaths(); + }); +}); diff --git a/packages/core/src/props.ts b/packages/core/src/props.ts index fde2f9f..0e1c9a9 100644 --- a/packages/core/src/props.ts +++ b/packages/core/src/props.ts @@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility"; * repeat item — resolves via `repeatBasePath + path` and exposes the * absolute state path for write-back. * - `{ $cond, $then, $else }` conditionally picks a value + * - `{ $computed: string, args?: Record }` calls a + * registered function with resolved args and returns the result + * - `{ $template: string }` interpolates `${/path}` references in the + * string with values from the state model * - Any other value is a literal (passthrough) */ export type PropExpression = @@ -35,7 +39,15 @@ export type PropExpression = $cond: VisibilityCondition; $then: PropExpression; $else: PropExpression; - }; + } + | { $computed: string; args?: Record } + | { $template: string }; + +/** + * Function signature for `$computed` expressions. + * Receives a record of resolved argument values and returns a computed result. + */ +export type ComputedFunction = (args: Record) => unknown; /** * Context for resolving prop expressions. @@ -45,6 +57,8 @@ export type PropExpression = export interface PropResolutionContext extends VisibilityContext { /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */ repeatBasePath?: string; + /** Named functions available for `$computed` expressions. */ + functions?: Record; } // ============================================================================= @@ -110,6 +124,47 @@ function isCondExpression( ); } +function isComputedExpression( + value: unknown, +): value is { $computed: string; args?: Record } { + return ( + typeof value === "object" && + value !== null && + "$computed" in value && + typeof (value as Record).$computed === "string" + ); +} + +function isTemplateExpression(value: unknown): value is { $template: string } { + return ( + typeof value === "object" && + value !== null && + "$template" in value && + typeof (value as Record).$template === "string" + ); +} + +// Module-level set to avoid spamming console.warn on every render for the same +// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX, +// new names are no longer deduplicated (warnings still fire) but the set stops +// growing, preventing unbounded memory use in long-lived processes (e.g. SSR). +const WARNED_COMPUTED_MAX = 100; +const warnedComputedFns = new Set(); + +/** @internal Test-only: clear the deduplication set for $computed warnings. */ +export function _resetWarnedComputedFns(): void { + warnedComputedFns.clear(); +} + +// Same deduplication pattern for $template paths that don't start with "/". +const WARNED_TEMPLATE_MAX = 100; +const warnedTemplatePaths = new Set(); + +/** @internal Test-only: clear the deduplication set for $template warnings. */ +export function _resetWarnedTemplatePaths(): void { + warnedTemplatePaths.clear(); +} + // ============================================================================= // Prop Expression Resolution // ============================================================================= @@ -193,6 +248,50 @@ export function resolvePropValue( return resolvePropValue(result ? value.$then : value.$else, ctx); } + // $computed: call a registered function with resolved args + if (isComputedExpression(value)) { + const fn = ctx.functions?.[value.$computed]; + if (!fn) { + if (!warnedComputedFns.has(value.$computed)) { + if (warnedComputedFns.size < WARNED_COMPUTED_MAX) { + warnedComputedFns.add(value.$computed); + } + console.warn(`Unknown $computed function: "${value.$computed}"`); + } + return undefined; + } + const resolvedArgs: Record = {}; + if (value.args) { + for (const [key, arg] of Object.entries(value.args)) { + resolvedArgs[key] = resolvePropValue(arg, ctx); + } + } + return fn(resolvedArgs); + } + + // $template: interpolate ${/path} references with state values + if (isTemplateExpression(value)) { + return value.$template.replace( + /\$\{([^}]+)\}/g, + (_match, rawPath: string) => { + let path = rawPath; + if (!path.startsWith("/")) { + if (!warnedTemplatePaths.has(path)) { + if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) { + warnedTemplatePaths.add(path); + } + console.warn( + `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`, + ); + } + path = "/" + path; + } + const resolved = getByPath(ctx.stateModel, path); + return resolved != null ? String(resolved) : ""; + }, + ); + } + // Arrays: resolve each element if (Array.isArray(value)) { return value.map((item) => resolvePropValue(item, ctx)); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 214fa61..0e89fa0 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -900,6 +900,31 @@ Note: state patches appear right after the elements that use them, so the UI fil "Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.", ); lines.push(""); + lines.push( + '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.', + ); + lines.push( + ' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.', + ); + lines.push(""); + + // $computed section — only emit when catalog defines functions + const catalogFunctions = (catalog.data as Record).functions; + if (catalogFunctions && Object.keys(catalogFunctions).length > 0) { + lines.push( + '5. Computed: `{ "$computed": "", "args": { "key": } }` - calls a registered function with resolved args and returns the result.', + ); + lines.push( + ' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`', + ); + lines.push(" Available functions:"); + for (const name of Object.keys( + catalogFunctions as Record, + )) { + lines.push(` - ${name}`); + } + lines.push(""); + } // Validation section — only emit when at least one component has a `checks` prop const hasChecksComponents = allComponents @@ -930,7 +955,19 @@ Note: state patches appear right after the elements that use them, so the UI fil lines.push(" - numeric — value must be a number"); lines.push(" - url — valid URL format"); lines.push( - ' - matches — must equal another field (args: { "other": "value" })', + ' - matches — must equal another field (args: { "other": { "$state": "/path" } })', + ); + lines.push( + ' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })', + ); + lines.push( + ' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })', + ); + lines.push( + ' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })', + ); + lines.push( + ' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })', ); lines.push(""); lines.push("Example:"); @@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil lines.push(""); } + // State watchers section — only emit when actions are available (watchers + // trigger actions, so the section is irrelevant without them). + if (hasCustomActions || hasBuiltInActions) { + lines.push("STATE WATCHERS:"); + lines.push( + "Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.", + ); + lines.push( + "Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.", + ); + lines.push(""); + lines.push( + "Example (cascading select — country changes trigger city loading):", + ); + lines.push( + ` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`, + ); + lines.push(""); + lines.push( + "Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).", + ); + lines.push( + "IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.", + ); + lines.push(""); + } + // Rules lines.push("RULES:"); const baseRules = diff --git a/packages/core/src/spec-validator.test.ts b/packages/core/src/spec-validator.test.ts new file mode 100644 index 0000000..e5ee6ca --- /dev/null +++ b/packages/core/src/spec-validator.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import type { Spec } from "./types"; +import { validateSpec, autoFixSpec } from "./spec-validator"; + +// ============================================================================= +// validateSpec +// ============================================================================= + +describe("validateSpec", () => { + it("returns valid for a correct spec", () => { + const spec: Spec = { + root: "root", + elements: { + root: { type: "Stack", props: {}, children: ["child1"] }, + child1: { type: "Text", props: { text: "hello" }, children: [] }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it("detects missing root", () => { + const spec = { + root: "", + elements: { a: { type: "T", props: {}, children: [] } }, + } as Spec; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "missing_root")).toBe(true); + }); + + it("detects root_not_found", () => { + const spec: Spec = { + root: "missing", + elements: { a: { type: "T", props: {}, children: [] } }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true); + }); + + it("detects empty spec", () => { + const spec: Spec = { root: "r", elements: {} }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true); + }); + + it("detects missing_child", () => { + const spec: Spec = { + root: "root", + elements: { + root: { type: "Stack", props: {}, children: ["nonexistent"] }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "missing_child")).toBe(true); + }); + + it("detects visible_in_props", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Text", + props: { visible: { $state: "/show" } }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true); + }); + + it("detects on_in_props", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Button", + props: { on: { press: { action: "doSomething" } } }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true); + }); + + it("detects repeat_in_props", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Stack", + props: { repeat: { statePath: "/items" } }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true); + }); + + it("detects watch_in_props", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Select", + props: { + watch: { + "/form/country": { action: "loadCities" }, + }, + }, + children: [], + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + const watchIssue = result.issues.find((i) => i.code === "watch_in_props"); + expect(watchIssue).toBeDefined(); + expect(watchIssue!.elementKey).toBe("root"); + }); + + it("detects orphaned elements when checkOrphans is true", () => { + const spec: Spec = { + root: "root", + elements: { + root: { type: "Stack", props: {}, children: [] }, + orphan: { type: "Text", props: {}, children: [] }, + }, + }; + const result = validateSpec(spec, { checkOrphans: true }); + expect(result.valid).toBe(true); + expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true); + }); +}); + +// ============================================================================= +// autoFixSpec +// ============================================================================= + +describe("autoFixSpec", () => { + it("moves visible from props to element level", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Text", + props: { text: "hi", visible: { $state: "/show" } }, + children: [], + }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + expect( + (fixed.elements.root.props as Record).visible, + ).toBeUndefined(); + expect(fixed.elements.root.visible).toEqual({ $state: "/show" }); + expect(fixes.some((f) => f.includes("visible"))).toBe(true); + }); + + it("moves on from props to element level", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Button", + props: { label: "OK", on: { press: { action: "submit" } } }, + children: [], + }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + expect( + (fixed.elements.root.props as Record).on, + ).toBeUndefined(); + expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } }); + expect(fixes.some((f) => f.includes('"on"'))).toBe(true); + }); + + it("moves repeat from props to element level", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Stack", + props: { repeat: { statePath: "/items" } }, + children: ["child"], + }, + child: { type: "Text", props: {}, children: [] }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + expect( + (fixed.elements.root.props as Record).repeat, + ).toBeUndefined(); + expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" }); + expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true); + }); + + it("moves watch from props to element level", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Select", + props: { + label: "Country", + watch: { + "/form/country": { action: "loadCities" }, + }, + }, + children: [], + }, + }, + }; + const { spec: fixed, fixes } = autoFixSpec(spec); + expect( + (fixed.elements.root.props as Record).watch, + ).toBeUndefined(); + expect(fixed.elements.root.watch).toEqual({ + "/form/country": { action: "loadCities" }, + }); + expect(fixes.some((f) => f.includes('"watch"'))).toBe(true); + }); + + it("returns no fixes for a correct spec", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Stack", + props: { direction: "vertical" }, + children: [], + watch: { "/x": { action: "y" } }, + }, + }, + }; + const { fixes } = autoFixSpec(spec); + expect(fixes).toHaveLength(0); + }); +}); diff --git a/packages/core/src/spec-validator.ts b/packages/core/src/spec-validator.ts index 8784999..a04d2f2 100644 --- a/packages/core/src/spec-validator.ts +++ b/packages/core/src/spec-validator.ts @@ -28,7 +28,8 @@ export interface SpecIssue { | "orphaned_element" | "empty_spec" | "on_in_props" - | "repeat_in_props"; + | "repeat_in_props" + | "watch_in_props"; } /** @@ -151,6 +152,16 @@ export function validateSpec( code: "repeat_in_props", }); } + + // 3e. `watch` inside props (should be a top-level field) + if (props && "watch" in props && props.watch !== undefined) { + issues.push({ + severity: "error", + message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`, + elementKey: key, + code: "watch_in_props", + }); + } } // 4. Orphaned elements (optional) @@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): { fixes.push(`Moved "repeat" from props to element level on "${key}".`); } + currentProps = fixed.props as Record | undefined; + if ( + currentProps && + "watch" in currentProps && + currentProps.watch !== undefined + ) { + const { watch, ...restProps } = currentProps; + fixed = { + ...fixed, + props: restProps, + watch: watch as UIElement["watch"], + }; + fixes.push(`Moved "watch" from props to element level on "${key}".`); + } + fixedElements[key] = fixed; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3815533..cbc2808 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -69,6 +69,12 @@ export interface UIElement< on?: Record; /** Repeat children once per item in a state array */ repeat?: { statePath: string; key?: string }; + /** + * State watchers — maps JSON Pointer state paths to action bindings. + * When the value at a watched path changes, the bound actions fire. + * Useful for cascading dependencies (e.g. country → city option loading). + */ + watch?: Record; } /** diff --git a/packages/core/src/validation.test.ts b/packages/core/src/validation.test.ts index 5aa401b..85fefb4 100644 --- a/packages/core/src/validation.test.ts +++ b/packages/core/src/validation.test.ts @@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => { ).toBe(false); }); }); + + describe("equalTo", () => { + it("passes when values are equal", () => { + expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe( + true, + ); + }); + + it("fails when values differ", () => { + expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe( + false, + ); + }); + }); + + describe("lessThan", () => { + it("passes when value is less than other", () => { + expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true); + }); + + it("fails when value equals other", () => { + expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false); + }); + + it("fails when value is greater than other", () => { + expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false); + }); + + it("coerces numeric string vs number", () => { + expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true); + }); + + it("fails coercion when non-numeric string", () => { + expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe( + false, + ); + }); + + it("passes for string comparison (ISO dates)", () => { + expect( + builtInValidationFunctions.lessThan("2026-01-01", { + other: "2026-06-15", + }), + ).toBe(true); + }); + + it("fails for equal strings", () => { + expect( + builtInValidationFunctions.lessThan("2026-01-01", { + other: "2026-01-01", + }), + ).toBe(false); + }); + + it("returns false when value is empty string", () => { + expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false); + }); + + it("returns false when other is empty string", () => { + expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false); + }); + + it("returns false when value is empty string vs non-empty string", () => { + expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe( + false, + ); + }); + + it("returns false when other is empty string vs non-empty string", () => { + expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe( + false, + ); + }); + + it("returns false when other is null", () => { + expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe( + false, + ); + }); + + it("returns false when value is null", () => { + expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe( + false, + ); + }); + + it("returns false when other is undefined", () => { + expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe( + false, + ); + }); + }); + + describe("greaterThan", () => { + it("passes when value is greater than other", () => { + expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe( + true, + ); + }); + + it("fails when value equals other", () => { + expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe( + false, + ); + }); + + it("fails when value is less than other", () => { + expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe( + false, + ); + }); + + it("coerces numeric string vs number", () => { + expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe( + true, + ); + }); + + it("fails coercion when non-numeric string", () => { + expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe( + false, + ); + }); + + it("passes for string comparison (ISO dates)", () => { + expect( + builtInValidationFunctions.greaterThan("2026-06-15", { + other: "2026-01-01", + }), + ).toBe(true); + }); + + it("fails for lesser strings", () => { + expect( + builtInValidationFunctions.greaterThan("2026-01-01", { + other: "2026-06-15", + }), + ).toBe(false); + }); + + it("returns false when value is empty string", () => { + expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe( + false, + ); + }); + + it("returns false when other is empty string", () => { + expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe( + false, + ); + }); + + it("returns false when value is empty string vs non-empty string", () => { + expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe( + false, + ); + }); + + it("returns false when other is empty string vs non-empty string", () => { + expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe( + false, + ); + }); + + it("returns false when other is null", () => { + expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe( + false, + ); + }); + + it("returns false when value is undefined", () => { + expect( + builtInValidationFunctions.greaterThan(undefined, { other: 5 }), + ).toBe(false); + }); + + it("returns false when value is null", () => { + expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe( + false, + ); + }); + }); + + describe("requiredIf", () => { + it("passes when condition is falsy (field not required)", () => { + expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe( + true, + ); + expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe( + true, + ); + expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe( + true, + ); + expect( + builtInValidationFunctions.requiredIf("", { field: undefined }), + ).toBe(true); + }); + + it("fails when condition is truthy and value is empty", () => { + expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe( + false, + ); + expect( + builtInValidationFunctions.requiredIf(null, { field: "yes" }), + ).toBe(false); + expect( + builtInValidationFunctions.requiredIf(undefined, { field: 1 }), + ).toBe(false); + }); + + it("passes when condition is truthy and value is present", () => { + expect( + builtInValidationFunctions.requiredIf("hello", { field: true }), + ).toBe(true); + expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe( + true, + ); + }); + }); }); describe("runValidationCheck", () => { @@ -391,6 +611,22 @@ describe("check helper", () => { }); }); + describe("numeric", () => { + it("creates numeric check with default message", () => { + const c = check.numeric(); + + expect(c.type).toBe("numeric"); + expect(c.message).toBe("Must be a number"); + }); + + it("creates numeric check with custom message", () => { + const c = check.numeric("Numbers only"); + + expect(c.type).toBe("numeric"); + expect(c.message).toBe("Numbers only"); + }); + }); + describe("matches", () => { it("creates matches check with path reference", () => { const c = check.matches("/password", "Passwords must match"); @@ -400,4 +636,109 @@ describe("check helper", () => { expect(c.message).toBe("Passwords must match"); }); }); + + describe("equalTo", () => { + it("creates equalTo check with path reference", () => { + const c = check.equalTo("/email", "Emails must match"); + + expect(c.type).toBe("equalTo"); + expect(c.args).toEqual({ other: { $state: "/email" } }); + expect(c.message).toBe("Emails must match"); + }); + }); + + describe("lessThan", () => { + it("creates lessThan check with path reference", () => { + const c = check.lessThan("/maxValue", "Must be less"); + + expect(c.type).toBe("lessThan"); + expect(c.args).toEqual({ other: { $state: "/maxValue" } }); + expect(c.message).toBe("Must be less"); + }); + }); + + describe("greaterThan", () => { + it("creates greaterThan check with path reference", () => { + const c = check.greaterThan("/minValue"); + + expect(c.type).toBe("greaterThan"); + expect(c.args).toEqual({ other: { $state: "/minValue" } }); + }); + }); + + describe("requiredIf", () => { + it("creates requiredIf check with path reference", () => { + const c = check.requiredIf("/toggle", "Required when toggle is on"); + + expect(c.type).toBe("requiredIf"); + expect(c.args).toEqual({ field: { $state: "/toggle" } }); + expect(c.message).toBe("Required when toggle is on"); + }); + }); +}); + +// ============================================================================= +// Deep arg resolution in runValidationCheck +// ============================================================================= + +describe("deep arg resolution", () => { + it("resolves nested $state refs in validation args", () => { + const result = runValidationCheck( + { + type: "matches", + args: { other: { $state: "/form/password" } }, + message: "Passwords must match", + }, + { + value: "secret123", + stateModel: { form: { password: "secret123" } }, + }, + ); + expect(result.valid).toBe(true); + }); + + it("resolves $state in cross-field lessThan check", () => { + const result = runValidationCheck( + { + type: "lessThan", + args: { other: { $state: "/form/maxPrice" } }, + message: "Must be less than max price", + }, + { + value: 50, + stateModel: { form: { maxPrice: 100 } }, + }, + ); + expect(result.valid).toBe(true); + }); + + it("resolves $state in requiredIf check", () => { + const result = runValidationCheck( + { + type: "requiredIf", + args: { field: { $state: "/form/enableEmail" } }, + message: "Email is required", + }, + { + value: "", + stateModel: { form: { enableEmail: true } }, + }, + ); + expect(result.valid).toBe(false); + }); + + it("passes requiredIf when condition is false", () => { + const result = runValidationCheck( + { + type: "requiredIf", + args: { field: { $state: "/form/enableEmail" } }, + message: "Email is required", + }, + { + value: "", + stateModel: { form: { enableEmail: false } }, + }, + ); + expect(result.valid).toBe(true); + }); }); diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 9d1b701..fd9c20e 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { DynamicValue, StateModel, VisibilityCondition } from "./types"; import { DynamicValueSchema, resolveDynamicValue } from "./types"; import { VisibilityConditionSchema, evaluateVisibility } from "./visibility"; +import { resolvePropValue } from "./props"; /** * Validation check definition @@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition { description?: string; } +const matchesImpl: ValidationFunction = ( + value: unknown, + args?: Record, +) => { + const other = args?.other; + return value === other; +}; + /** * Built-in validation functions */ @@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record = { /** * Check if value matches another field */ - matches: (value: unknown, args?: Record) => { + matches: matchesImpl, + + /** + * Alias for matches with a more descriptive name for cross-field equality + */ + equalTo: matchesImpl, + + /** + * Check if value is less than another field's value. + * Supports numbers, strings (useful for ISO date comparison), and + * cross-type numeric coercion (e.g. string "3" vs number 5). + */ + lessThan: (value: unknown, args?: Record) => { const other = args?.other; - return value === other; + if (value == null || other == null || value === "" || other === "") + return false; + if (typeof value === "number" && typeof other === "number") + return value < other; + if (typeof value === "string" && typeof other === "string") + return value < other; + const numVal = Number(value); + const numOther = Number(other); + if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther; + return false; + }, + + /** + * Check if value is greater than another field's value. + * Supports numbers, strings (useful for ISO date comparison), and + * cross-type numeric coercion (e.g. string "7" vs number 5). + */ + greaterThan: (value: unknown, args?: Record) => { + const other = args?.other; + if (value == null || other == null || value === "" || other === "") + return false; + if (typeof value === "number" && typeof other === "number") + return value > other; + if (typeof value === "string" && typeof other === "string") + return value > other; + const numVal = Number(value); + const numOther = Number(other); + if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther; + return false; + }, + + /** + * Required only when a condition is met. + * Uses JS truthiness: 0, false, "", null, and undefined are all + * treated as "condition not met" (field not required), matching + * the visibility system's bare-condition semantics. + */ + requiredIf: (value: unknown, args?: Record) => { + const condition = args?.field; + if (!condition) return true; + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; }, }; @@ -209,11 +273,12 @@ export function runValidationCheck( ): ValidationCheckResult { const { value, stateModel, customFunctions } = ctx; - // Resolve args + // Resolve args using resolvePropValue so nested $state refs (and any other + // prop expressions) are handled consistently with the rest of the system. const resolvedArgs: Record = {}; if (check.args) { for (const [key, argValue] of Object.entries(check.args)) { - resolvedArgs[key] = resolveDynamicValue(argValue, stateModel); + resolvedArgs[key] = resolvePropValue(argValue, { stateModel }); } } @@ -326,6 +391,11 @@ export const check = { message, }), + numeric: (message = "Must be a number"): ValidationCheck => ({ + type: "numeric", + message, + }), + matches: ( otherPath: string, message = "Fields must match", @@ -334,4 +404,34 @@ export const check = { args: { other: { $state: otherPath } }, message, }), + + equalTo: ( + otherPath: string, + message = "Fields must match", + ): ValidationCheck => ({ + type: "equalTo", + args: { other: { $state: otherPath } }, + message, + }), + + lessThan: (otherPath: string, message?: string): ValidationCheck => ({ + type: "lessThan", + args: { other: { $state: otherPath } }, + message: message ?? "Must be less than the compared field", + }), + + greaterThan: (otherPath: string, message?: string): ValidationCheck => ({ + type: "greaterThan", + args: { other: { $state: otherPath } }, + message: message ?? "Must be greater than the compared field", + }), + + requiredIf: ( + fieldPath: string, + message = "This field is required", + ): ValidationCheck => ({ + type: "requiredIf", + args: { field: { $state: fieldPath } }, + message, + }), }; diff --git a/packages/react/src/contexts/actions.tsx b/packages/react/src/contexts/actions.tsx index 3773379..064a298 100644 --- a/packages/react/src/contexts/actions.tsx +++ b/packages/react/src/contexts/actions.tsx @@ -17,6 +17,7 @@ import { type ResolvedAction, } from "@json-render/core"; import { useStateStore } from "./state"; +import { useOptionalValidation } from "./validation"; /** * Generate a unique ID for use with the "$id" token. @@ -137,6 +138,7 @@ export function ActionProvider({ children, }: ActionProviderProps) { const { get, set, getSnapshot } = useStateStore(); + const validation = useOptionalValidation(); const [handlers, setHandlers] = useState>(initialHandlers); @@ -227,13 +229,34 @@ export function ActionProvider({ if (previousScreen) { set("/currentScreen", previousScreen); } else { - // Sentinel empty string = clear currentScreen (return to default) set("/currentScreen", undefined); } } return; } + // Built-in: validateForm triggers validateAll from the ValidationProvider + // and writes the result to a state path (default: /formValidation). + // IMPORTANT: validateAll() is synchronous — it runs all registered field + // validations and returns immediately. This guarantees that the next action + // in a sequential list (e.g. [validateForm, submitForm]) can read the + // validation result from state without awaiting an extra tick. + if (resolved.action === "validateForm") { + const validateAll = validation?.validateAll; + if (!validateAll) { + console.warn( + "validateForm action was dispatched but no ValidationProvider is connected. " + + "Ensure ValidationProvider is rendered inside the provider tree.", + ); + return; + } + const valid = validateAll(); + const statePath = + (resolved.params?.statePath as string) || "/formValidation"; + set(statePath, { valid }); + return; + } + const handler = handlers[resolved.action]; if (!handler) { @@ -300,7 +323,7 @@ export function ActionProvider({ }); } }, - [handlers, get, set, getSnapshot, navigate], + [handlers, get, set, getSnapshot, navigate, validation], ); const confirm = useCallback(() => { diff --git a/packages/react/src/contexts/validation.tsx b/packages/react/src/contexts/validation.tsx index 10befe5..7c2e0a3 100644 --- a/packages/react/src/contexts/validation.tsx +++ b/packages/react/src/contexts/validation.tsx @@ -6,7 +6,6 @@ import React, { useState, useCallback, useMemo, - useRef, type ReactNode, } from "react"; import { @@ -130,11 +129,7 @@ export function ValidationProvider({ customFunctions = {}, children, }: ValidationProviderProps) { - const { state } = useStateStore(); - // Keep a ref to the latest state so `validate` doesn't change on every - // state update — preventing the entire validation context from churning. - const stateRef = useRef(state); - stateRef.current = state; + const { state, getSnapshot } = useStateStore(); const [fieldStates, setFieldStates] = useState< Record @@ -160,8 +155,10 @@ export function ValidationProvider({ const validate = useCallback( (path: string, config: ValidationConfig): ValidationResult => { - // Walk the nested state object using JSON Pointer segments - const currentState = stateRef.current; + // Read from the store directly so validation sees values written in the + // same synchronous handler (e.g. setValue then validate in onChange). + // Using React state would return the stale pre-render snapshot. + const currentState = getSnapshot(); const segments = path.split("/").filter(Boolean); let value: unknown = currentState; for (const seg of segments) { @@ -189,7 +186,7 @@ export function ValidationProvider({ return result; }, - [customFunctions], + [customFunctions, getSnapshot], ); const touch = useCallback((path: string) => { @@ -263,6 +260,14 @@ export function useValidation(): ValidationContextValue { return ctx; } +/** + * Non-throwing variant of useValidation. + * Returns null when no ValidationProvider is present. + */ +export function useOptionalValidation(): ValidationContextValue | null { + return useContext(ValidationContext); +} + /** * Hook to get validation state for a field */ diff --git a/packages/react/src/dynamic-forms.test.tsx b/packages/react/src/dynamic-forms.test.tsx new file mode 100644 index 0000000..7a11be9 --- /dev/null +++ b/packages/react/src/dynamic-forms.test.tsx @@ -0,0 +1,770 @@ +import { describe, it, expect, vi } from "vitest"; +import React, { useState, useCallback, useMemo } from "react"; +import { render, act, fireEvent, screen } from "@testing-library/react"; +import type { Spec } from "@json-render/core"; +import { + JSONUIProvider, + Renderer, + type ComponentRenderProps, +} from "./renderer"; +import { useStateStore } from "./contexts/state"; +import { useFieldValidation } from "./contexts/validation"; +import { useBoundProp } from "./hooks"; + +// ============================================================================= +// Stub components +// ============================================================================= + +function Button({ element, emit }: ComponentRenderProps<{ label: string }>) { + return ( + + ); +} + +function Text({ element }: ComponentRenderProps<{ text: unknown }>) { + const value = element.props.text; + return ( + + {value == null + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value)} + + ); +} + +function InputField({ + element, + bindings, +}: ComponentRenderProps<{ + label?: string; + value?: string; + checks?: Array<{ + type: string; + message: string; + args?: Record; + }>; +}>) { + const props = element.props; + const [boundValue, setBoundValue] = useBoundProp( + props.value as string | undefined, + bindings?.value, + ); + const [localValue, setLocalValue] = useState(""); + const isBound = !!bindings?.value; + const value = isBound ? (boundValue ?? "") : localValue; + const setValue = isBound ? setBoundValue : setLocalValue; + + const hasValidation = !!(bindings?.value && props.checks?.length); + const config = useMemo( + () => (hasValidation ? { checks: props.checks ?? [] } : undefined), + [hasValidation, props.checks], + ); + const { errors } = useFieldValidation(bindings?.value ?? "", config); + + return ( +
+ {props.label && } + setValue(e.target.value)} + /> + {errors.length > 0 && {errors[0]}} +
+ ); +} + +function SelectField({ + element, + bindings, +}: ComponentRenderProps<{ label?: string; value?: string }>) { + const props = element.props; + const [boundValue] = useBoundProp( + props.value as string | undefined, + bindings?.value, + ); + return {boundValue ?? ""}; +} + +/** + * Select stub that mirrors the real shadcn Select validation behavior: + * calls setValue then validate() synchronously in onValueChange. + */ +function ValidatedSelect({ + element, + bindings, + emit, +}: ComponentRenderProps<{ + label?: string; + name?: string; + options?: string[]; + placeholder?: string; + value?: string; + checks?: Array<{ + type: string; + message: string; + args?: Record; + }>; + validateOn?: "change" | "blur" | "submit"; +}>) { + const props = element.props; + const [boundValue, setBoundValue] = useBoundProp( + props.value as string | undefined, + bindings?.value, + ); + const [localValue, setLocalValue] = useState(""); + const isBound = !!bindings?.value; + const value = isBound ? (boundValue ?? "") : localValue; + const setValue = isBound ? setBoundValue : setLocalValue; + const validateOn = props.validateOn ?? "change"; + + const hasValidation = !!(bindings?.value && props.checks?.length); + const config = useMemo( + () => + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, + [hasValidation, props.checks, validateOn], + ); + const { errors, validate } = useFieldValidation( + bindings?.value ?? "", + config, + ); + + const options = props.options ?? []; + + return ( +
+ {props.label && } + + {errors.length > 0 && ( + + {errors[0]} + + )} +
+ ); +} + +function StateProbe() { + const { state } = useStateStore(); + return
{JSON.stringify(state)}
; +} + +const registry = { Button, Text, Input: InputField, Select: SelectField }; + +function getState(): Record { + return JSON.parse(screen.getByTestId("state-probe").textContent!); +} + +// ============================================================================= +// $computed expressions in rendering +// ============================================================================= + +describe("$computed expressions in rendering", () => { + it("resolves a $computed prop using provided functions", async () => { + const spec: Spec = { + state: { first: "Jane", last: "Doe" }, + root: "main", + elements: { + main: { + type: "Text", + props: { + text: { + $computed: "fullName", + args: { + first: { $state: "/first" }, + last: { $state: "/last" }, + }, + }, + }, + children: [], + }, + }, + }; + + const functions = { + fullName: (args: Record) => `${args.first} ${args.last}`, + }; + + render( + + + , + ); + + expect(screen.getByTestId("text").textContent).toBe("Jane Doe"); + }); + + it("renders gracefully when functions prop is omitted", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spec: Spec = { + state: {}, + root: "main", + elements: { + main: { + type: "Text", + props: { + text: { $computed: "missing" }, + }, + children: [], + }, + }, + }; + + render( + + + , + ); + + expect(screen.getByTestId("text").textContent).toBe(""); + warnSpy.mockRestore(); + }); +}); + +// ============================================================================= +// $template expressions in rendering +// ============================================================================= + +describe("$template expressions in rendering", () => { + it("interpolates state values into a template string", () => { + const spec: Spec = { + state: { user: { name: "Alice" }, count: 3 }, + root: "main", + elements: { + main: { + type: "Text", + props: { + text: { + $template: "Hello, ${/user/name}! You have ${/count} messages.", + }, + }, + children: [], + }, + }, + }; + + render( + + + , + ); + + expect(screen.getByTestId("text").textContent).toBe( + "Hello, Alice! You have 3 messages.", + ); + }); + + it("resolves missing paths to empty string", () => { + const spec: Spec = { + state: {}, + root: "main", + elements: { + main: { + type: "Text", + props: { + text: { $template: "Hi ${/name}!" }, + }, + children: [], + }, + }, + }; + + render( + + + , + ); + + expect(screen.getByTestId("text").textContent).toBe("Hi !"); + }); +}); + +// ============================================================================= +// Watchers +// ============================================================================= + +describe("watchers (watch field)", () => { + it("does not fire on initial render, fires when watched state changes", async () => { + const loadCities = vi.fn(async (params: Record) => { + // no-op, just tracking the call + }); + + const spec: Spec = { + state: { form: { country: "" }, citiesLoaded: false }, + root: "main", + elements: { + main: { + type: "Button", + props: { label: "Set Country" }, + on: { + press: [ + { + action: "setState", + params: { statePath: "/form/country", value: "US" }, + }, + ], + }, + children: [], + }, + watcher: { + type: "Select", + props: { value: { $state: "/form/country" } }, + watch: { + "/form/country": { + action: "loadCities", + params: { country: { $state: "/form/country" } }, + }, + }, + children: [], + }, + }, + }; + + // Add watcher as a child of a wrapper so both render + const wrapperSpec: Spec = { + ...spec, + root: "wrapper", + elements: { + ...spec.elements, + wrapper: { + type: "Button", + props: { label: "wrapper" }, + children: ["main", "watcher"], + }, + }, + }; + + // Use a Stack-like wrapper -- but since we only have Button/Text/Select + // stubs, we need a container. Let's add a simple Stack stub. + const Stack = ({ + children, + }: ComponentRenderProps>) => { + return
{children}
; + }; + const reg = { ...registry, Stack }; + + const stackSpec: Spec = { + state: { form: { country: "" }, citiesLoaded: false }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["btn", "watcher"], + }, + btn: { + type: "Button", + props: { label: "Set Country" }, + on: { + press: [ + { + action: "setState", + params: { statePath: "/form/country", value: "US" }, + }, + ], + }, + children: [], + }, + watcher: { + type: "Select", + props: { value: { $state: "/form/country" } }, + watch: { + "/form/country": { + action: "loadCities", + params: { country: { $state: "/form/country" } }, + }, + }, + children: [], + }, + }, + }; + + render( + + + + , + ); + + // Not called on initial render + expect(loadCities).not.toHaveBeenCalled(); + + // Change the watched state path + await act(async () => { + fireEvent.click(screen.getByTestId("btn")); + }); + + expect(loadCities).toHaveBeenCalledTimes(1); + expect(loadCities).toHaveBeenCalledWith( + expect.objectContaining({ country: "US" }), + ); + }); + + it("fires multiple action bindings on the same watch path", async () => { + const action1 = vi.fn(); + const action2 = vi.fn(); + + const Stack = ({ + children, + }: ComponentRenderProps>) =>
{children}
; + const reg = { ...registry, Stack }; + + const spec: Spec = { + state: { value: "a" }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["btn", "watcher"], + }, + btn: { + type: "Button", + props: { label: "Change" }, + on: { + press: [ + { + action: "setState", + params: { statePath: "/value", value: "b" }, + }, + ], + }, + children: [], + }, + watcher: { + type: "Text", + props: { text: { $state: "/value" } }, + watch: { + "/value": [{ action: "action1" }, { action: "action2" }], + }, + children: [], + }, + }, + }; + + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("btn")); + }); + + expect(action1).toHaveBeenCalledTimes(1); + expect(action2).toHaveBeenCalledTimes(1); + }); +}); + +// ============================================================================= +// validateForm action +// ============================================================================= + +describe("validateForm action", () => { + it("writes { valid: false } when a required field is empty", async () => { + const Stack = ({ + children, + }: ComponentRenderProps>) =>
{children}
; + const reg = { ...registry, Stack }; + + const spec: Spec = { + state: { form: { email: "" }, result: null }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["emailInput", "submitBtn"], + }, + emailInput: { + type: "Input", + props: { + label: "Email", + value: { $bindState: "/form/email" }, + checks: [{ type: "required", message: "Email is required" }], + }, + children: [], + }, + submitBtn: { + type: "Button", + props: { label: "Submit" }, + on: { + press: [ + { + action: "validateForm", + params: { statePath: "/result" }, + }, + ], + }, + children: [], + }, + }, + }; + + render( + + + + , + ); + + // Click submit with empty email + await act(async () => { + fireEvent.click(screen.getByTestId("btn")); + }); + + const state = getState(); + expect(state.result).toEqual({ valid: false }); + }); + + it("writes { valid: true } when all fields pass validation", async () => { + const Stack = ({ + children, + }: ComponentRenderProps>) =>
{children}
; + const reg = { ...registry, Stack }; + + const spec: Spec = { + state: { form: { email: "test@example.com" }, result: null }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["emailInput", "submitBtn"], + }, + emailInput: { + type: "Input", + props: { + label: "Email", + value: { $bindState: "/form/email" }, + checks: [{ type: "required", message: "Email is required" }], + }, + children: [], + }, + submitBtn: { + type: "Button", + props: { label: "Submit" }, + on: { + press: [ + { + action: "validateForm", + params: { statePath: "/result" }, + }, + ], + }, + children: [], + }, + }, + }; + + render( + + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("btn")); + }); + + const state = getState(); + expect(state.result).toEqual({ valid: true }); + }); + + it("defaults to /formValidation when no statePath is provided", async () => { + const Stack = ({ + children, + }: ComponentRenderProps>) =>
{children}
; + const reg = { ...registry, Stack }; + + const spec: Spec = { + state: { form: { name: "filled" } }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["nameInput", "submitBtn"], + }, + nameInput: { + type: "Input", + props: { + label: "Name", + value: { $bindState: "/form/name" }, + checks: [{ type: "required", message: "Required" }], + }, + children: [], + }, + submitBtn: { + type: "Button", + props: { label: "Submit" }, + on: { + press: [{ action: "validateForm" }], + }, + children: [], + }, + }, + }; + + render( + + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByTestId("btn")); + }); + + const state = getState(); + expect(state.formValidation).toEqual({ valid: true }); + }); +}); + +// ============================================================================= +// Select validate-on-change timing (#151) +// ============================================================================= + +describe("Select validate-on-change sees the new value, not the stale value", () => { + const Stack = ({ + children, + }: ComponentRenderProps>) =>
{children}
; + + const regWithSelect = { + ...registry, + Stack, + Select: ValidatedSelect, + }; + + it("does not show 'required' error when selecting the first value", async () => { + const spec: Spec = { + state: { form: { country: "" } }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["countrySelect"], + }, + countrySelect: { + type: "Select", + props: { + label: "Country", + name: "country", + options: ["US", "Canada", "UK"], + placeholder: "Choose a country", + value: { $bindState: "/form/country" }, + checks: [{ type: "required", message: "Country is required" }], + validateOn: "change", + }, + children: [], + }, + }, + }; + + render( + + + + , + ); + + // Select "US" for the first time (from empty) + await act(async () => { + fireEvent.change(screen.getByTestId("select-country"), { + target: { value: "US" }, + }); + }); + + // The value should be set in state + const state = getState(); + expect((state.form as Record).country).toBe("US"); + + // No validation error should appear -- "US" is non-empty + expect(screen.queryByTestId("select-error-country")).toBeNull(); + }); + + it("does not show 'required' error when selecting the first city after country change resets it", async () => { + const spec: Spec = { + state: { + form: { country: "US", city: "" }, + availableCities: ["New York", "Chicago"], + }, + root: "wrapper", + elements: { + wrapper: { + type: "Stack", + props: {}, + children: ["citySelect"], + }, + citySelect: { + type: "Select", + props: { + label: "City", + name: "city", + options: ["New York", "Chicago"], + placeholder: "Select a city", + value: { $bindState: "/form/city" }, + checks: [{ type: "required", message: "City is required" }], + validateOn: "change", + }, + children: [], + }, + }, + }; + + render( + + + + , + ); + + // Select "New York" for the first time (from empty) + await act(async () => { + fireEvent.change(screen.getByTestId("select-city"), { + target: { value: "New York" }, + }); + }); + + const state = getState(); + expect((state.form as Record).city).toBe("New York"); + + // No validation error should appear + expect(screen.queryByTestId("select-error-city")).toBeNull(); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 47d9b12..2e40c44 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,7 @@ export { export { ValidationProvider, useValidation, + useOptionalValidation, useFieldValidation, type ValidationContextValue, type ValidationProviderProps, diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index a397c2c..1ff7faa 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -5,7 +5,9 @@ import React, { type ErrorInfo, type ReactNode, useCallback, + useEffect, useMemo, + useRef, } from "react"; import type { UIElement, @@ -14,6 +16,7 @@ import type { Catalog, SchemaDefinition, StateStore, + ComputedFunction, } from "@json-render/core"; import { resolveElementProps, @@ -136,6 +139,19 @@ class ElementErrorBoundary extends React.Component< } } +// --------------------------------------------------------------------------- +// FunctionsContext – provides $computed functions to the element tree +// --------------------------------------------------------------------------- + +const EMPTY_FUNCTIONS: Record = {}; + +const FunctionsContext = + React.createContext>(EMPTY_FUNCTIONS); + +function useFunctions(): Record { + return React.useContext(FunctionsContext); +} + interface ElementRendererProps { element: UIElement; spec: Spec; @@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({ const repeatScope = useRepeatScope(); const { ctx } = useVisibility(); const { execute } = useActions(); - const { getSnapshot } = useStateStore(); + const { getSnapshot, state: watchState } = useStateStore(); + const functions = useFunctions(); - // Build context with repeat scope (used for both visibility and props) - const fullCtx: PropResolutionContext = useMemo( - () => - repeatScope - ? { - ...ctx, - repeatItem: repeatScope.item, - repeatIndex: repeatScope.index, - repeatBasePath: repeatScope.basePath, - } - : ctx, - [ctx, repeatScope], - ); + // Build context with repeat scope and $computed functions + const fullCtx: PropResolutionContext = useMemo(() => { + const base: PropResolutionContext = repeatScope + ? { + ...ctx, + repeatItem: repeatScope.item, + repeatIndex: repeatScope.index, + repeatBasePath: repeatScope.basePath, + } + : { ...ctx }; + base.functions = functions; + return base; + }, [ctx, repeatScope, functions]); // Evaluate visibility (now supports $item/$index inside repeat scopes) const isVisible = @@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({ [onBindings, emit], ); + // Watch effect: fire actions when watched state paths change. + // Must be called before any early return to satisfy Rules of Hooks. + // + // Two refs serve distinct roles: + // - `stableWatchRef` (useMemo): holds the last emitted values object so we + // can return the same reference when watched values haven't changed, + // preventing the downstream useEffect from firing on unrelated state updates. + // - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot + // for change detection. Starts as `null` to skip the initial mount. + const watchConfig = element.watch; + const prevWatchValues = useRef | null>(null); + const stableWatchRef = useRef | undefined>(undefined); + + const watchedValues = useMemo(() => { + if (!watchConfig) return undefined; + const values: Record = {}; + for (const path of Object.keys(watchConfig)) { + values[path] = getByPath(watchState, path); + } + const prev = stableWatchRef.current; + if (prev) { + const keys = Object.keys(values); + if ( + keys.length === Object.keys(prev).length && + keys.every((k) => values[k] === prev[k]) + ) { + return prev; + } + } + stableWatchRef.current = values; + return values; + }, [watchConfig, watchState]); + + useEffect(() => { + if (!watchConfig || !watchedValues) return; + const paths = Object.keys(watchConfig); + if (paths.length === 0) return; + + const prev = prevWatchValues.current; + prevWatchValues.current = watchedValues; + + // Skip the initial mount — only fire on changes + if (prev === null) return; + + let cancelled = false; + void (async () => { + for (const path of paths) { + if (cancelled) break; + if (watchedValues[path] !== prev[path]) { + const binding = watchConfig[path]; + if (!binding) continue; + const bindings = Array.isArray(binding) ? binding : [binding]; + for (const b of bindings) { + if (cancelled) break; + if (!b.params) { + await execute(b); + if (cancelled) break; + continue; + } + const liveCtx: PropResolutionContext = { + ...fullCtx, + stateModel: getSnapshot(), + }; + const resolved: Record = {}; + for (const [key, val] of Object.entries(b.params)) { + resolved[key] = resolveActionParam(val, liveCtx); + } + await execute({ ...b, params: resolved }); + if (cancelled) break; + } + } + } + })().catch(console.error); + + return () => { + cancelled = true; + }; + }, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]); + // Don't render if not visible if (!isVisible) { return null; @@ -419,6 +515,8 @@ export interface JSONUIProviderProps { string, (value: unknown, args?: Record) => boolean >; + /** Named functions for `$computed` expressions in props */ + functions?: Record; /** Callback when state changes (uncontrolled mode) */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; children: ReactNode; @@ -434,6 +532,7 @@ export function JSONUIProvider({ handlers, navigate, validationFunctions, + functions, onStateChange, children, }: JSONUIProviderProps) { @@ -444,12 +543,14 @@ export function JSONUIProvider({ onStateChange={onStateChange} > - - - {children} - - - + + + + {children} + + + + ); @@ -650,6 +751,8 @@ export interface CreateRendererProps { onAction?: (actionName: string, params?: Record) => void; /** Callback when state changes (uncontrolled mode) */ onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; + /** Named functions for `$computed` expressions in props */ + functions?: Record; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Fallback component for unknown types */ @@ -703,6 +806,7 @@ export function createRenderer< state, onAction, onStateChange, + functions, loading, fallback, }: CreateRendererProps) { @@ -730,17 +834,19 @@ export function createRenderer< onStateChange={onStateChange} > - - - - - - + + + + + + + + ); diff --git a/packages/react/src/schema.ts b/packages/react/src/schema.ts index 85c2fc3..41be808 100644 --- a/packages/react/src/schema.ts +++ b/packages/react/src/schema.ts @@ -67,6 +67,11 @@ export const schema = defineSchema( description: "Remove an item from an array in state by index. Params: { statePath: string, index: number }", }, + { + name: "validateForm", + description: + "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.", + }, ], defaultRules: [ // Element integrity diff --git a/packages/shadcn/src/catalog.ts b/packages/shadcn/src/catalog.ts index 7d59073..5119608 100644 --- a/packages/shadcn/src/catalog.ts +++ b/packages/shadcn/src/catalog.ts @@ -1,5 +1,21 @@ import { z } from "zod"; +// ============================================================================= +// Shared validation schemas used across form components +// ============================================================================= + +const validationCheckSchema = z + .array( + z.object({ + type: z.string(), + message: z.string(), + args: z.record(z.string(), z.unknown()).optional(), + }), + ) + .nullable(); + +const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable(); + // ============================================================================= // shadcn/ui Component Definitions // ============================================================================= @@ -272,19 +288,12 @@ export const shadcnComponentDefinitions = { type: z.enum(["text", "email", "password", "number"]).nullable(), placeholder: z.string().nullable(), value: z.string().nullable(), - checks: z - .array( - z.object({ - type: z.string(), - message: z.string(), - args: z.record(z.string(), z.unknown()).optional(), - }), - ) - .nullable(), + checks: validationCheckSchema, + validateOn: validateOnSchema, }), events: ["submit", "focus", "blur"], description: - "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).", + "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).", example: { label: "Email", name: "email", @@ -300,18 +309,11 @@ export const shadcnComponentDefinitions = { placeholder: z.string().nullable(), rows: z.number().nullable(), value: z.string().nullable(), - checks: z - .array( - z.object({ - type: z.string(), - message: z.string(), - args: z.record(z.string(), z.unknown()).optional(), - }), - ) - .nullable(), + checks: validationCheckSchema, + validateOn: validateOnSchema, }), description: - "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.", + "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).", }, Select: { @@ -321,19 +323,12 @@ export const shadcnComponentDefinitions = { options: z.array(z.string()), placeholder: z.string().nullable(), value: z.string().nullable(), - checks: z - .array( - z.object({ - type: z.string(), - message: z.string(), - args: z.record(z.string(), z.unknown()).optional(), - }), - ) - .nullable(), + checks: validationCheckSchema, + validateOn: validateOnSchema, }), events: ["change"], description: - "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.", + "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).", }, Checkbox: { @@ -341,9 +336,12 @@ export const shadcnComponentDefinitions = { label: z.string(), name: z.string(), checked: z.boolean().nullable(), + checks: validationCheckSchema, + validateOn: validateOnSchema, }), events: ["change"], - description: "Checkbox input. Use { $bindState } on checked for binding.", + description: + "Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).", }, Radio: { @@ -352,9 +350,12 @@ export const shadcnComponentDefinitions = { name: z.string(), options: z.array(z.string()), value: z.string().nullable(), + checks: validationCheckSchema, + validateOn: validateOnSchema, }), events: ["change"], - description: "Radio button group. Use { $bindState } on value for binding.", + description: + "Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).", }, Switch: { @@ -362,9 +363,12 @@ export const shadcnComponentDefinitions = { label: z.string(), name: z.string(), checked: z.boolean().nullable(), + checks: validationCheckSchema, + validateOn: validateOnSchema, }), events: ["change"], - description: "Toggle switch. Use { $bindState } on checked for binding.", + description: + "Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).", }, Slider: { diff --git a/packages/shadcn/src/components.tsx b/packages/shadcn/src/components.tsx index 666ca5f..6f85f9b 100644 --- a/packages/shadcn/src/components.tsx +++ b/packages/shadcn/src/components.tsx @@ -664,11 +664,12 @@ export const shadcnComponents = { const isBound = !!bindings?.value; const value = isBound ? (boundValue ?? "") : localValue; const setValue = isBound ? setBoundValue : setLocalValue; + const validateOn = props.validateOn ?? "blur"; const hasValidation = !!(bindings?.value && props.checks?.length); const { errors, validate } = useFieldValidation( bindings?.value ?? "", - hasValidation ? { checks: props.checks ?? [] } : undefined, + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, ); return ( @@ -682,13 +683,16 @@ export const shadcnComponents = { type={props.type ?? "text"} placeholder={props.placeholder ?? ""} value={value} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + if (hasValidation && validateOn === "change") validate(); + }} onKeyDown={(e) => { if (e.key === "Enter") emit("submit"); }} onFocus={() => emit("focus")} onBlur={() => { - if (hasValidation) validate(); + if (hasValidation && validateOn === "blur") validate(); emit("blur"); }} /> @@ -711,11 +715,12 @@ export const shadcnComponents = { const isBound = !!bindings?.value; const value = isBound ? (boundValue ?? "") : localValue; const setValue = isBound ? setBoundValue : setLocalValue; + const validateOn = props.validateOn ?? "blur"; const hasValidation = !!(bindings?.value && props.checks?.length); const { errors, validate } = useFieldValidation( bindings?.value ?? "", - hasValidation ? { checks: props.checks ?? [] } : undefined, + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, ); return ( @@ -729,9 +734,12 @@ export const shadcnComponents = { placeholder={props.placeholder ?? ""} rows={props.rows ?? 3} value={value} - onChange={(e) => setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + if (hasValidation && validateOn === "change") validate(); + }} onBlur={() => { - if (hasValidation) validate(); + if (hasValidation && validateOn === "blur") validate(); }} /> {errors.length > 0 && ( @@ -758,11 +766,12 @@ export const shadcnComponents = { const options = rawOptions.map((opt) => typeof opt === "string" ? opt : String(opt ?? ""), ); + const validateOn = props.validateOn ?? "change"; const hasValidation = !!(bindings?.value && props.checks?.length); const { errors, validate } = useFieldValidation( bindings?.value ?? "", - hasValidation ? { checks: props.checks ?? [] } : undefined, + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, ); return ( @@ -772,7 +781,8 @@ export const shadcnComponents = { value={value} onValueChange={(v) => { setValue(v); - if (hasValidation) validate(); + // Select has no native blur event, so only validate on "change" + if (hasValidation && validateOn === "change") validate(); emit("change"); }} > @@ -808,19 +818,32 @@ export const shadcnComponents = { const checked = isBound ? (boundChecked ?? false) : localChecked; const setChecked = isBound ? setBoundChecked : setLocalChecked; + const validateOn = props.validateOn ?? "change"; + const hasValidation = !!(bindings?.checked && props.checks?.length); + const { errors, validate } = useFieldValidation( + bindings?.checked ?? "", + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, + ); + return ( -
- { - setChecked(c === true); - emit("change"); - }} - /> - +
+
+ { + setChecked(c === true); + if (hasValidation && validateOn === "change") validate(); + emit("change"); + }} + /> + +
+ {errors.length > 0 && ( +

{errors[0]}

+ )}
); }, @@ -843,6 +866,13 @@ export const shadcnComponents = { const value = isBound ? (boundValue ?? "") : localValue; const setValue = isBound ? setBoundValue : setLocalValue; + const validateOn = props.validateOn ?? "change"; + const hasValidation = !!(bindings?.value && props.checks?.length); + const { errors, validate } = useFieldValidation( + bindings?.value ?? "", + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, + ); + return (
{props.label && } @@ -850,6 +880,7 @@ export const shadcnComponents = { value={value} onValueChange={(v) => { setValue(v); + if (hasValidation && validateOn === "change") validate(); emit("change"); }} > @@ -868,6 +899,9 @@ export const shadcnComponents = {
))} + {errors.length > 0 && ( +

{errors[0]}

+ )}
); }, @@ -886,19 +920,32 @@ export const shadcnComponents = { const checked = isBound ? (boundChecked ?? false) : localChecked; const setChecked = isBound ? setBoundChecked : setLocalChecked; + const validateOn = props.validateOn ?? "change"; + const hasValidation = !!(bindings?.checked && props.checks?.length); + const { errors, validate } = useFieldValidation( + bindings?.checked ?? "", + hasValidation ? { checks: props.checks ?? [], validateOn } : undefined, + ); + return ( -
- - { - setChecked(c); - emit("change"); - }} - /> +
+
+ + { + setChecked(c); + if (hasValidation && validateOn === "change") validate(); + emit("change"); + }} + /> +
+ {errors.length > 0 && ( +

{errors[0]}

+ )}
); },