mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 20:38:21 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b397510e | ||
|
|
8506cfaa03 | ||
|
|
9cef4e9142 | ||
|
|
3c11f19be4 |
@@ -13,7 +13,9 @@
|
||||
"@json-render/codegen",
|
||||
"@json-render/zustand",
|
||||
"@json-render/redux",
|
||||
"@json-render/jotai"
|
||||
"@json-render/jotai",
|
||||
"@json-render/vue",
|
||||
"@json-render/xstate"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -121,7 +121,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` |
|
||||
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
@@ -342,10 +342,12 @@ Any prop value can be data-driven using expressions:
|
||||
}
|
||||
```
|
||||
|
||||
Two expression forms:
|
||||
Expression forms:
|
||||
|
||||
- **`{ "$state": "/state/key" }`** - reads a value from the state model
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition and picks a branch
|
||||
- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings
|
||||
- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args
|
||||
|
||||
### Actions
|
||||
|
||||
@@ -361,6 +363,22 @@ Components can trigger actions, including the built-in `setState` action:
|
||||
|
||||
The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
|
||||
|
||||
### State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
|
||||
"watch": {
|
||||
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
@@ -376,6 +394,8 @@ pnpm dev
|
||||
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
|
||||
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
|
||||
- Chat Example: run `pnpm dev` in `examples/chat`
|
||||
- Vue Example: run `pnpm dev` in `examples/vue`
|
||||
- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers`
|
||||
- React Native example: run `npx expo start` in `examples/react-native`
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# web
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/codegen@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -109,6 +109,27 @@ const { registry } = defineRegistry(catalog, {
|
||||
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||
```
|
||||
|
||||
### JSONUIProvider
|
||||
|
||||
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `functions` | `Record<string, ComputedFunction>` | Named functions for `$computed` expressions in props |
|
||||
|
||||
```tsx
|
||||
<JSONUIProvider
|
||||
spec={spec}
|
||||
catalog={catalog}
|
||||
handlers={{ submit: async () => { /* ... */ } }}
|
||||
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
The `functions` prop is also available on `createRenderer`.
|
||||
|
||||
### Component Props (via defineRegistry)
|
||||
|
||||
```tsx
|
||||
@@ -237,6 +258,15 @@ const {
|
||||
|
||||
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||
|
||||
### useOptionalValidation
|
||||
|
||||
Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context.
|
||||
|
||||
```typescript
|
||||
const validation = useOptionalValidation();
|
||||
// ValidationContextValue | null
|
||||
```
|
||||
|
||||
### useBoundProp
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
||||
|
||||
@@ -241,6 +241,6 @@ const {
|
||||
| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
|
||||
| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
|
||||
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
|
||||
| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
|
||||
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
|
||||
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
|
||||
| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |
|
||||
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
|
||||
|
||||
@@ -5,6 +5,119 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.10.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: `@json-render/vue`
|
||||
|
||||
Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/vue
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { h } from "vue";
|
||||
import { defineRegistry, Renderer } from "@json-render/vue";
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [h("h3", null, props.title), children]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
|
||||
|
||||
See the [Vue API reference](/docs/api/vue) for details.
|
||||
|
||||
### New: `@json-render/xstate`
|
||||
|
||||
[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
|
||||
|
||||
```bash
|
||||
npm install @json-render/xstate @xstate/store
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { createAtom } from "@xstate/store";
|
||||
import { xstateStoreStateStore } from "@json-render/xstate";
|
||||
|
||||
const atom = createAtom({ count: 0 });
|
||||
const store = xstateStoreStateStore({ atom });
|
||||
```
|
||||
|
||||
Requires `@xstate/store` v3+.
|
||||
|
||||
### New: `$computed` and `$template` Expressions
|
||||
|
||||
Two new prop expression types for dynamic values:
|
||||
|
||||
- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
|
||||
- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
|
||||
|
||||
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
|
||||
"watch": {
|
||||
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
|
||||
|
||||
### New: Cross-Field Validation
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
|
||||
- `equalTo` -- alias for `matches` with clearer semantics
|
||||
- `lessThan` -- value must be less than another field
|
||||
- `greaterThan` -- value must be greater than another field
|
||||
- `requiredIf` -- required only when a condition field is truthy
|
||||
|
||||
Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
|
||||
|
||||
### New: `validateForm` Action
|
||||
|
||||
Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
|
||||
|
||||
```json
|
||||
{
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support `checks` and `validateOn` props:
|
||||
- Checkbox, Radio, Switch added validation support
|
||||
- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
|
||||
|
||||
### New Examples
|
||||
|
||||
- **Vue example** -- standalone Vue 3 app with custom components
|
||||
- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
|
||||
|
||||
---
|
||||
|
||||
## v0.9.1
|
||||
|
||||
February 2026
|
||||
|
||||
@@ -47,6 +47,8 @@ If you want to wire json-render to an existing state management library instead
|
||||
|
||||
<PackageInstall packages="@json-render/jotai" />
|
||||
|
||||
<PackageInstall packages="@json-render/xstate" />
|
||||
|
||||
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
||||
|
||||
## For AI Integration
|
||||
|
||||
@@ -23,7 +23,9 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/streaming": "Streaming",
|
||||
"docs/validation": "Validation",
|
||||
"docs/data-binding": "Data Binding",
|
||||
"docs/computed-values": "Computed Values",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/watchers": "Watchers",
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-chat
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/shadcn@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/codegen@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-no-ai
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/shadcn@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-native
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react-native@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-pdf
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react-pdf@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-remotion
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/remotion@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-demo
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-fullpage-demo
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# vite-renderers
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/vue@0.10.0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vite-renderers",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless vite-renderers.json-render vite",
|
||||
|
||||
@@ -96,7 +96,7 @@ export const demoSpec: Spec = {
|
||||
// ---- Milestone badge (visible only when count >= 10) ----
|
||||
"milestone-badge": {
|
||||
type: "Badge",
|
||||
props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
|
||||
props: { label: "Milestone reached: 10!", color: "#10b981" },
|
||||
visible: { $state: "/count", gte: 10 },
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# example-vue
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/vue@0.10.0
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue",
|
||||
"version": "0.1.0",
|
||||
"name": "example-vue",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless vue.json-render vite",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Spec } from "@json-render/vue";
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export const demoSpec: Spec = {
|
||||
root: "root",
|
||||
@@ -78,7 +78,7 @@ export const demoSpec: Spec = {
|
||||
// ---- Milestone badge (visible only when count >= 10) ----
|
||||
"milestone-badge": {
|
||||
type: "Badge",
|
||||
props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
|
||||
props: { label: "Milestone reached: 10!", color: "#10b981" },
|
||||
visible: { $state: "/count", gte: 10 },
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,76 @@
|
||||
# @json-render/core
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
@@ -188,6 +188,22 @@ Schema options:
|
||||
| `resolvePropValue(value, ctx)` | Resolve a single prop expression |
|
||||
| `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element |
|
||||
| `PropExpression<T>` | Type for prop values that may contain expressions |
|
||||
| `ComputedFunction` | Function signature for `$computed` expressions |
|
||||
| `PropResolutionContext` | Context for resolving props (includes `functions` for `$computed`) |
|
||||
|
||||
### Validation
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `check.required()` | Required validation helper |
|
||||
| `check.email()` | Email validation helper |
|
||||
| `check.matches(path)` | Cross-field match helper |
|
||||
| `check.equalTo(path)` | Cross-field equality helper |
|
||||
| `check.lessThan(path)` | Cross-field less-than helper |
|
||||
| `check.greaterThan(path)` | Cross-field greater-than helper |
|
||||
| `check.requiredIf(path)` | Conditional required helper |
|
||||
| `builtInValidationFunctions` | All built-in validation functions |
|
||||
| `runValidationCheck()` | Run a single validation check |
|
||||
|
||||
### User Prompt
|
||||
|
||||
@@ -285,6 +301,7 @@ The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@j
|
||||
| `Spec` | Base spec type |
|
||||
| `Catalog` | Catalog type |
|
||||
| `BuiltInAction` | Built-in action type (`name` + `description`) |
|
||||
| `ComputedFunction` | Function signature for `$computed` expressions |
|
||||
| `VisibilityCondition` | Visibility condition type (used by `$cond`) |
|
||||
| `VisibilityContext` | Context for evaluating visibility and prop expressions |
|
||||
| `SpecStreamLine` | Single patch operation |
|
||||
@@ -372,6 +389,44 @@ Get the current array index inside a repeat:
|
||||
|
||||
`$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path).
|
||||
|
||||
### Template (`$template`)
|
||||
|
||||
Interpolate state values into strings using `${/path}` syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
|
||||
}
|
||||
```
|
||||
|
||||
Missing paths resolve to an empty string.
|
||||
|
||||
### Computed (`$computed`)
|
||||
|
||||
Call a registered function with resolved arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Functions are registered in the catalog and provided at runtime via the `functions` prop on the renderer.
|
||||
|
||||
```typescript
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
|
||||
const functions: Record<string, ComputedFunction> = {
|
||||
fullName: (args) => `${args.first} ${args.last}`,
|
||||
};
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```typescript
|
||||
@@ -466,6 +521,65 @@ console.log(formatSpecIssues(issues));
|
||||
const fixed = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
## State Watchers
|
||||
|
||||
Elements can declare a `watch` field to trigger actions when state values change. `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": []
|
||||
}
|
||||
```
|
||||
|
||||
Watchers only fire on value changes, not on initial render. Multiple action bindings per path execute sequentially.
|
||||
|
||||
## Validation
|
||||
|
||||
### Built-in Validation Functions
|
||||
|
||||
| Function | Description | Args |
|
||||
|----------|-------------|------|
|
||||
| `required` | Value must not be empty | — |
|
||||
| `email` | Must be a valid email | — |
|
||||
| `url` | Must be a valid URL | — |
|
||||
| `numeric` | Must be a number | — |
|
||||
| `minLength` | Minimum string length | `{ min: number }` |
|
||||
| `maxLength` | Maximum string length | `{ max: number }` |
|
||||
| `min` | Minimum numeric value | `{ min: number }` |
|
||||
| `max` | Maximum numeric value | `{ max: number }` |
|
||||
| `pattern` | Must match regex | `{ pattern: string }` |
|
||||
| `matches` | Must equal another field | `{ other: { $state: "/path" } }` |
|
||||
| `equalTo` | Alias for matches | `{ other: { $state: "/path" } }` |
|
||||
| `lessThan` | Must be less than another field | `{ other: { $state: "/path" } }` |
|
||||
| `greaterThan` | Must be greater than another field | `{ other: { $state: "/path" } }` |
|
||||
| `requiredIf` | Required when condition is truthy | `{ field: { $state: "/path" } }` |
|
||||
|
||||
### TypeScript Helpers
|
||||
|
||||
```typescript
|
||||
import { check } from "@json-render/core";
|
||||
|
||||
check.required("Field is required");
|
||||
check.email("Invalid email");
|
||||
check.matches("/form/password", "Passwords must match");
|
||||
check.equalTo("/form/password", "Passwords must match");
|
||||
check.lessThan("/form/endDate", "Must be before end date");
|
||||
check.greaterThan("/form/startDate", "Must be after start date");
|
||||
check.requiredIf("/form/enableNotifications", "Required when notifications enabled");
|
||||
```
|
||||
|
||||
## Custom Schemas
|
||||
|
||||
json-render supports completely different spec formats for different renderers:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/jotai
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-native
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-pdf
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @internal/react-state
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@internal/react-state",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Shared React state context for json-render renderer packages",
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
# @json-render/react
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -261,6 +261,7 @@ const { errors, validate } = useFieldValidation("/form/email", {
|
||||
| `useActions()` | Access action context |
|
||||
| `useAction(name)` | Get a single action dispatch function |
|
||||
| `useFieldValidation(path, config)` | Field validation state |
|
||||
| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) |
|
||||
| `useUIStream(options)` | Stream specs from an API endpoint |
|
||||
|
||||
## Visibility Conditions
|
||||
@@ -324,11 +325,60 @@ Any prop value can use data-driven expressions that resolve at render time. The
|
||||
|
||||
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
|
||||
|
||||
### `$template` and `$computed`
|
||||
|
||||
```json
|
||||
{
|
||||
"label": { "$template": "Hello, ${/user/name}!" },
|
||||
"fullName": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`:
|
||||
|
||||
```tsx
|
||||
<JSONUIProvider
|
||||
spec={spec}
|
||||
catalog={catalog}
|
||||
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
|
||||
>
|
||||
```
|
||||
|
||||
See [@json-render/core](../core/README.md) for full expression syntax.
|
||||
|
||||
## State Watchers
|
||||
|
||||
Elements can declare a `watch` field to trigger actions when state values change:
|
||||
|
||||
```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": []
|
||||
}
|
||||
```
|
||||
|
||||
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`), not inside `props`. Watchers only fire on value changes, not on initial render.
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
|
||||
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -344,6 +394,26 @@ The `setState`, `pushState`, and `removeState` actions are built into the React
|
||||
}
|
||||
```
|
||||
|
||||
### `validateForm`
|
||||
|
||||
Validate all registered form fields at once and write the result to state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Writes `{ valid: boolean, errors: Record<string, string[]> }` to the specified state path (defaults to `/formValidation`).
|
||||
|
||||
## Component Props
|
||||
|
||||
When using `defineRegistry`, components receive these props:
|
||||
@@ -451,7 +521,7 @@ function App() {
|
||||
|--------|---------|
|
||||
| `defineRegistry` | Create a type-safe component registry from a catalog |
|
||||
| `Renderer` | Render a spec using a registry |
|
||||
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) |
|
||||
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) |
|
||||
| `useStateStore` | Access state context |
|
||||
| `useStateValue` | Get single value from state |
|
||||
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -251,9 +251,15 @@ export function ActionProvider({
|
||||
return;
|
||||
}
|
||||
const valid = validateAll();
|
||||
const errors: Record<string, string[]> = {};
|
||||
for (const [path, fs] of Object.entries(validation.fieldStates)) {
|
||||
if (fs.result && !fs.result.valid) {
|
||||
errors[path] = fs.result.errors;
|
||||
}
|
||||
}
|
||||
const statePath =
|
||||
(resolved.params?.statePath as string) || "/formValidation";
|
||||
set(statePath, { valid });
|
||||
set(statePath, { valid, errors });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -134,6 +135,9 @@ export function ValidationProvider({
|
||||
const [fieldStates, setFieldStates] = useState<
|
||||
Record<string, FieldValidationState>
|
||||
>({});
|
||||
// Mutable mirror of fieldStates for synchronous reads (e.g. reading errors
|
||||
// immediately after validateAll() before React flushes the batched setState).
|
||||
const fieldStatesRef = useRef<Record<string, FieldValidationState>>({});
|
||||
const [fieldConfigs, setFieldConfigs] = useState<
|
||||
Record<string, ValidationConfig>
|
||||
>({});
|
||||
@@ -175,14 +179,16 @@ export function ValidationProvider({
|
||||
customFunctions,
|
||||
});
|
||||
|
||||
setFieldStates((prev) => ({
|
||||
...prev,
|
||||
[path]: {
|
||||
touched: prev[path]?.touched ?? true,
|
||||
validated: true,
|
||||
result,
|
||||
},
|
||||
}));
|
||||
const newFieldState: FieldValidationState = {
|
||||
touched: fieldStatesRef.current[path]?.touched ?? true,
|
||||
validated: true,
|
||||
result,
|
||||
};
|
||||
fieldStatesRef.current = {
|
||||
...fieldStatesRef.current,
|
||||
[path]: newFieldState,
|
||||
};
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
|
||||
return result;
|
||||
},
|
||||
@@ -190,22 +196,22 @@ export function ValidationProvider({
|
||||
);
|
||||
|
||||
const touch = useCallback((path: string) => {
|
||||
setFieldStates((prev) => ({
|
||||
...prev,
|
||||
fieldStatesRef.current = {
|
||||
...fieldStatesRef.current,
|
||||
[path]: {
|
||||
...prev[path],
|
||||
...fieldStatesRef.current[path],
|
||||
touched: true,
|
||||
validated: prev[path]?.validated ?? false,
|
||||
result: prev[path]?.result ?? null,
|
||||
validated: fieldStatesRef.current[path]?.validated ?? false,
|
||||
result: fieldStatesRef.current[path]?.result ?? null,
|
||||
},
|
||||
}));
|
||||
};
|
||||
setFieldStates(fieldStatesRef.current);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((path: string) => {
|
||||
setFieldStates((prev) => {
|
||||
const { [path]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
const { [path]: _, ...rest } = fieldStatesRef.current;
|
||||
fieldStatesRef.current = rest;
|
||||
setFieldStates(rest);
|
||||
}, []);
|
||||
|
||||
const validateAll = useCallback(() => {
|
||||
@@ -224,7 +230,11 @@ export function ValidationProvider({
|
||||
const value = useMemo<ValidationContextValue>(
|
||||
() => ({
|
||||
customFunctions,
|
||||
fieldStates,
|
||||
// Getter returns the mutable ref so callers that read fieldStates
|
||||
// synchronously after validateAll() see the latest values.
|
||||
get fieldStates() {
|
||||
return fieldStatesRef.current;
|
||||
},
|
||||
validate,
|
||||
touch,
|
||||
clear,
|
||||
@@ -233,6 +243,8 @@ export function ValidationProvider({
|
||||
}),
|
||||
[
|
||||
customFunctions,
|
||||
// fieldStates (React state) stays in deps so the context value object
|
||||
// is recreated on re-render, triggering downstream consumers.
|
||||
fieldStates,
|
||||
validate,
|
||||
touch,
|
||||
|
||||
@@ -546,7 +546,10 @@ describe("validateForm action", () => {
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.result).toEqual({ valid: false });
|
||||
expect(state.result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/email": ["Email is required"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes { valid: true } when all fields pass validation", async () => {
|
||||
@@ -601,7 +604,7 @@ describe("validateForm action", () => {
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.result).toEqual({ valid: true });
|
||||
expect(state.result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("defaults to /formValidation when no statePath is provided", async () => {
|
||||
@@ -651,7 +654,7 @@ describe("validateForm action", () => {
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.formValidation).toEqual({ valid: true });
|
||||
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const schema = defineSchema(
|
||||
{
|
||||
name: "validateForm",
|
||||
description:
|
||||
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
|
||||
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
|
||||
},
|
||||
],
|
||||
defaultRules: [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/redux
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/remotion
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
# @json-render/shadcn
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -167,12 +167,12 @@ const { registry } = defineRegistry(catalog, {
|
||||
|-----------|-------------|
|
||||
| `Button` | Clickable button with variants |
|
||||
| `Link` | Anchor link |
|
||||
| `Input` | Text input with label and validation |
|
||||
| `Textarea` | Multi-line text input |
|
||||
| `Select` | Dropdown select |
|
||||
| `Checkbox` | Checkbox input |
|
||||
| `Radio` | Radio button group |
|
||||
| `Switch` | Toggle switch |
|
||||
| `Input` | Text input with label, validation, and `validateOn` timing |
|
||||
| `Textarea` | Multi-line text input with validation and `validateOn` |
|
||||
| `Select` | Dropdown select with validation and `validateOn` |
|
||||
| `Checkbox` | Checkbox input with validation and `validateOn` |
|
||||
| `Radio` | Radio button group with validation and `validateOn` |
|
||||
| `Switch` | Toggle switch with validation and `validateOn` |
|
||||
| `Slider` | Range slider |
|
||||
| `Toggle` | Toggle button |
|
||||
| `ToggleGroup` | Group of toggle buttons |
|
||||
@@ -180,13 +180,24 @@ const { registry } = defineRegistry(catalog, {
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
State actions (`setState`, `pushState`, `removeState`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
|
||||
State actions (`setState`, `pushState`, `removeState`, `validateForm`) are built into the `@json-render/react` schema and handled automatically by `ActionProvider`. They are included in prompts without needing to be declared in your catalog.
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `setState` | Set a value at a state path |
|
||||
| `pushState` | Push a value onto an array in state |
|
||||
| `removeState` | Remove an item from an array in state |
|
||||
| `validateForm` | Validate all fields and write result to state |
|
||||
|
||||
### Validation Timing (`validateOn`)
|
||||
|
||||
All form components support the `validateOn` prop to control when validation runs:
|
||||
|
||||
| Value | Description | Default For |
|
||||
|-------|-------------|-------------|
|
||||
| `"change"` | Validate on every input change | Select, Checkbox, Radio, Switch |
|
||||
| `"blur"` | Validate when field loses focus | Input, Textarea |
|
||||
| `"submit"` | Validate only on form submission | — |
|
||||
|
||||
## Exports
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# @json-render/vue
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
@@ -91,7 +91,7 @@ export const { registry } = defineRegistry(catalog, {
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** Vue does not have `useBoundProp`. For two-way binding, use `{ "$bindState": "/path" }` on the value prop and handle the `bindings` object in your component.
|
||||
> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
|
||||
|
||||
### 3. Render Specs
|
||||
|
||||
@@ -345,7 +345,7 @@ See [@json-render/core](../core/README.md) for full expression syntax.
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
The `setState`, `pushState`, and `removeState` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
|
||||
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -492,5 +492,5 @@ const spec = {
|
||||
|-----|-------|-----|------|
|
||||
| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
|
||||
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
|
||||
| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
|
||||
| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |
|
||||
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
|
||||
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/vue",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -18,6 +18,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.
|
||||
@@ -120,6 +121,7 @@ export const ActionProvider = defineComponent({
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const { get, set, getSnapshot } = useStateStore();
|
||||
const validation = useOptionalValidation();
|
||||
|
||||
const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
|
||||
const loadingActions = ref<Set<string>>(new Set());
|
||||
@@ -182,6 +184,29 @@ export const ActionProvider = defineComponent({
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: validateForm — triggers validateAll and writes result to state
|
||||
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 errors: Record<string, string[]> = {};
|
||||
for (const [path, fs] of Object.entries(validation.fieldStates)) {
|
||||
if (fs.result && !fs.result.valid) {
|
||||
errors[path] = fs.result.errors;
|
||||
}
|
||||
}
|
||||
const statePath =
|
||||
(resolved.params?.statePath as string) || "/formValidation";
|
||||
set(statePath, { valid, errors });
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: push (navigation)
|
||||
if (resolved.action === "push" && resolved.params) {
|
||||
const screen = resolved.params.screen as string;
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { StateProvider, useStateStore } from "./composables/state";
|
||||
import { VisibilityProvider } from "./composables/visibility";
|
||||
import { ActionProvider } from "./composables/actions";
|
||||
import { ValidationProvider } from "./composables/validation";
|
||||
import { useFieldValidation } from "./composables/validation";
|
||||
import { useBoundProp } from "./hooks";
|
||||
import {
|
||||
Renderer,
|
||||
JSONUIProvider,
|
||||
type ComponentRegistry,
|
||||
type ComponentRenderProps,
|
||||
} from "./renderer";
|
||||
|
||||
// =============================================================================
|
||||
// Stub components
|
||||
// =============================================================================
|
||||
|
||||
const Button = defineComponent({
|
||||
name: "Button",
|
||||
props: {
|
||||
element: { type: Object, required: true },
|
||||
emit: { type: Function, required: true },
|
||||
on: { type: Function, required: true },
|
||||
bindings: { type: Object, default: undefined },
|
||||
loading: { type: Boolean, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
"button",
|
||||
{ "data-testid": "btn", onClick: () => props.emit("press") },
|
||||
String((props.element as any).props?.label ?? ""),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const Text = defineComponent({
|
||||
name: "Text",
|
||||
props: {
|
||||
element: { type: Object, required: true },
|
||||
emit: { type: Function, required: true },
|
||||
on: { type: Function, required: true },
|
||||
bindings: { type: Object, default: undefined },
|
||||
loading: { type: Boolean, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
const value = (props.element as any).props?.text;
|
||||
return h(
|
||||
"span",
|
||||
{ "data-testid": "text" },
|
||||
value == null
|
||||
? ""
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value),
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const Stack = defineComponent({
|
||||
name: "Stack",
|
||||
props: {
|
||||
element: { type: Object, required: true },
|
||||
emit: { type: Function, required: true },
|
||||
on: { type: Function, required: true },
|
||||
bindings: { type: Object, default: undefined },
|
||||
loading: { type: Boolean, default: undefined },
|
||||
},
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", { "data-testid": "stack" }, slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
const InputField = defineComponent({
|
||||
name: "InputField",
|
||||
props: {
|
||||
element: { type: Object, required: true },
|
||||
emit: { type: Function, required: true },
|
||||
on: { type: Function, required: true },
|
||||
bindings: { type: Object, default: undefined },
|
||||
loading: { type: Boolean, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const elProps = (props.element as any).props ?? {};
|
||||
const bindingPath = (props.bindings as Record<string, string>)?.value;
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
elProps.value as string | undefined,
|
||||
bindingPath,
|
||||
);
|
||||
|
||||
const hasValidation = !!(bindingPath && elProps.checks?.length);
|
||||
const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined;
|
||||
const { errors } = useFieldValidation(bindingPath ?? "", config);
|
||||
|
||||
return () =>
|
||||
h("div", null, [
|
||||
elProps.label ? h("label", null, elProps.label) : null,
|
||||
h("input", {
|
||||
"data-testid": "input",
|
||||
value: boundValue ?? "",
|
||||
onInput: (e: Event) =>
|
||||
setBoundValue((e.target as HTMLInputElement).value),
|
||||
}),
|
||||
errors.value.length > 0
|
||||
? h("span", { "data-testid": "input-error" }, errors.value[0])
|
||||
: null,
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const Select = defineComponent({
|
||||
name: "Select",
|
||||
props: {
|
||||
element: { type: Object, required: true },
|
||||
emit: { type: Function, required: true },
|
||||
on: { type: Function, required: true },
|
||||
bindings: { type: Object, default: undefined },
|
||||
loading: { type: Boolean, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const elProps = (props.element as any).props ?? {};
|
||||
const bindingPath = (props.bindings as Record<string, string>)?.value;
|
||||
const [boundValue] = useBoundProp<string>(
|
||||
elProps.value as string | undefined,
|
||||
bindingPath,
|
||||
);
|
||||
return () => h("span", { "data-testid": "select-value" }, boundValue ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
const registry: ComponentRegistry = {
|
||||
Button: Button as unknown as Component,
|
||||
Text: Text as unknown as Component,
|
||||
Input: InputField as unknown as Component,
|
||||
Select: Select as unknown as Component,
|
||||
Stack: Stack as unknown as Component,
|
||||
};
|
||||
|
||||
// State probe to read state from tests
|
||||
const StateProbe = defineComponent({
|
||||
name: "StateProbe",
|
||||
setup() {
|
||||
const { state } = useStateStore();
|
||||
return () =>
|
||||
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value));
|
||||
},
|
||||
});
|
||||
|
||||
function getState(wrapper: ReturnType<typeof mount>): Record<string, unknown> {
|
||||
return JSON.parse(wrapper.find("[data-testid='state-probe']").text());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mount helper
|
||||
// =============================================================================
|
||||
|
||||
function mountWithProviders(
|
||||
spec: Spec,
|
||||
opts: {
|
||||
functions?: Record<string, (args: Record<string, unknown>) => unknown>;
|
||||
handlers?: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<void> | void
|
||||
>;
|
||||
initialState?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
return mount(JSONUIProvider as Component, {
|
||||
props: {
|
||||
registry,
|
||||
initialState: opts.initialState ?? spec.state ?? {},
|
||||
functions: opts.functions,
|
||||
handlers: opts.handlers,
|
||||
} as any,
|
||||
slots: {
|
||||
default: () => [h(Renderer, { spec, registry }), h(StateProbe)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// $computed expressions in rendering
|
||||
// =============================================================================
|
||||
|
||||
describe("$computed expressions in rendering", () => {
|
||||
it("resolves a $computed prop using provided functions", () => {
|
||||
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<string, unknown>) => `${args.first} ${args.last}`,
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec, { functions });
|
||||
expect(wrapper.find("[data-testid='text']").text()).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("renders gracefully when functions prop is omitted", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const spec: Spec = {
|
||||
state: {},
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: { $computed: "missing" },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec);
|
||||
expect(wrapper.find("[data-testid='text']").text()).toBe("");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Watchers
|
||||
// =============================================================================
|
||||
|
||||
describe("watchers (watch field)", () => {
|
||||
it("does not fire on initial render, fires when watched state changes", async () => {
|
||||
const loadCities = vi.fn();
|
||||
|
||||
const spec: 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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec, { handlers: { loadCities } });
|
||||
|
||||
expect(loadCities).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
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 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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec, {
|
||||
handlers: { action1, action2 },
|
||||
});
|
||||
|
||||
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(action1).toHaveBeenCalledTimes(1);
|
||||
expect(action2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// validateForm action
|
||||
// =============================================================================
|
||||
|
||||
describe("validateForm action", () => {
|
||||
it("writes { valid: false, errors } when a required field is empty", async () => {
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec);
|
||||
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||
await nextTick();
|
||||
|
||||
const state = getState(wrapper);
|
||||
expect(state.result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/email": ["Email is required"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes { valid: true, errors: {} } when all fields pass validation", async () => {
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec);
|
||||
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||
await nextTick();
|
||||
|
||||
const state = getState(wrapper);
|
||||
expect(state.result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("defaults to /formValidation when no statePath is provided", async () => {
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountWithProviders(spec);
|
||||
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||
await nextTick();
|
||||
|
||||
const state = getState(wrapper);
|
||||
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
});
|
||||
@@ -256,17 +256,16 @@ export function useUIStream({
|
||||
const usage = ref<TokenUsage | null>(null);
|
||||
const rawLines = ref<string[]>([]);
|
||||
|
||||
// Keep latest callbacks (Vue refs are always current — no stale closure issues)
|
||||
const onCompleteRef = ref(onComplete);
|
||||
onCompleteRef.value = onComplete;
|
||||
const onErrorRef = ref(onError);
|
||||
onErrorRef.value = onError;
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const clear = () => {
|
||||
spec.value = null;
|
||||
error.value = null;
|
||||
usage.value = null;
|
||||
rawLines.value = [];
|
||||
};
|
||||
|
||||
const send = async (
|
||||
@@ -670,11 +669,8 @@ export function useChatUI({
|
||||
const isStreaming = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
// Keep latest callbacks (Vue refs are always current — no stale closure issues)
|
||||
const onCompleteRef = ref(onComplete);
|
||||
onCompleteRef.value = onComplete;
|
||||
const onErrorRef = ref(onError);
|
||||
onErrorRef.value = onError;
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export {
|
||||
export { schema, type VueSchema, type VueSpec } from "./schema";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export type { Spec, StateStore, ComputedFunction } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
|
||||
// Catalog-aware types for Vue
|
||||
|
||||
@@ -56,9 +56,9 @@ function mountRenderer(
|
||||
default: () =>
|
||||
h(VisibilityProvider as Component, null, {
|
||||
default: () =>
|
||||
h(ActionProvider as Component, { handlers } as any, {
|
||||
h(ValidationProvider as Component, null, {
|
||||
default: () =>
|
||||
h(ValidationProvider as Component, null, {
|
||||
h(ActionProvider as Component, { handlers } as any, {
|
||||
default: () =>
|
||||
h(Renderer, { spec, registry: reg, ...extraProps }),
|
||||
}),
|
||||
|
||||
+182
-55
@@ -2,9 +2,13 @@ import {
|
||||
computed,
|
||||
defineComponent,
|
||||
h,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
provide,
|
||||
ref,
|
||||
watch,
|
||||
type Component,
|
||||
type ComputedRef,
|
||||
type PropType,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
@@ -13,6 +17,7 @@ import type {
|
||||
Spec,
|
||||
ActionBinding,
|
||||
Catalog,
|
||||
ComputedFunction,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
} from "@json-render/core";
|
||||
@@ -80,6 +85,35 @@ export interface RendererProps {
|
||||
fallback?: Component;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FunctionsContext — provides $computed functions to the element tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
|
||||
const FUNCTIONS_KEY = Symbol("json-render:functions");
|
||||
|
||||
const FunctionsProvider = defineComponent({
|
||||
name: "FunctionsProvider",
|
||||
props: {
|
||||
functions: {
|
||||
type: Object as PropType<Record<string, ComputedFunction>>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const fns = computed(() => props.functions ?? EMPTY_FUNCTIONS);
|
||||
provide(FUNCTIONS_KEY, fns);
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
function useFunctions(): ComputedRef<Record<string, ComputedFunction>> {
|
||||
return inject<ComputedRef<Record<string, ComputedFunction>>>(
|
||||
FUNCTIONS_KEY,
|
||||
computed(() => EMPTY_FUNCTIONS),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementErrorBoundary — catches rendering errors in individual elements
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -111,6 +145,37 @@ const ElementErrorBoundary = defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveAndExecuteBindings — shared helper for emitEvent / watch handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveAndExecuteBindings(
|
||||
actionBindings: ActionBinding[],
|
||||
ctx: PropResolutionContext,
|
||||
getSnapshot: () => Record<string, unknown>,
|
||||
execute: (binding: ActionBinding) => Promise<void>,
|
||||
cancelled?: () => boolean,
|
||||
): Promise<void> {
|
||||
for (const b of actionBindings) {
|
||||
if (cancelled?.()) break;
|
||||
if (!b.params) {
|
||||
await execute(b);
|
||||
if (cancelled?.()) break;
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...ctx,
|
||||
stateModel: getSnapshot(),
|
||||
};
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(b.params)) {
|
||||
resolved[key] = resolveActionParam(val, liveCtx);
|
||||
}
|
||||
await execute({ ...b, params: resolved });
|
||||
if (cancelled?.()) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementRenderer — renders a single element from the spec
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,19 +216,20 @@ const ElementRenderer = defineComponent({
|
||||
const repeatScope = useRepeatScope();
|
||||
const { ctx: visibilityCtx } = useVisibility();
|
||||
const { execute } = useActions();
|
||||
const { getSnapshot } = useStateStore();
|
||||
const { getSnapshot, state: watchState } = useStateStore();
|
||||
const functions = useFunctions();
|
||||
|
||||
// Build context with repeat scope
|
||||
// Build context with repeat scope and $computed functions
|
||||
const fullCtx = computed<PropResolutionContext>(() => {
|
||||
const base = visibilityCtx.value;
|
||||
if (repeatScope) {
|
||||
return {
|
||||
...base,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
};
|
||||
}
|
||||
const base: PropResolutionContext = repeatScope
|
||||
? {
|
||||
...visibilityCtx.value,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: { ...visibilityCtx.value };
|
||||
base.functions = functions.value;
|
||||
return base;
|
||||
});
|
||||
|
||||
@@ -172,21 +238,12 @@ const ElementRenderer = defineComponent({
|
||||
const binding = props.element.on?.[eventName];
|
||||
if (!binding) return;
|
||||
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
||||
for (const b of actionBindings) {
|
||||
if (!b.params) {
|
||||
await execute(b);
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...fullCtx.value,
|
||||
stateModel: getSnapshot(),
|
||||
};
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(b.params)) {
|
||||
resolved[key] = resolveActionParam(val, liveCtx);
|
||||
}
|
||||
await execute({ ...b, params: resolved });
|
||||
}
|
||||
await resolveAndExecuteBindings(
|
||||
actionBindings,
|
||||
fullCtx.value,
|
||||
getSnapshot,
|
||||
execute,
|
||||
);
|
||||
};
|
||||
|
||||
// Create on() function
|
||||
@@ -206,6 +263,49 @@ const ElementRenderer = defineComponent({
|
||||
};
|
||||
};
|
||||
|
||||
// Watch effect: fire actions when watched state paths change.
|
||||
const watchedValues = computed(() => {
|
||||
const cfg = props.element.watch;
|
||||
if (!cfg) return undefined;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const path of Object.keys(cfg)) {
|
||||
values[path] = getByPath(watchState.value, path);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
|
||||
watch(
|
||||
watchedValues,
|
||||
(current, prev, onCleanup) => {
|
||||
const cfg = props.element.watch;
|
||||
if (!cfg || !current) return;
|
||||
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
const paths = Object.keys(cfg);
|
||||
void (async () => {
|
||||
for (const path of paths) {
|
||||
if (cancelled) break;
|
||||
if (prev && current[path] === prev[path]) continue;
|
||||
const binding = cfg[path];
|
||||
if (!binding) continue;
|
||||
const bindings = Array.isArray(binding) ? binding : [binding];
|
||||
await resolveAndExecuteBindings(
|
||||
bindings,
|
||||
fullCtx.value,
|
||||
getSnapshot,
|
||||
execute,
|
||||
() => cancelled,
|
||||
);
|
||||
}
|
||||
})().catch(console.error);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
return () => {
|
||||
const ctx = fullCtx.value;
|
||||
|
||||
@@ -322,10 +422,11 @@ const RepeatChildren = defineComponent({
|
||||
const { state } = useStateStore();
|
||||
|
||||
return () => {
|
||||
const repeat = props.element.repeat!;
|
||||
const repeat = props.element.repeat;
|
||||
if (!repeat?.statePath) return null;
|
||||
const statePath = repeat.statePath;
|
||||
const items =
|
||||
(getByPath(state.value, statePath) as unknown[] | undefined) ?? [];
|
||||
const raw = getByPath(state.value, statePath);
|
||||
const items = Array.isArray(raw) ? (raw as unknown[]) : [];
|
||||
|
||||
return items.map((itemValue, index) => {
|
||||
const key =
|
||||
@@ -454,6 +555,8 @@ export interface JSONUIProviderProps {
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
/** Named functions for `$computed` expressions in props */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
}
|
||||
|
||||
@@ -497,6 +600,10 @@ export const JSONUIProvider = defineComponent({
|
||||
>,
|
||||
default: undefined,
|
||||
},
|
||||
functions: {
|
||||
type: Object as PropType<Record<string, ComputedFunction>>,
|
||||
default: undefined,
|
||||
},
|
||||
onStateChange: {
|
||||
type: Function as PropType<
|
||||
(changes: Array<{ path: string; value: unknown }>) => void
|
||||
@@ -518,18 +625,25 @@ export const JSONUIProvider = defineComponent({
|
||||
h(VisibilityProvider, null, {
|
||||
default: () =>
|
||||
h(
|
||||
ActionProvider,
|
||||
{ handlers: props.handlers, navigate: props.navigate },
|
||||
ValidationProvider,
|
||||
{ customFunctions: props.validationFunctions },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
ValidationProvider,
|
||||
{ customFunctions: props.validationFunctions },
|
||||
ActionProvider,
|
||||
{ handlers: props.handlers, navigate: props.navigate },
|
||||
{
|
||||
default: () => [
|
||||
slots.default?.(),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
default: () =>
|
||||
h(
|
||||
FunctionsProvider,
|
||||
{ functions: props.functions },
|
||||
{
|
||||
default: () => [
|
||||
slots.default?.(),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -704,6 +818,8 @@ export interface CreateRendererProps {
|
||||
state?: Record<string, unknown>;
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Named functions for `$computed` expressions in props */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
loading?: boolean;
|
||||
fallback?: Component;
|
||||
}
|
||||
@@ -768,6 +884,10 @@ export function createRenderer<
|
||||
>,
|
||||
default: undefined,
|
||||
},
|
||||
functions: {
|
||||
type: Object as PropType<Record<string, ComputedFunction>>,
|
||||
default: undefined,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
@@ -807,24 +927,31 @@ export function createRenderer<
|
||||
default: () =>
|
||||
h(VisibilityProvider, null, {
|
||||
default: () =>
|
||||
h(
|
||||
ActionProvider,
|
||||
{ handlers: actionHandlers },
|
||||
{
|
||||
default: () =>
|
||||
h(ValidationProvider, null, {
|
||||
default: () => [
|
||||
h(Renderer, {
|
||||
spec: rendererProps.spec,
|
||||
registry,
|
||||
loading: rendererProps.loading,
|
||||
fallback: rendererProps.fallback,
|
||||
}),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
}),
|
||||
},
|
||||
),
|
||||
h(ValidationProvider, null, {
|
||||
default: () =>
|
||||
h(
|
||||
ActionProvider,
|
||||
{ handlers: actionHandlers },
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
FunctionsProvider,
|
||||
{ functions: rendererProps.functions },
|
||||
{
|
||||
default: () => [
|
||||
h(Renderer, {
|
||||
spec: rendererProps.spec,
|
||||
registry,
|
||||
loading: rendererProps.loading,
|
||||
fallback: rendererProps.fallback,
|
||||
}),
|
||||
h(ConfirmationDialogManager),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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, errors: Record<string, string[]> }.",
|
||||
},
|
||||
],
|
||||
defaultRules: [
|
||||
// Element integrity
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# @json-render/xstate
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
|
||||
|
||||
### New: `@json-render/vue` Package
|
||||
|
||||
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
|
||||
- `defineRegistry` — create type-safe component registries from catalogs
|
||||
- `Renderer` — render specs as Vue component trees
|
||||
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
|
||||
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
|
||||
- Streaming: `useUIStream`, `useChatUI`
|
||||
- External store support via `StateStore` interface
|
||||
|
||||
### New: `@json-render/xstate` Package
|
||||
|
||||
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
|
||||
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
|
||||
- Requires `@xstate/store` v3+
|
||||
|
||||
### New: `$computed` Expressions
|
||||
|
||||
Call registered functions from prop expressions:
|
||||
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
|
||||
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
|
||||
- `ComputedFunction` type exported from `@json-render/core`
|
||||
|
||||
### New: `$template` Expressions
|
||||
|
||||
Interpolate state values into strings:
|
||||
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
|
||||
- Missing paths resolve to empty string
|
||||
|
||||
### New: State Watchers
|
||||
|
||||
React to state changes by triggering actions:
|
||||
- `watch` field on elements maps state paths to action bindings
|
||||
- Fires when watched values change (not on initial render)
|
||||
- Supports cascading dependencies (e.g. country → city loading)
|
||||
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
|
||||
- Spec validator detects and auto-fixes `watch` placed inside props
|
||||
|
||||
### New: Cross-Field Validation Functions
|
||||
|
||||
New built-in validation functions for cross-field comparisons:
|
||||
- `equalTo` — alias for `matches` with clearer semantics
|
||||
- `lessThan` — value must be less than another field (numbers, strings, coerced)
|
||||
- `greaterThan` — value must be greater than another field
|
||||
- `requiredIf` — required only when a condition field is truthy
|
||||
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
|
||||
|
||||
### New: `validateForm` Action (React)
|
||||
|
||||
Built-in action that validates all registered form fields at once:
|
||||
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
|
||||
- Default state path: `/formValidation` (configurable via `statePath` param)
|
||||
- Added to React schema's built-in actions list
|
||||
|
||||
### Improved: shadcn/ui Validation
|
||||
|
||||
All form components now support validation:
|
||||
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
|
||||
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
|
||||
- Shared validation schemas reduce catalog definition duplication
|
||||
|
||||
### Improved: React Provider Tree
|
||||
|
||||
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
@@ -1,11 +1,11 @@
|
||||
# @json-render/xstate-store
|
||||
# @json-render/xstate
|
||||
|
||||
[XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for json-render.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/xstate-store @json-render/core @json-render/react @xstate/store
|
||||
npm install @json-render/xstate @json-render/core @json-render/react @xstate/store
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
@@ -15,7 +15,7 @@ npm install @json-render/xstate-store @json-render/core @json-render/react @xsta
|
||||
|
||||
```ts
|
||||
import { createAtom } from "@xstate/store";
|
||||
import { xstateStoreStateStore } from "@json-render/xstate-store";
|
||||
import { xstateStoreStateStore } from "@json-render/xstate";
|
||||
import { StateProvider } from "@json-render/react";
|
||||
|
||||
// 1. Create an atom
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/xstate-store",
|
||||
"version": "0.0.0",
|
||||
"name": "@json-render/xstate",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "XState Store adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
@@ -13,7 +13,7 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/xstate-store"
|
||||
"directory": "packages/xstate"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||
"bugs": {
|
||||
@@ -18,7 +18,7 @@ export interface XstateStoreStateStoreOptions {
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createAtom } from "@xstate/store";
|
||||
* import { xstateStoreStateStore } from "@json-render/xstate-store";
|
||||
* import { xstateStoreStateStore } from "@json-render/xstate";
|
||||
*
|
||||
* const uiAtom = createAtom<Record<string, unknown>>({ count: 0 });
|
||||
*
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/zustand
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/zustand",
|
||||
"version": "0.9.1",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Zustand adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
Generated
+38
-38
@@ -448,7 +448,7 @@ importers:
|
||||
version: 0.563.0(react@19.2.4)
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -722,7 +722,7 @@ importers:
|
||||
version: 6.0.91(zod@4.3.6)
|
||||
next:
|
||||
specifier: ^16.1.6
|
||||
version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.2.4
|
||||
@@ -1212,6 +1212,9 @@ importers:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@internal/typescript-config':
|
||||
specifier: workspace:*
|
||||
@@ -1229,7 +1232,7 @@ importers:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.29(typescript@5.9.2)
|
||||
|
||||
packages/xstate-store:
|
||||
packages/xstate:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
@@ -1243,10 +1246,10 @@ importers:
|
||||
version: 3.15.0(react@19.2.4)
|
||||
tsup:
|
||||
specifier: ^8.0.2
|
||||
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
|
||||
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
version: 5.9.3
|
||||
|
||||
packages/zustand:
|
||||
dependencies:
|
||||
@@ -16571,7 +16574,7 @@ snapshots:
|
||||
'@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
|
||||
dependencies:
|
||||
'@types/jest': 28.1.8
|
||||
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
eslint: 8.57.1
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
@@ -16926,7 +16929,7 @@ snapshots:
|
||||
'@types/node': 22.19.6
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
|
||||
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
@@ -22103,32 +22106,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.14
|
||||
caniuse-lite: 1.0.30001764
|
||||
postcss: 8.4.31
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@opentelemetry/api': 1.9.0
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-abi@3.87.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
@@ -24149,11 +24126,6 @@ snapshots:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.4
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
@@ -24461,6 +24433,34 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
|
||||
dependencies:
|
||||
bundle-require: 5.1.0(esbuild@0.27.2)
|
||||
cac: 6.7.14
|
||||
chokidar: 4.0.3
|
||||
consola: 3.4.2
|
||||
debug: 4.4.3
|
||||
esbuild: 0.27.2
|
||||
fix-dts-default-cjs-exports: 1.0.1
|
||||
joycon: 3.1.1
|
||||
picocolors: 1.1.1
|
||||
postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
|
||||
resolve-from: 5.0.0
|
||||
rollup: 4.55.1
|
||||
source-map: 0.7.6
|
||||
sucrase: 3.35.1
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tree-kill: 1.2.2
|
||||
optionalDependencies:
|
||||
postcss: 8.5.6
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- supports-color
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
tsutils@3.21.0(typescript@4.9.5):
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
@@ -85,6 +85,8 @@ Any prop value can be a dynamic expression resolved at render time:
|
||||
- **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
|
||||
- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a visibility condition and picks a branch
|
||||
- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates `${/path}` references with state values
|
||||
- **`{ "$computed": "fnName", "args": { "key": <expression> } }`** - calls a registered function with resolved args
|
||||
|
||||
`$cond` uses the same syntax as visibility conditions (`$state`, `eq`, `neq`, `not`, arrays for AND). `$then` and `$else` can themselves be expressions (recursive).
|
||||
|
||||
@@ -96,6 +98,14 @@ Components do not use a `statePath` prop for two-way binding. Instead, use `{ "$
|
||||
"$cond": { "$state": "/activeTab", "eq": "home" },
|
||||
"$then": "#007AFF",
|
||||
"$else": "#8E8E93"
|
||||
},
|
||||
"label": { "$template": "Welcome, ${/user/name}!" },
|
||||
"fullName": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -106,6 +116,39 @@ import { resolvePropValue, resolveElementProps } from "@json-render/core";
|
||||
const resolved = resolveElementProps(element.props, { stateModel: myState });
|
||||
```
|
||||
|
||||
## State Watchers
|
||||
|
||||
Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
|
||||
"watch": {
|
||||
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Watchers only fire on value changes, not on initial render.
|
||||
|
||||
## Validation
|
||||
|
||||
Built-in validation functions: `required`, `email`, `url`, `numeric`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `matches`, `equalTo`, `lessThan`, `greaterThan`, `requiredIf`.
|
||||
|
||||
Cross-field validation uses `$state` expressions in args:
|
||||
|
||||
```typescript
|
||||
import { check } from "@json-render/core";
|
||||
|
||||
check.required("Field is required");
|
||||
check.matches("/form/password", "Passwords must match");
|
||||
check.lessThan("/form/endDate", "Must be before end date");
|
||||
check.greaterThan("/form/startDate", "Must be after start date");
|
||||
check.requiredIf("/form/enableNotifications", "Required when enabled");
|
||||
```
|
||||
|
||||
## User Prompt Builder
|
||||
|
||||
Build structured user prompts with optional spec refinement and state context:
|
||||
@@ -208,5 +251,7 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
|
||||
| `parseSpecStreamLine` | Parse single JSONL line |
|
||||
| `applySpecStreamPatch` | Apply patch to object |
|
||||
| `StateStore` | Interface for plugging in external state management |
|
||||
| `ComputedFunction` | Function signature for `$computed` expressions |
|
||||
| `check` | TypeScript helpers for creating validation checks |
|
||||
| `BuiltInAction` | Type for built-in action definitions (`name` + `description`) |
|
||||
| `ActionBinding` | Action binding type (includes `preventDefault` field) |
|
||||
|
||||
@@ -119,6 +119,8 @@ Any prop value can be a data-driven expression resolved by the renderer before c
|
||||
- **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
|
||||
- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
|
||||
- **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings
|
||||
- **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -134,6 +136,14 @@ Components do not use a `statePath` prop for two-way binding. Use `{ "$bindState
|
||||
|
||||
Components receive already-resolved props. For two-way bound props, use the `useBoundProp` hook with the `bindings` map the renderer provides.
|
||||
|
||||
Register `$computed` functions via the `functions` prop on `JSONUIProvider` or `createRenderer`:
|
||||
|
||||
```tsx
|
||||
<JSONUIProvider
|
||||
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
|
||||
>
|
||||
```
|
||||
|
||||
## Event System
|
||||
|
||||
Components use `emit` to fire named events, or `on()` to get an event handle with metadata. The element's `on` field maps events to action bindings:
|
||||
@@ -166,16 +176,32 @@ Link: ({ props, on }) => {
|
||||
|
||||
The `EventHandle` returned by `on()` has: `emit()`, `shouldPreventDefault` (boolean), and `bound` (boolean).
|
||||
|
||||
## State Watchers
|
||||
|
||||
Elements can declare a `watch` field (top-level, sibling of type/props/children) to trigger actions when state values change:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
|
||||
"watch": { "/form/country": { "action": "loadCities" } },
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`:
|
||||
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in catalog `actions`:
|
||||
|
||||
```json
|
||||
{ "action": "setState", "params": { "statePath": "/activeTab", "value": "home" } }
|
||||
{ "action": "pushState", "params": { "statePath": "/items", "value": { "text": "New" } } }
|
||||
{ "action": "removeState", "params": { "statePath": "/items", "index": 0 } }
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } }
|
||||
```
|
||||
|
||||
`validateForm` validates all registered fields and writes `{ valid, errors }` to state.
|
||||
|
||||
Note: `statePath` in action params (e.g. `setState.statePath`) targets the mutation path. Two-way binding in component props uses `{ "$bindState": "/path" }` on the value prop, not `statePath`.
|
||||
|
||||
## useBoundProp
|
||||
@@ -223,12 +249,13 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
|
||||
|--------|---------|
|
||||
| `defineRegistry` | Create a type-safe component registry from a catalog |
|
||||
| `Renderer` | Render a spec using a registry |
|
||||
| `schema` | Element tree schema (includes built-in state actions) |
|
||||
| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) |
|
||||
| `useStateStore` | Access state context |
|
||||
| `useStateValue` | Get single value from state |
|
||||
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
|
||||
| `useActions` | Access actions context |
|
||||
| `useAction` | Get a single action dispatch function |
|
||||
| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) |
|
||||
| `useUIStream` | Stream specs from an API endpoint |
|
||||
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
|
||||
| `StateStore` | Interface for plugging in external state management |
|
||||
|
||||
@@ -126,9 +126,9 @@ const { registry } = defineRegistry(catalog, {
|
||||
- **Input** - Text input with label, name, type, placeholder, value, checks
|
||||
- **Textarea** - Multi-line input with label, name, placeholder, rows, value, checks
|
||||
- **Select** - Dropdown select with label, name, options (string[]), value, checks
|
||||
- **Checkbox** - Checkbox with label, name, checked
|
||||
- **Radio** - Radio group with label, name, options (string[]), value
|
||||
- **Switch** - Toggle switch with label, name, checked
|
||||
- **Checkbox** - Checkbox with label, name, checked, checks, validateOn
|
||||
- **Radio** - Radio group with label, name, options (string[]), value, checks, validateOn
|
||||
- **Switch** - Toggle switch with label, name, checked, checks, validateOn
|
||||
- **Slider** - Range slider with label, min, max, step, value
|
||||
- **Toggle** - Toggle button with label, pressed, variant
|
||||
- **ToggleGroup** - Group of toggles with items, type, value
|
||||
@@ -141,11 +141,19 @@ These are built into the React schema and handled by `ActionProvider` automatica
|
||||
- **setState** - Set a value at a state path (`{ statePath, value }`)
|
||||
- **pushState** - Push a value onto an array (`{ statePath, value, clearStatePath? }`)
|
||||
- **removeState** - Remove an array item by index (`{ statePath, index }`)
|
||||
- **validateForm** - Validate all fields, write `{ valid, errors }` to state (`{ statePath? }`)
|
||||
|
||||
## Validation Timing (`validateOn`)
|
||||
|
||||
All form components support `validateOn` to control when validation runs:
|
||||
- `"change"` — validate on every input change (default for Select, Checkbox, Radio, Switch)
|
||||
- `"blur"` — validate when field loses focus (default for Input, Textarea)
|
||||
- `"submit"` — validate only on form submission
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
|
||||
- Components use Tailwind CSS classes -- your app must have Tailwind configured
|
||||
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
|
||||
- Form inputs support `checks` for validation (type + message pairs)
|
||||
- All form inputs support `checks` for validation (type + message pairs) and `validateOn` for timing
|
||||
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
|
||||
|
||||
Reference in New Issue
Block a user