mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b397510e | ||
|
|
8506cfaa03 | ||
|
|
9cef4e9142 | ||
|
|
3c11f19be4 | ||
|
|
db3a8b41e9 | ||
|
|
ea47b66dfc | ||
|
|
cd82f969c8 | ||
|
|
6bcaaad57d | ||
|
|
0b7d767cdd | ||
|
|
b1036763d2 |
@@ -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": [],
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
```bash
|
||||
# for React
|
||||
npm install @json-render/core @json-render/react
|
||||
# pre-built shadcn/ui components
|
||||
# for React with pre-built shadcn/ui components
|
||||
npm install @json-render/shadcn
|
||||
# or for mobile
|
||||
# or for React Native
|
||||
npm install @json-render/core @json-render/react-native
|
||||
# or for video
|
||||
npm install @json-render/core @json-render/remotion
|
||||
# or for PDF documents
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
# or for Vue
|
||||
npm install @json-render/core @json-render/vue
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
@@ -23,7 +26,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
|
||||
- **Guardrailed** - AI can only use components in your catalog
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
- **Fast** - Stream and render progressively as the model responds
|
||||
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
|
||||
- **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
|
||||
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
|
||||
|
||||
## Quick Start
|
||||
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@json-render/react` | React renderer, contexts, hooks |
|
||||
| `@json-render/vue` | Vue 3 renderer, composables, providers |
|
||||
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
@@ -117,6 +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` | XState Store (atom) adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
@@ -148,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### Vue (UI)
|
||||
|
||||
```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),
|
||||
},
|
||||
});
|
||||
|
||||
// In your Vue component template:
|
||||
// <Renderer :spec="spec" :registry="registry" />
|
||||
```
|
||||
|
||||
### shadcn/ui (Web)
|
||||
|
||||
```tsx
|
||||
@@ -317,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
|
||||
|
||||
@@ -336,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
|
||||
@@ -351,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,23 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/vue")
|
||||
|
||||
# @json-render/vue
|
||||
|
||||
Vue 3 components, providers, and composables.
|
||||
|
||||
## Providers
|
||||
|
||||
### StateProvider
|
||||
|
||||
```vue
|
||||
<StateProvider :initial-state="object" :on-state-change="fn">
|
||||
<!-- children -->
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
|
||||
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
|
||||
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||
|
||||
```typescript
|
||||
import { createStateStore, type StateStore } from "@json-render/vue";
|
||||
|
||||
const store = createStateStore({ count: 0 });
|
||||
```
|
||||
|
||||
```vue
|
||||
<StateProvider :store="store">
|
||||
<!-- children -->
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Mutate from anywhere — Vue re-renders automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```vue
|
||||
<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
|
||||
<!-- children -->
|
||||
</ActionProvider>
|
||||
|
||||
// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
|
||||
```
|
||||
|
||||
### VisibilityProvider
|
||||
|
||||
```vue
|
||||
<VisibilityProvider>
|
||||
<!-- children -->
|
||||
</VisibilityProvider>
|
||||
```
|
||||
|
||||
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```vue
|
||||
<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
|
||||
<!-- children -->
|
||||
</ValidationProvider>
|
||||
|
||||
// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||
```
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
|
||||
|
||||
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
|
||||
|
||||
```typescript
|
||||
import { h } from "vue";
|
||||
import { defineRegistry } from "@json-render/vue";
|
||||
|
||||
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),
|
||||
},
|
||||
// Required when catalog declares actions:
|
||||
actions: {
|
||||
submit: async (params) => { /* ... */ },
|
||||
},
|
||||
});
|
||||
|
||||
// Pass to <Renderer>
|
||||
// <Renderer :spec="spec" :registry="registry" />
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Renderer
|
||||
|
||||
```vue
|
||||
<Renderer
|
||||
:spec="Spec" // The UI spec to render
|
||||
:registry="Registry" // Component registry (from defineRegistry)
|
||||
:loading="boolean" // Optional loading state
|
||||
:fallback="Component" // Optional fallback for unknown types
|
||||
/>
|
||||
```
|
||||
|
||||
### Component Props (via defineRegistry)
|
||||
|
||||
```typescript
|
||||
import type { VNode } from "vue";
|
||||
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from catalog
|
||||
children?: VNode | VNode[]; // Rendered children (for container components)
|
||||
emit: (event: string) => void; // Emit a named event (always defined)
|
||||
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||
loading?: boolean;
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
}
|
||||
|
||||
interface EventHandle {
|
||||
emit: () => void; // Fire the event
|
||||
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
|
||||
bound: boolean; // Whether any handler is bound
|
||||
}
|
||||
```
|
||||
|
||||
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
|
||||
|
||||
```typescript
|
||||
Link: ({ props, on }) => {
|
||||
const click = on("click");
|
||||
return h("a", {
|
||||
href: props.href,
|
||||
onClick: (e: MouseEvent) => {
|
||||
if (click.shouldPreventDefault) e.preventDefault();
|
||||
click.emit();
|
||||
},
|
||||
}, props.label);
|
||||
},
|
||||
```
|
||||
|
||||
### BaseComponentProps
|
||||
|
||||
Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
|
||||
|
||||
```typescript
|
||||
import type { BaseComponentProps } from "@json-render/vue";
|
||||
|
||||
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
|
||||
h("div", null, [props.title, children]);
|
||||
```
|
||||
|
||||
## Composables
|
||||
|
||||
### useStateStore
|
||||
|
||||
```typescript
|
||||
const {
|
||||
state, // ShallowRef<StateModel> — access with state.value
|
||||
get, // (path: string) => unknown
|
||||
set, // (path: string, value: unknown) => void
|
||||
update, // (updates: Record<string, unknown>) => void
|
||||
} = useStateStore();
|
||||
```
|
||||
|
||||
> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
|
||||
|
||||
### useStateValue
|
||||
|
||||
```typescript
|
||||
const value = useStateValue(path: string); // ComputedRef<T | undefined>
|
||||
```
|
||||
|
||||
Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
|
||||
|
||||
### useStateBinding (deprecated)
|
||||
|
||||
> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useStateBinding(path: string);
|
||||
// value: ComputedRef<T | undefined>
|
||||
// setValue: (value: T) => void
|
||||
```
|
||||
|
||||
### useActions
|
||||
|
||||
```typescript
|
||||
const { execute } = useActions();
|
||||
// execute(binding: ActionBinding) => Promise<void>
|
||||
```
|
||||
|
||||
### useAction
|
||||
|
||||
```typescript
|
||||
const { execute, isLoading } = useAction(binding: ActionBinding);
|
||||
// execute: () => Promise<void>
|
||||
// isLoading: ComputedRef<boolean>
|
||||
```
|
||||
|
||||
### useIsVisible
|
||||
|
||||
```typescript
|
||||
const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||
```
|
||||
|
||||
### useFieldValidation
|
||||
|
||||
```typescript
|
||||
const {
|
||||
state, // ComputedRef<FieldValidationState>
|
||||
validate, // () => ValidationResult
|
||||
touch, // () => void
|
||||
clear, // () => void
|
||||
errors, // ComputedRef<string[]>
|
||||
isValid, // ComputedRef<boolean>
|
||||
} = useFieldValidation(path: string, config?: ValidationConfig);
|
||||
```
|
||||
|
||||
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||
|
||||
## Differences from `@json-render/react`
|
||||
|
||||
| API | React | Vue | Note |
|
||||
|-----|-------|-----|------|
|
||||
| `useStateStore().state` | `StateModel` (plain object) | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
|
||||
| `useStateValue()` | `T \| undefined` | `ComputedRef<T \| undefined>` | Vue reactivity; use `.value` |
|
||||
| `useStateBinding()` | `[T \| undefined, setter]` | `[ComputedRef<T \| undefined>, setter]` | Vue reactivity; use `value.value` |
|
||||
| `useAction().isLoading` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
|
||||
| `useFieldValidation().state` | `FieldValidationState` | `ComputedRef<FieldValidationState>` | Vue reactivity; use `.value` |
|
||||
| `useFieldValidation().errors` | `string[]` | `ComputedRef<string[]>` | Vue reactivity; use `.value` |
|
||||
| `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 | exported | Same API; returns `[value, setValue]` |
|
||||
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
|
||||
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
|
||||
@@ -5,6 +5,129 @@ 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
|
||||
|
||||
### Fixed: Install failure due to private dependency
|
||||
|
||||
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0
|
||||
|
||||
February 2026
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/computed-values")
|
||||
|
||||
# Computed Values
|
||||
|
||||
Derive dynamic prop values using registered functions or string templates.
|
||||
|
||||
## `$template` — String Interpolation
|
||||
|
||||
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
|
||||
|
||||
Missing paths resolve to an empty string.
|
||||
|
||||
## `$computed` — Registered Functions
|
||||
|
||||
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Functions
|
||||
|
||||
Functions are registered in the catalog and provided at runtime.
|
||||
|
||||
**Catalog definition (for AI prompt generation):**
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
functions: {
|
||||
fullName: {
|
||||
description: 'Combines first and last name into a full name',
|
||||
},
|
||||
formatCurrency: {
|
||||
description: 'Formats a number as currency',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Runtime implementation:**
|
||||
|
||||
```tsx
|
||||
import { JSONUIProvider } from '@json-render/react';
|
||||
|
||||
const functions = {
|
||||
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
|
||||
formatCurrency: (args) => {
|
||||
const value = Number(args.value ?? 0);
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: (args.currency as string) ?? 'USD',
|
||||
}).format(value);
|
||||
},
|
||||
};
|
||||
|
||||
<JSONUIProvider registry={registry} functions={functions}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### Using with `createRenderer`
|
||||
|
||||
```tsx
|
||||
const MyRenderer = createRenderer(catalog, components);
|
||||
|
||||
<MyRenderer
|
||||
spec={spec}
|
||||
functions={functions}
|
||||
/>
|
||||
```
|
||||
|
||||
## Combining Expressions
|
||||
|
||||
`$computed` args can use any expression type. This example computes a total from repeat item fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"$computed": "lineTotal",
|
||||
"args": {
|
||||
"price": { "$item": "price" },
|
||||
"quantity": { "$item": "quantity" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
- [Watchers](/docs/watchers) — react to state changes with cascading actions
|
||||
- [Data Binding](/docs/data-binding) — all expression types
|
||||
- [Validation](/docs/validation) — validate form inputs
|
||||
@@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
|
||||
|
||||
The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
|
||||
## Template Strings
|
||||
|
||||
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": { "$template": "Welcome back, ${/user/name}!" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
@@ -255,6 +271,16 @@ The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||
<td>Form components inside repeat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$template"}</code></td>
|
||||
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
|
||||
<td>Anywhere (string props)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$computed"}</code></td>
|
||||
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
|
||||
<td>Anywhere (requires registered function)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||
|
||||
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
|
||||
|
||||
<PackageInstall packages="react zod" />
|
||||
|
||||
## For Vue
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/vue" />
|
||||
|
||||
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
|
||||
|
||||
<PackageInstall packages="vue zod" />
|
||||
|
||||
## For React UI with shadcn/ui
|
||||
|
||||
Pre-built components for fast prototyping and production use:
|
||||
@@ -35,17 +47,10 @@ 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.
|
||||
|
||||
## Peer Dependencies
|
||||
|
||||
json-render requires the following peer dependencies:
|
||||
|
||||
- `react` ^19.0.0
|
||||
- `zod` ^4.0.0
|
||||
|
||||
<PackageInstall packages="react zod" />
|
||||
|
||||
## For AI Integration
|
||||
|
||||
To use json-render with AI models, you'll also need the Vercel AI SDK:
|
||||
|
||||
@@ -11,11 +11,18 @@ json-render includes common validation functions:
|
||||
|
||||
- `required` — Value must be non-empty
|
||||
- `email` — Valid email format
|
||||
- `minLength` — Minimum string length
|
||||
- `maxLength` — Maximum string length
|
||||
- `pattern` — Match a regex pattern
|
||||
- `min` — Minimum numeric value
|
||||
- `max` — Maximum numeric value
|
||||
- `minLength` — Minimum string length (args: `{ "min": N }`)
|
||||
- `maxLength` — Maximum string length (args: `{ "max": N }`)
|
||||
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
|
||||
- `min` — Minimum numeric value (args: `{ "min": N }`)
|
||||
- `max` — Maximum numeric value (args: `{ "max": N }`)
|
||||
- `numeric` — Value must be a number
|
||||
- `url` — Valid URL format
|
||||
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
|
||||
|
||||
## Using Validation in JSON
|
||||
|
||||
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
||||
|
||||
## Cross-Field Validation
|
||||
|
||||
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"label": "Confirm Password",
|
||||
"value": { "$bindState": "/form/confirmPassword" },
|
||||
"checks": [
|
||||
{ "type": "required", "message": "Please confirm your password" },
|
||||
{
|
||||
"type": "matches",
|
||||
"args": { "other": { "$state": "/form/password" } },
|
||||
"message": "Passwords must match"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other cross-field examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"type": "greaterThan",
|
||||
"args": { "other": { "$state": "/form/startDate" } },
|
||||
"message": "End date must be after start date"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"type": "requiredIf",
|
||||
"args": { "field": { "$state": "/form/enableNotifications" } },
|
||||
"message": "Email is required when notifications are enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Validation
|
||||
|
||||
Use the `enabled` field in the validation config to only run checks when a condition is met:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"label": "Company Name",
|
||||
"value": { "$bindState": "/form/company" },
|
||||
"checks": [
|
||||
{ "type": "required", "message": "Company name is required" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the component implementation, you can pass `enabled` to `useFieldValidation`:
|
||||
|
||||
```typescript
|
||||
useFieldValidation(bindings?.value ?? "", {
|
||||
checks: props.checks ?? [],
|
||||
enabled: { "$state": "/form/accountType", eq: "business" },
|
||||
});
|
||||
```
|
||||
|
||||
This only validates the company name when the account type is "business".
|
||||
|
||||
## Validation Timing
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
|
||||
- `change` — Validate on every input change
|
||||
- `blur` — Validate when field loses focus
|
||||
- `blur` — Validate when field loses focus (default for Input, Textarea)
|
||||
- `submit` — Validate only on form submission
|
||||
|
||||
## Form-Level Validation
|
||||
|
||||
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
|
||||
|
||||
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [generation modes](/docs/generation-modes).
|
||||
- [Computed Values](/docs/computed-values) — derive dynamic prop values
|
||||
- [Watchers](/docs/watchers) — react to state changes
|
||||
- [Generation Modes](/docs/generation-modes) — how AI generates specs
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/watchers")
|
||||
|
||||
# Watchers
|
||||
|
||||
React to state changes by triggering actions when watched paths update.
|
||||
|
||||
## The `watch` Field
|
||||
|
||||
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
|
||||
|
||||
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": {
|
||||
"action": "loadCities",
|
||||
"params": { "country": { "$state": "/form/country" } }
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
|
||||
|
||||
## Cascading Selects
|
||||
|
||||
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "form",
|
||||
"elements": {
|
||||
"form": {
|
||||
"type": "Stack",
|
||||
"props": { "direction": "vertical", "gap": "md" },
|
||||
"children": ["country-select", "city-select"]
|
||||
},
|
||||
"country-select": {
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": [
|
||||
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
|
||||
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
"city-select": {
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "City",
|
||||
"value": { "$bindState": "/form/city" },
|
||||
"options": { "$state": "/availableCities" },
|
||||
"placeholder": "Select a city"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"form": { "country": "", "city": "" },
|
||||
"availableCities": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The watcher on `country-select` fires two actions when the country changes:
|
||||
1. `loadCities` — fetches and writes city options to `/availableCities`
|
||||
2. `setState` — resets the city selection
|
||||
|
||||
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
|
||||
|
||||
### Action Handler
|
||||
|
||||
```typescript
|
||||
const handlers = {
|
||||
loadCities: async (params) => {
|
||||
const cities = await fetchCities(params.country);
|
||||
// setState is called by the runtime to write the result
|
||||
return cities;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Or with `defineRegistry`:
|
||||
|
||||
```typescript
|
||||
const { registry, handlers } = defineRegistry(catalog, {
|
||||
components: { /* ... */ },
|
||||
actions: {
|
||||
loadCities: async (params, setState) => {
|
||||
const response = await fetch(`/api/cities?country=${params.country}`);
|
||||
const cities = await response.json();
|
||||
setState('/availableCities', cities);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Multiple Watchers
|
||||
|
||||
An element can watch multiple state paths. Each path maps to one or more action bindings:
|
||||
|
||||
```json
|
||||
{
|
||||
"watch": {
|
||||
"/form/startDate": { "action": "validateDateRange" },
|
||||
"/form/endDate": { "action": "validateDateRange" },
|
||||
"/form/quantity": [
|
||||
{ "action": "recalculateTotal" },
|
||||
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Watchers only fire on **value changes**, not on the initial render
|
||||
- Comparison is by reference (`===`), not deep equality
|
||||
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
|
||||
- Multiple action bindings on the same path execute sequentially
|
||||
|
||||
## When to Use `watch` vs `on`
|
||||
|
||||
| Mechanism | Trigger | Use Case |
|
||||
|-----------|---------|----------|
|
||||
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
|
||||
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
|
||||
|
||||
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
|
||||
|
||||
## Next
|
||||
|
||||
- [Data Binding](/docs/data-binding) — connect elements to state
|
||||
- [Computed Values](/docs/computed-values) — derive prop values
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Computed Values", href: "/docs/computed-values" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Watchers", href: "/docs/watchers" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
@@ -67,6 +69,16 @@ export const docsNavigation: NavSection[] = [
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Vue",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Renders with Vite (Vue / React)",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -95,6 +107,7 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
@@ -38,6 +40,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
// API references
|
||||
"docs/api/core": "@json-render/core API",
|
||||
"docs/api/react": "@json-render/react API",
|
||||
"docs/api/vue": "@json-render/vue API",
|
||||
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||
"docs/api/react-native": "@json-render/react-native API",
|
||||
"docs/api/codegen": "@json-render/codegen API",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/shadcn@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/shadcn@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+59
-41
@@ -2,33 +2,31 @@
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
ValidationProvider,
|
||||
} from "@json-render/react";
|
||||
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
|
||||
import {
|
||||
registry,
|
||||
actionHandlers,
|
||||
computedFunctions,
|
||||
onConfetti,
|
||||
} from "@/lib/render/registry";
|
||||
import { examples } from "@/lib/examples";
|
||||
|
||||
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
||||
return (
|
||||
<StateProvider initialState={spec.state ?? {}}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec.state ?? {}}
|
||||
handlers={actionHandlers}
|
||||
functions={computedFunctions}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [selectedIndex] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = examples[selectedIndex]!;
|
||||
const [confettiKey, setConfettiKey] = useState(0);
|
||||
const [confettiActive, setConfettiActive] = useState(false);
|
||||
@@ -41,30 +39,50 @@ export default function Page() {
|
||||
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-muted/30">
|
||||
<div
|
||||
className="relative bg-background border rounded-lg shadow-sm"
|
||||
style={{ width: 960, height: 1080 }}
|
||||
>
|
||||
{confettiActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
<div className="h-screen flex flex-col bg-muted/30">
|
||||
{/* Example selector */}
|
||||
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
|
||||
{examples.map((ex, i) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => setSelectedIndex(i)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
|
||||
i === selectedIndex
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{ex.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Render area */}
|
||||
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
|
||||
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
|
||||
{confettiActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 relative z-10">
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{selected.description}
|
||||
</p>
|
||||
<SpecRenderer key={selectedIndex} spec={selected.spec} />
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
|
||||
<SpecRenderer key={selectedIndex} spec={selected.spec} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -508,4 +508,353 @@ export const examples: Example[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Advanced: Registration form with cross-field validation & $template
|
||||
// =========================================================================
|
||||
{
|
||||
name: "Registration Form",
|
||||
description:
|
||||
"Cross-field validation, $template preview, and validateForm action",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
form: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
accountType: "personal",
|
||||
company: "",
|
||||
},
|
||||
result: null,
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Create Account",
|
||||
description: "Fill out the form below to register",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["formStack"],
|
||||
},
|
||||
formStack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"preview",
|
||||
"sep0",
|
||||
"nameInput",
|
||||
"emailInput",
|
||||
"passwordInput",
|
||||
"confirmInput",
|
||||
"sep1",
|
||||
"accountTypeRadio",
|
||||
"companyInput",
|
||||
"sep2",
|
||||
"actions",
|
||||
"statusText",
|
||||
],
|
||||
},
|
||||
|
||||
// $template live preview
|
||||
preview: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
|
||||
},
|
||||
variant: "muted",
|
||||
},
|
||||
visible: { $state: "/form/name", neq: "" },
|
||||
children: [],
|
||||
},
|
||||
sep0: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
nameInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Full Name",
|
||||
name: "name",
|
||||
type: "text",
|
||||
placeholder: "Jane Doe",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: [
|
||||
{ type: "required", message: "Name is required" },
|
||||
{
|
||||
type: "minLength",
|
||||
args: { min: 2 },
|
||||
message: "Name must be at least 2 characters",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "jane@example.com",
|
||||
value: { $bindState: "/form/email" },
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Enter a valid email address" },
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
passwordInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Password",
|
||||
name: "password",
|
||||
type: "password",
|
||||
placeholder: "At least 8 characters",
|
||||
value: { $bindState: "/form/password" },
|
||||
checks: [
|
||||
{ type: "required", message: "Password is required" },
|
||||
{
|
||||
type: "minLength",
|
||||
args: { min: 8 },
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
confirmInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Confirm Password",
|
||||
name: "confirmPassword",
|
||||
type: "password",
|
||||
placeholder: "Re-enter your password",
|
||||
value: { $bindState: "/form/confirmPassword" },
|
||||
checks: [
|
||||
{ type: "required", message: "Please confirm your password" },
|
||||
{
|
||||
type: "matches",
|
||||
args: { other: { $state: "/form/password" } },
|
||||
message: "Passwords must match",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sep1: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
accountTypeRadio: {
|
||||
type: "Radio",
|
||||
props: {
|
||||
label: "Account Type",
|
||||
name: "accountType",
|
||||
options: ["personal", "business"],
|
||||
value: { $bindState: "/form/accountType" },
|
||||
checks: null,
|
||||
validateOn: null,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
companyInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Company Name",
|
||||
name: "company",
|
||||
type: "text",
|
||||
placeholder: "Acme Inc.",
|
||||
value: { $bindState: "/form/company" },
|
||||
checks: [
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/accountType" } },
|
||||
message: "Company name is required for business accounts",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
visible: { $state: "/form/accountType", eq: "business" },
|
||||
children: [],
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: "end",
|
||||
},
|
||||
children: ["submitBtn"],
|
||||
},
|
||||
submitBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Register", variant: "primary", disabled: null },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
// Validation result
|
||||
statusText: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "Validation Result",
|
||||
message: {
|
||||
$cond: { $state: "/result/valid", eq: true },
|
||||
$then: "All fields are valid -- ready to submit!",
|
||||
$else: "Please fix the errors above before submitting.",
|
||||
},
|
||||
type: {
|
||||
$cond: { $state: "/result/valid", eq: true },
|
||||
$then: "success",
|
||||
$else: "error",
|
||||
},
|
||||
},
|
||||
visible: { $state: "/result", neq: null },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Advanced: Cascading selects with watchers & $computed
|
||||
// =========================================================================
|
||||
{
|
||||
name: "Cascading Selects",
|
||||
description:
|
||||
"Watchers reset dependent fields, $computed derives display values",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
form: { country: "", city: "" },
|
||||
availableCities: [],
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Shipping Address",
|
||||
description: "Select your country to load available cities",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["formStack"],
|
||||
},
|
||||
formStack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"countrySelect",
|
||||
"citySelect",
|
||||
"sep",
|
||||
"addressPreview",
|
||||
"templatePreview",
|
||||
],
|
||||
},
|
||||
|
||||
countrySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
name: "country",
|
||||
options: ["US", "Canada", "UK", "Germany", "Japan"],
|
||||
placeholder: "Choose a country",
|
||||
value: { $bindState: "/form/country" },
|
||||
checks: [{ type: "required", message: "Country is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
watch: {
|
||||
"/form/country": [
|
||||
{
|
||||
action: "setState",
|
||||
params: {
|
||||
statePath: "/availableCities",
|
||||
value: {
|
||||
$computed: "citiesForCountry",
|
||||
args: { country: { $state: "/form/country" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "setState",
|
||||
params: { statePath: "/form/city", value: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
citySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "City",
|
||||
name: "city",
|
||||
options: { $state: "/availableCities" },
|
||||
placeholder: "Select a city",
|
||||
value: { $bindState: "/form/city" },
|
||||
checks: [{ type: "required", message: "City is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
// $computed formatted address
|
||||
addressPreview: {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: {
|
||||
$computed: "formatAddress",
|
||||
args: {
|
||||
city: { $state: "/form/city" },
|
||||
country: { $state: "/form/country" },
|
||||
},
|
||||
},
|
||||
level: "h3",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
// $template string interpolation
|
||||
templatePreview: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template:
|
||||
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
|
||||
},
|
||||
variant: "muted",
|
||||
},
|
||||
visible: { $state: "/form/country", neq: "" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
|
||||
description: "Fire confetti",
|
||||
},
|
||||
},
|
||||
functions: {
|
||||
formatAddress: {
|
||||
description:
|
||||
"Formats country and city into a single address string like 'City, Country'",
|
||||
},
|
||||
citiesForCountry: {
|
||||
description: "Returns an array of city names for the given country code",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
@@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) {
|
||||
};
|
||||
}
|
||||
|
||||
const cityData: Record<string, string[]> = {
|
||||
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
|
||||
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
|
||||
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
|
||||
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
|
||||
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
|
||||
};
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
...shadcnComponents,
|
||||
@@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, {
|
||||
},
|
||||
});
|
||||
|
||||
export const actionHandlers: Record<string, () => void> = {
|
||||
export const actionHandlers: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void
|
||||
> = {
|
||||
confetti: () => confettiListener?.(),
|
||||
};
|
||||
|
||||
export const computedFunctions: Record<string, ComputedFunction> = {
|
||||
formatAddress: (args) => {
|
||||
const city = (args.city as string) ?? "";
|
||||
const country = (args.country as string) ?? "";
|
||||
if (!city && !country) return "No location selected";
|
||||
if (!city) return country;
|
||||
return `${city}, ${country}`;
|
||||
},
|
||||
citiesForCountry: (args) => {
|
||||
const country = (args.country as string) ?? "";
|
||||
return cityData[country] ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react-native@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react-pdf@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/remotion@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.4",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.2",
|
||||
"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
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@json-render multi-renderer demo</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#renderer-root {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="renderer-root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "vite-renderers",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless vite-renderers.json-render vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/vue": "workspace:*",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"vue": "^3.5.29",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import "./shared/styles.css";
|
||||
import { demoSpec } from "./spec";
|
||||
|
||||
type Renderer = "vue" | "react";
|
||||
|
||||
const container = document.getElementById("renderer-root") as HTMLElement;
|
||||
|
||||
let unmountCurrent: (() => void) | null = null;
|
||||
|
||||
async function switchTo(renderer: Renderer) {
|
||||
unmountCurrent?.();
|
||||
container.innerHTML = "";
|
||||
if (renderer === "vue") {
|
||||
const mod = await import("./vue/mount.ts");
|
||||
mod.mount(container, renderer, demoSpec);
|
||||
unmountCurrent = mod.unmount;
|
||||
} else {
|
||||
const mod = await import("./react/mount.tsx");
|
||||
mod.mount(container, renderer, demoSpec);
|
||||
unmountCurrent = mod.unmount;
|
||||
}
|
||||
}
|
||||
|
||||
// The RendererTabs component (rendered by JSON renderer) dispatches this event
|
||||
document.addEventListener("switch-renderer", (e: Event) => {
|
||||
switchTo((e as CustomEvent<string>).detail as Renderer);
|
||||
});
|
||||
|
||||
// Default: Vue
|
||||
switchTo("vue");
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
StateProvider,
|
||||
ActionProvider,
|
||||
VisibilityProvider,
|
||||
ValidationProvider,
|
||||
Renderer,
|
||||
defineRegistry,
|
||||
useStateStore,
|
||||
} from "@json-render/react";
|
||||
import { catalog } from "./catalog";
|
||||
import { components } from "./registry";
|
||||
import { actionStubs, makeHandlers } from "../shared/handlers";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components,
|
||||
actions: actionStubs,
|
||||
});
|
||||
|
||||
function DemoRenderer({ spec }: { spec: Spec }) {
|
||||
const { get, set } = useStateStore();
|
||||
const handlers = useMemo(() => makeHandlers(get, set), [get, set]);
|
||||
return (
|
||||
<ActionProvider handlers={handlers}>
|
||||
<VisibilityProvider>
|
||||
<ValidationProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</ActionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App({
|
||||
initialRenderer = "vue",
|
||||
spec,
|
||||
}: {
|
||||
initialRenderer?: string;
|
||||
spec: Spec;
|
||||
}) {
|
||||
return (
|
||||
<div className={`renderer-${initialRenderer}`}>
|
||||
<StateProvider
|
||||
initialState={{ ...spec.state, renderer: initialRenderer }}
|
||||
>
|
||||
<DemoRenderer spec={spec} />
|
||||
</StateProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { catalogDef } from "../shared/catalog-def";
|
||||
|
||||
export const catalog = schema.createCatalog(catalogDef);
|
||||
export type AppCatalog = typeof catalog;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import App from "./App";
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
|
||||
root = createRoot(container);
|
||||
root.render(<App initialRenderer={renderer} spec={spec} />);
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
root?.unmount();
|
||||
root = null;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Components } from "@json-render/react";
|
||||
import type { AppCatalog } from "./catalog";
|
||||
|
||||
export const components: Components<AppCatalog> = {
|
||||
Stack: ({ props, children }) => (
|
||||
<div
|
||||
className={[
|
||||
"json-render-stack",
|
||||
props.direction === "horizontal" && "json-render-stack--horizontal",
|
||||
props.align && `json-render-stack--align-${props.align}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={{
|
||||
gap: props.gap ? `${props.gap}px` : undefined,
|
||||
padding: props.padding ? `${props.padding}px` : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Card: ({ props, children }) => (
|
||||
<div className="json-render-card">
|
||||
{props.title && (
|
||||
<div className="json-render-card-title-wrap">
|
||||
<h2 className="json-render-card-title">{props.title}</h2>
|
||||
</div>
|
||||
)}
|
||||
{props.subtitle && (
|
||||
<p className="json-render-card-subtitle">{props.subtitle}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Text: ({ props }) => (
|
||||
<span
|
||||
className={[
|
||||
"json-render-text",
|
||||
props.size && props.size !== "md" && `json-render-text--${props.size}`,
|
||||
props.weight &&
|
||||
props.weight !== "normal" &&
|
||||
`json-render-text--${props.weight}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={props.color ? { color: props.color } : undefined}
|
||||
>
|
||||
{String(props.content ?? "")}
|
||||
</span>
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) => (
|
||||
<button
|
||||
disabled={props.disabled}
|
||||
onClick={() => emit("press")}
|
||||
className={[
|
||||
"json-render-button",
|
||||
props.variant && `json-render-button--${props.variant}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
|
||||
Badge: ({ props }) => (
|
||||
<span
|
||||
className="json-render-badge"
|
||||
style={
|
||||
props.color
|
||||
? {
|
||||
backgroundColor: `${props.color}20`,
|
||||
color: props.color,
|
||||
borderColor: `${props.color}40`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
),
|
||||
|
||||
ListItem: ({ props, emit }) => (
|
||||
<div
|
||||
onClick={() => emit("press")}
|
||||
className={[
|
||||
"json-render-list-item",
|
||||
props.completed && "json-render-list-item--done",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"json-render-list-item-check",
|
||||
props.completed && "json-render-list-item-check--done",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{props.completed ? "✓" : ""}
|
||||
</div>
|
||||
<span
|
||||
className={[
|
||||
"json-render-list-item-text",
|
||||
props.completed && "json-render-list-item-text--done",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
|
||||
RendererBadge: ({ props }) => (
|
||||
<span className="json-render-renderer-badge">
|
||||
<span className="json-render-renderer-dot" />
|
||||
{props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"}
|
||||
</span>
|
||||
),
|
||||
|
||||
RendererTabs: ({ props, emit }) => (
|
||||
<div className="json-render-renderer-tabs-wrapper">
|
||||
<span className="json-render-renderer-tabs-label">Render</span>
|
||||
<div className="json-render-renderer-tabs">
|
||||
<button
|
||||
onClick={() => emit("pressVue")}
|
||||
className={[
|
||||
"json-render-renderer-tab",
|
||||
props.renderer === "vue" && "json-render-renderer-tab--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
Vue
|
||||
</button>
|
||||
<button
|
||||
onClick={() => emit("pressReact")}
|
||||
className={[
|
||||
"json-render-renderer-tab",
|
||||
props.renderer === "react" && "json-render-renderer-tab--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
React
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts.
|
||||
* Each renderer calls schema.createCatalog(catalogDef) with its own schema instance.
|
||||
*/
|
||||
export const catalogDef = {
|
||||
components: {
|
||||
Stack: {
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
padding: z.number().optional(),
|
||||
direction: z.enum(["vertical", "horizontal"]).optional(),
|
||||
align: z.enum(["start", "center", "end"]).optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Layout container that stacks children vertically or horizontally",
|
||||
},
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "A card container with optional title and subtitle",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
|
||||
weight: z.enum(["normal", "medium", "bold"]).optional(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Displays a text string",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A clickable button that emits a 'press' event",
|
||||
},
|
||||
Badge: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A small badge/tag label",
|
||||
},
|
||||
ListItem: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
completed: z.boolean().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A single item in a list",
|
||||
},
|
||||
RendererTabs: {
|
||||
props: z.object({ renderer: z.string() }),
|
||||
slots: [],
|
||||
description:
|
||||
"Segmented tab control for switching between Vue and React renderers",
|
||||
},
|
||||
RendererBadge: {
|
||||
props: z.object({ renderer: z.string() }),
|
||||
slots: [],
|
||||
description: "Badge indicating which renderer is currently active",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
increment: {
|
||||
params: z.object({}),
|
||||
description: "Increment the counter by 1",
|
||||
},
|
||||
decrement: {
|
||||
params: z.object({}),
|
||||
description: "Decrement the counter by 1",
|
||||
},
|
||||
reset: { params: z.object({}), description: "Reset the counter to 0" },
|
||||
toggleItem: {
|
||||
params: z.object({ index: z.number() }),
|
||||
description: "Toggle the completed state of a todo item",
|
||||
},
|
||||
switchToVue: {
|
||||
params: z.object({}),
|
||||
description: "Switch to the Vue renderer",
|
||||
},
|
||||
switchToReact: {
|
||||
params: z.object({}),
|
||||
description: "Switch to the React renderer",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
type Get = (path: string) => unknown;
|
||||
type Set = (path: string, value: unknown) => void;
|
||||
|
||||
/** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */
|
||||
export const actionStubs = {
|
||||
increment: async () => {},
|
||||
decrement: async () => {},
|
||||
reset: async () => {},
|
||||
toggleItem: async () => {},
|
||||
switchToVue: async () => {},
|
||||
switchToReact: async () => {},
|
||||
};
|
||||
|
||||
/** Creates action handlers that close over the state store's get/set */
|
||||
export function makeHandlers(get: Get, set: Set) {
|
||||
return {
|
||||
increment: async () => {
|
||||
set("/count", Number(get("/count") || 0) + 1);
|
||||
},
|
||||
decrement: async () => {
|
||||
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
|
||||
},
|
||||
reset: async () => {
|
||||
set("/count", 0);
|
||||
},
|
||||
toggleItem: async (params: Record<string, unknown>) => {
|
||||
const index = params.index as number;
|
||||
const todos = (
|
||||
get("/todos") as Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
}>
|
||||
).slice();
|
||||
const item = todos[index];
|
||||
if (item) todos[index] = { ...item, completed: !item.completed };
|
||||
set("/todos", todos);
|
||||
},
|
||||
switchToVue: async () => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("switch-renderer", { detail: "vue" }),
|
||||
);
|
||||
},
|
||||
switchToReact: async () => {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("switch-renderer", { detail: "react" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/* ---- Stack ---------------------------------------------------------------- */
|
||||
|
||||
.json-render-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.json-render-stack--horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.json-render-stack--align-start { align-items: flex-start; }
|
||||
.json-render-stack--align-center { align-items: center; }
|
||||
.json-render-stack--align-end { align-items: flex-end; }
|
||||
|
||||
/* ---- Card ----------------------------------------------------------------- */
|
||||
|
||||
.json-render-card {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.json-render-card-title-wrap {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.json-render-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.json-render-card-subtitle {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
/* ---- Text ----------------------------------------------------------------- */
|
||||
|
||||
.json-render-text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.json-render-text--sm { font-size: 12px; }
|
||||
.json-render-text--lg { font-size: 16px; }
|
||||
.json-render-text--xl { font-size: 24px; }
|
||||
|
||||
.json-render-text--medium { font-weight: 500; }
|
||||
.json-render-text--bold { font-weight: 700; }
|
||||
|
||||
/* ---- Button --------------------------------------------------------------- */
|
||||
|
||||
.json-render-button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.json-render-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.json-render-button--primary {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.json-render-button--secondary {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.json-render-button--danger {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ---- Badge ---------------------------------------------------------------- */
|
||||
|
||||
.json-render-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background-color: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border: 1px solid #bae6fd;
|
||||
}
|
||||
|
||||
/* ---- ListItem ------------------------------------------------------------- */
|
||||
|
||||
.json-render-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.json-render-list-item--done {
|
||||
background-color: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.json-render-list-item-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #d1d5db;
|
||||
background-color: transparent;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.json-render-list-item-check--done {
|
||||
border-color: #16a34a;
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.json-render-list-item-text {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.json-render-list-item-text--done {
|
||||
color: #6b7280;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ---- RendererBadge -------------------------------------------------------- */
|
||||
|
||||
.json-render-renderer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
/* default colors; overridden by renderer parent class below */
|
||||
background-color: #e0f2fe;
|
||||
color: #0369a1;
|
||||
border: 1px solid #bae6fd;
|
||||
}
|
||||
|
||||
.json-render-renderer-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
/* background overridden by renderer parent class below */
|
||||
background-color: #0369a1;
|
||||
}
|
||||
|
||||
/* ---- RendererTabs --------------------------------------------------------- */
|
||||
|
||||
.json-render-renderer-tabs-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.json-render-renderer-tabs-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.json-render-renderer-tabs {
|
||||
display: inline-flex;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-render-renderer-tab {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-right: 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.json-render-renderer-tab:first-child {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* ---- Renderer-specific overrides ----------------------------------------- */
|
||||
|
||||
.renderer-vue .json-render-renderer-badge {
|
||||
color: #42b883;
|
||||
background-color: #42b88318;
|
||||
border-color: #42b88340;
|
||||
}
|
||||
|
||||
.renderer-vue .json-render-renderer-dot {
|
||||
background-color: #42b883;
|
||||
}
|
||||
|
||||
.renderer-vue .json-render-renderer-tab--active {
|
||||
background-color: #42b883;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.renderer-react .json-render-renderer-badge {
|
||||
color: #149eca;
|
||||
background-color: #149eca18;
|
||||
border-color: #149eca40;
|
||||
}
|
||||
|
||||
.renderer-react .json-render-renderer-dot {
|
||||
background-color: #149eca;
|
||||
}
|
||||
|
||||
.renderer-react .json-render-renderer-tab--active {
|
||||
background-color: #149eca;
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export const demoSpec: Spec = {
|
||||
root: "root",
|
||||
state: {
|
||||
renderer: "vue",
|
||||
count: 0,
|
||||
todos: [
|
||||
{ id: 1, title: "Learn JSON Render", completed: true },
|
||||
{
|
||||
id: 2,
|
||||
title: "Try @json-render/vue or @json-render/react",
|
||||
completed: false,
|
||||
},
|
||||
{ id: 3, title: "Build something awesome", completed: false },
|
||||
],
|
||||
},
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { gap: 24, padding: 24, direction: "vertical" },
|
||||
children: [
|
||||
"demo-title",
|
||||
"renderer-tabs",
|
||||
"renderer-badge",
|
||||
"counter-card",
|
||||
"milestone-badge",
|
||||
"todos-card",
|
||||
],
|
||||
},
|
||||
|
||||
"demo-title": {
|
||||
type: "Text",
|
||||
props: {
|
||||
content: "@json-render multi-renderer demo",
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
},
|
||||
},
|
||||
"renderer-badge": {
|
||||
type: "RendererBadge",
|
||||
props: { renderer: { $state: "/renderer" } },
|
||||
},
|
||||
"renderer-tabs": {
|
||||
type: "RendererTabs",
|
||||
props: { renderer: { $state: "/renderer" } },
|
||||
on: {
|
||||
pressVue: { action: "switchToVue" },
|
||||
pressReact: { action: "switchToReact" },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Counter card ----
|
||||
"counter-card": {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Counter",
|
||||
subtitle: "Click the buttons to change the count",
|
||||
},
|
||||
children: ["counter-body"],
|
||||
},
|
||||
"counter-body": {
|
||||
type: "Stack",
|
||||
props: { gap: 12, direction: "horizontal", align: "center" },
|
||||
children: [
|
||||
"decrement-btn",
|
||||
"counter-value",
|
||||
"increment-btn",
|
||||
"reset-btn",
|
||||
],
|
||||
},
|
||||
"decrement-btn": {
|
||||
type: "Button",
|
||||
props: { label: "−", variant: "secondary" },
|
||||
on: { press: { action: "decrement" } },
|
||||
},
|
||||
"counter-value": {
|
||||
type: "Text",
|
||||
props: {
|
||||
content: { $state: "/count" },
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
},
|
||||
},
|
||||
"increment-btn": {
|
||||
type: "Button",
|
||||
props: { label: "+", variant: "primary" },
|
||||
on: { press: { action: "increment" } },
|
||||
},
|
||||
"reset-btn": {
|
||||
type: "Button",
|
||||
props: { label: "Reset", variant: "danger" },
|
||||
on: { press: { action: "reset" } },
|
||||
},
|
||||
|
||||
// ---- Milestone badge (visible only when count >= 10) ----
|
||||
"milestone-badge": {
|
||||
type: "Badge",
|
||||
props: { label: "Milestone reached: 10!", color: "#10b981" },
|
||||
visible: { $state: "/count", gte: 10 },
|
||||
},
|
||||
|
||||
// ---- Todos card ----
|
||||
"todos-card": {
|
||||
type: "Card",
|
||||
props: { title: "Todo List", subtitle: "Your tasks" },
|
||||
children: ["todos-list"],
|
||||
},
|
||||
"todos-list": {
|
||||
type: "Stack",
|
||||
props: { gap: 8, direction: "vertical" },
|
||||
repeat: { statePath: "/todos", key: "id" },
|
||||
children: ["todo-item"],
|
||||
},
|
||||
"todo-item": {
|
||||
type: "ListItem",
|
||||
props: {
|
||||
title: { $item: "title" },
|
||||
completed: { $item: "completed" },
|
||||
},
|
||||
on: {
|
||||
press: { action: "toggleItem", params: { index: { $index: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { StateProvider } from "@json-render/vue";
|
||||
import DemoRenderer from "./DemoRenderer.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
initialRenderer?: string; spec: Spec }>();
|
||||
const initialState = {
|
||||
...props.spec.state,
|
||||
renderer: props.initialRenderer ?? "vue" };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="`renderer-${props.initialRenderer ?? 'vue'}`">
|
||||
<StateProvider :initial-state="initialState">
|
||||
<DemoRenderer :spec="props.spec" />
|
||||
</StateProvider>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
ActionProvider, ValidationProvider, VisibilityProvider,
|
||||
Renderer, defineRegistry, useStateStore,
|
||||
} from "@json-render/vue";
|
||||
import { catalog } from "./catalog";
|
||||
import { components } from "./registry";
|
||||
import { actionStubs, makeHandlers } from "../shared/handlers";
|
||||
|
||||
const props = defineProps<{ spec: Spec }>();
|
||||
const { get, set } = useStateStore();
|
||||
const { registry } = defineRegistry(catalog, { components, actions: actionStubs });
|
||||
const handlers = makeHandlers(get, set);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionProvider :handlers="handlers">
|
||||
<VisibilityProvider>
|
||||
<ValidationProvider>
|
||||
<Renderer :spec="props.spec" :registry="registry" />
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</ActionProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
import { catalogDef } from "../shared/catalog-def";
|
||||
|
||||
export const catalog = schema.createCatalog(catalogDef);
|
||||
export type AppCatalog = typeof catalog;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp, type App } from "vue";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import VueApp from "./App.vue";
|
||||
|
||||
let app: App | null = null;
|
||||
|
||||
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
|
||||
app = createApp(VueApp, { initialRenderer: renderer, spec });
|
||||
app.mount(container);
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { h } from "vue";
|
||||
import type { Components } from "@json-render/vue";
|
||||
import type { AppCatalog } from "./catalog";
|
||||
|
||||
export const components: Components<AppCatalog> = {
|
||||
Stack: ({ props, children }) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"json-render-stack",
|
||||
props.direction === "horizontal" && "json-render-stack--horizontal",
|
||||
props.align && `json-render-stack--align-${props.align}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
style: {
|
||||
gap: props.gap ? `${props.gap}px` : undefined,
|
||||
padding: props.padding ? `${props.padding}px` : undefined,
|
||||
},
|
||||
},
|
||||
children,
|
||||
),
|
||||
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "json-render-card" }, [
|
||||
props.title &&
|
||||
h("div", { class: "json-render-card-title-wrap" }, [
|
||||
h("h2", { class: "json-render-card-title" }, props.title),
|
||||
]),
|
||||
props.subtitle &&
|
||||
h("p", { class: "json-render-card-subtitle" }, props.subtitle),
|
||||
children,
|
||||
]),
|
||||
|
||||
Text: ({ props }) =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: [
|
||||
"json-render-text",
|
||||
props.size &&
|
||||
props.size !== "md" &&
|
||||
`json-render-text--${props.size}`,
|
||||
props.weight &&
|
||||
props.weight !== "normal" &&
|
||||
`json-render-text--${props.weight}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
style: props.color ? { color: props.color } : undefined,
|
||||
},
|
||||
String(props.content ?? ""),
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit("press"),
|
||||
class: [
|
||||
"json-render-button",
|
||||
props.variant && `json-render-button--${props.variant}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
props.label,
|
||||
),
|
||||
|
||||
Badge: ({ props }) =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: "json-render-badge",
|
||||
style: props.color
|
||||
? {
|
||||
backgroundColor: `${props.color}20`,
|
||||
color: props.color,
|
||||
borderColor: `${props.color}40`,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
props.label,
|
||||
),
|
||||
|
||||
ListItem: ({ props, emit }) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
onClick: () => emit("press"),
|
||||
class: [
|
||||
"json-render-list-item",
|
||||
props.completed && "json-render-list-item--done",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
[
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"json-render-list-item-check",
|
||||
props.completed && "json-render-list-item-check--done",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
props.completed ? "✓" : "",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: [
|
||||
"json-render-list-item-text",
|
||||
props.completed && "json-render-list-item-text--done",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
props.title,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
RendererBadge: ({ props }) =>
|
||||
h("span", { class: "json-render-renderer-badge" }, [
|
||||
h("span", { class: "json-render-renderer-dot" }),
|
||||
props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React",
|
||||
]),
|
||||
|
||||
RendererTabs: ({ props, emit }) =>
|
||||
h("div", { class: "json-render-renderer-tabs-wrapper" }, [
|
||||
h("span", { class: "json-render-renderer-tabs-label" }, "Render"),
|
||||
h("div", { class: "json-render-renderer-tabs" }, [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
onClick: () => emit("pressVue"),
|
||||
class: [
|
||||
"json-render-renderer-tab",
|
||||
props.renderer === "vue" && "json-render-renderer-tab--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
"Vue",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
onClick: () => emit("pressReact"),
|
||||
class: [
|
||||
"json-render-renderer-tab",
|
||||
props.renderer === "react" && "json-render-renderer-tab--active",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
"React",
|
||||
),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), react({ include: /\.tsx$/ })],
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>json-render vue example</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "example-vue",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless vue.json-render vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/vue": "workspace:*",
|
||||
"vue": "^3.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { StateProvider } from "@json-render/vue";
|
||||
import { demoSpec } from "./lib/spec";
|
||||
import DemoRenderer from "./DemoRenderer.vue";
|
||||
|
||||
const initialState = demoSpec.state ?? {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- StateProvider sets up the reactive state store for the whole tree -->
|
||||
<StateProvider :initial-state="initialState">
|
||||
<DemoRenderer />
|
||||
</StateProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ActionProvider,
|
||||
ValidationProvider,
|
||||
VisibilityProvider,
|
||||
Renderer,
|
||||
defineRegistry,
|
||||
useStateStore,
|
||||
} from "@json-render/vue";
|
||||
import { catalog } from "./lib/catalog";
|
||||
import { components } from "./lib/registry";
|
||||
import { demoSpec } from "./lib/spec";
|
||||
|
||||
// Access the state store provided by the parent StateProvider
|
||||
const { get, set } = useStateStore();
|
||||
|
||||
// Build registry — include stub actions to satisfy catalog types.
|
||||
// The actual logic runs in the handlers below, which have direct
|
||||
// access to the state store.
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components,
|
||||
actions: {
|
||||
increment: async () => {},
|
||||
decrement: async () => {},
|
||||
reset: async () => {},
|
||||
toggleItem: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
// Action handlers — close over the state store's get/set so they
|
||||
// can read and write state directly without needing an external store.
|
||||
const handlers = {
|
||||
increment: async () => {
|
||||
set("/count", Number(get("/count") || 0) + 1);
|
||||
},
|
||||
decrement: async () => {
|
||||
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
|
||||
},
|
||||
reset: async () => {
|
||||
set("/count", 0);
|
||||
},
|
||||
toggleItem: async (params: Record<string, unknown>) => {
|
||||
const index = params.index as number;
|
||||
const todos = (
|
||||
get("/todos") as Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
}>
|
||||
).slice();
|
||||
const item = todos[index];
|
||||
if (item) {
|
||||
todos[index] = { ...item, completed: !item.completed };
|
||||
}
|
||||
set("/todos", todos);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionProvider :handlers="handlers">
|
||||
<VisibilityProvider>
|
||||
<ValidationProvider>
|
||||
<Renderer :spec="demoSpec" :registry="registry" />
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</ActionProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
export const catalog = schema.createCatalog({
|
||||
components: {
|
||||
Stack: {
|
||||
props: z.object({
|
||||
gap: z.number().optional(),
|
||||
padding: z.number().optional(),
|
||||
direction: z.enum(["vertical", "horizontal"]).optional(),
|
||||
align: z.enum(["start", "center", "end"]).optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Layout container that stacks children vertically or horizontally",
|
||||
},
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "A card container with optional title and subtitle",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
|
||||
weight: z.enum(["normal", "medium", "bold"]).optional(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Displays a text string",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A clickable button that emits a 'press' event",
|
||||
},
|
||||
Badge: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
color: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A small badge/tag label",
|
||||
},
|
||||
ListItem: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
completed: z.boolean().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A single item in a list",
|
||||
},
|
||||
Input: {
|
||||
props: z.object({
|
||||
value: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "A text input field that supports two-way state binding",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
increment: {
|
||||
params: z.object({}),
|
||||
description: "Increment the counter by 1",
|
||||
},
|
||||
decrement: {
|
||||
params: z.object({}),
|
||||
description: "Decrement the counter by 1",
|
||||
},
|
||||
reset: {
|
||||
params: z.object({}),
|
||||
description: "Reset the counter to 0",
|
||||
},
|
||||
toggleItem: {
|
||||
params: z.object({
|
||||
index: z.number(),
|
||||
}),
|
||||
description: "Toggle the completed state of a todo item",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type AppCatalog = typeof catalog;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { h } from "vue";
|
||||
import type { Components } from "@json-render/vue";
|
||||
import { useBoundProp } from "@json-render/vue";
|
||||
import type { AppCatalog } from "./catalog";
|
||||
|
||||
export const components: Components<AppCatalog> = {
|
||||
Stack: ({ props, children }) => {
|
||||
const isHorizontal = props.direction === "horizontal";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: isHorizontal ? "row" : "column",
|
||||
gap: props.gap ? `${props.gap}px` : undefined,
|
||||
padding: props.padding ? `${props.padding}px` : undefined,
|
||||
alignItems: props.align ?? (isHorizontal ? "center" : "stretch"),
|
||||
},
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
|
||||
Card: ({ props, children }) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid #e5e7eb",
|
||||
padding: "20px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
|
||||
},
|
||||
},
|
||||
[
|
||||
props.title &&
|
||||
h("div", { style: { marginBottom: "4px" } }, [
|
||||
h(
|
||||
"h2",
|
||||
{
|
||||
style: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
props.title,
|
||||
),
|
||||
]),
|
||||
props.subtitle &&
|
||||
h(
|
||||
"p",
|
||||
{
|
||||
style: {
|
||||
fontSize: "13px",
|
||||
color: "#6b7280",
|
||||
margin: "0 0 12px 0",
|
||||
},
|
||||
},
|
||||
props.subtitle,
|
||||
),
|
||||
children,
|
||||
],
|
||||
),
|
||||
|
||||
Text: ({ props }) => {
|
||||
const sizeMap: Record<string, string> = {
|
||||
sm: "12px",
|
||||
md: "14px",
|
||||
lg: "16px",
|
||||
xl: "24px",
|
||||
};
|
||||
const weightMap: Record<string, string> = {
|
||||
normal: "400",
|
||||
medium: "500",
|
||||
bold: "700",
|
||||
};
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
fontSize: sizeMap[props.size ?? "md"] ?? "14px",
|
||||
fontWeight: weightMap[props.weight ?? "normal"] ?? "400",
|
||||
color: props.color ?? "#111827",
|
||||
},
|
||||
},
|
||||
String(props.content ?? ""),
|
||||
);
|
||||
},
|
||||
|
||||
Button: ({ props, emit }) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit("press"),
|
||||
style: {
|
||||
padding: "8px 16px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
cursor: props.disabled ? "not-allowed" : "pointer",
|
||||
fontWeight: "500",
|
||||
fontSize: "14px",
|
||||
transition: "background 0.15s",
|
||||
opacity: props.disabled ? "0.5" : "1",
|
||||
backgroundColor:
|
||||
props.variant === "danger"
|
||||
? "#fee2e2"
|
||||
: props.variant === "secondary"
|
||||
? "#f3f4f6"
|
||||
: "#3b82f6",
|
||||
color:
|
||||
props.variant === "danger"
|
||||
? "#dc2626"
|
||||
: props.variant === "secondary"
|
||||
? "#374151"
|
||||
: "white",
|
||||
},
|
||||
},
|
||||
props.label,
|
||||
),
|
||||
|
||||
Badge: ({ props }) =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
display: "inline-block",
|
||||
padding: "4px 12px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "13px",
|
||||
fontWeight: "500",
|
||||
backgroundColor: props.color ? `${props.color}20` : "#e0f2fe",
|
||||
color: props.color ?? "#0369a1",
|
||||
border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`,
|
||||
},
|
||||
},
|
||||
props.label,
|
||||
),
|
||||
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return h("input", {
|
||||
value: value ?? "",
|
||||
placeholder: props.placeholder as string | undefined,
|
||||
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
|
||||
style: {
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
ListItem: ({ props, emit }) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
onClick: () => emit("press"),
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb",
|
||||
border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`,
|
||||
},
|
||||
},
|
||||
[
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`,
|
||||
backgroundColor: props.completed ? "#16a34a" : "transparent",
|
||||
flexShrink: "0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "11px",
|
||||
color: "white",
|
||||
},
|
||||
},
|
||||
props.completed ? "✓" : "",
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
fontSize: "14px",
|
||||
color: props.completed ? "#6b7280" : "#111827",
|
||||
textDecoration: props.completed ? "line-through" : "none",
|
||||
},
|
||||
},
|
||||
props.title,
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export const demoSpec: Spec = {
|
||||
root: "root",
|
||||
state: {
|
||||
count: 0,
|
||||
name: "",
|
||||
todos: [
|
||||
{ id: 1, title: "Learn Vue 3", completed: true },
|
||||
{ id: 2, title: "Try @json-render/vue", completed: false },
|
||||
{ id: 3, title: "Build something awesome", completed: false },
|
||||
],
|
||||
},
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { gap: 24, padding: 24, direction: "vertical" },
|
||||
children: [
|
||||
"header",
|
||||
"counter-card",
|
||||
"milestone-badge",
|
||||
"todos-card",
|
||||
"input-card",
|
||||
],
|
||||
},
|
||||
header: {
|
||||
type: "Text",
|
||||
props: {
|
||||
content: "@json-render/vue demo",
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Counter card ----
|
||||
"counter-card": {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Counter",
|
||||
subtitle: "Click the buttons to change the count",
|
||||
},
|
||||
children: ["counter-body"],
|
||||
},
|
||||
"counter-body": {
|
||||
type: "Stack",
|
||||
props: { gap: 12, direction: "horizontal", align: "center" },
|
||||
children: [
|
||||
"decrement-btn",
|
||||
"counter-value",
|
||||
"increment-btn",
|
||||
"reset-btn",
|
||||
],
|
||||
},
|
||||
"decrement-btn": {
|
||||
type: "Button",
|
||||
props: { label: "−", variant: "secondary" },
|
||||
on: { press: { action: "decrement" } },
|
||||
},
|
||||
"counter-value": {
|
||||
type: "Text",
|
||||
props: {
|
||||
content: { $state: "/count" },
|
||||
size: "xl",
|
||||
weight: "bold",
|
||||
},
|
||||
},
|
||||
"increment-btn": {
|
||||
type: "Button",
|
||||
props: { label: "+", variant: "primary" },
|
||||
on: { press: { action: "increment" } },
|
||||
},
|
||||
"reset-btn": {
|
||||
type: "Button",
|
||||
props: { label: "Reset", variant: "danger" },
|
||||
on: { press: { action: "reset" } },
|
||||
},
|
||||
|
||||
// ---- Milestone badge (visible only when count >= 10) ----
|
||||
"milestone-badge": {
|
||||
type: "Badge",
|
||||
props: { label: "Milestone reached: 10!", color: "#10b981" },
|
||||
visible: { $state: "/count", gte: 10 },
|
||||
},
|
||||
|
||||
// ---- Todos card ----
|
||||
"todos-card": {
|
||||
type: "Card",
|
||||
props: { title: "Todo List", subtitle: "Your tasks" },
|
||||
children: ["todos-list"],
|
||||
},
|
||||
"todos-list": {
|
||||
type: "Stack",
|
||||
props: { gap: 8, direction: "vertical" },
|
||||
repeat: { statePath: "/todos", key: "id" },
|
||||
children: ["todo-item"],
|
||||
},
|
||||
"todo-item": {
|
||||
type: "ListItem",
|
||||
props: {
|
||||
title: { $item: "title" },
|
||||
completed: { $item: "completed" },
|
||||
},
|
||||
on: {
|
||||
press: { action: "toggleItem", params: { index: { $index: true } } },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Bound Input card (useBoundProp demo) ----
|
||||
"input-card": {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Bound Input",
|
||||
subtitle: "Type to update state — the display text reacts in real time",
|
||||
},
|
||||
children: ["input-body"],
|
||||
},
|
||||
"input-body": {
|
||||
type: "Stack",
|
||||
props: { gap: 12, direction: "vertical" },
|
||||
children: ["name-input", "name-display"],
|
||||
},
|
||||
"name-input": {
|
||||
type: "Input",
|
||||
props: {
|
||||
value: { $bindState: "/name" },
|
||||
placeholder: "Enter your name…",
|
||||
},
|
||||
},
|
||||
"name-display": {
|
||||
type: "Text",
|
||||
props: {
|
||||
content: { $state: "/name" },
|
||||
size: "md",
|
||||
color: "#6b7280",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --concurrency 15",
|
||||
"dev": "turbo run dev --concurrency 20",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"type-check": "turbo run check-types",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -72,7 +72,11 @@ export {
|
||||
} from "./visibility";
|
||||
|
||||
// Prop Expressions
|
||||
export type { PropExpression, PropResolutionContext } from "./props";
|
||||
export type {
|
||||
PropExpression,
|
||||
PropResolutionContext,
|
||||
ComputedFunction,
|
||||
} from "./props";
|
||||
|
||||
export {
|
||||
resolvePropValue,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
resolvePropValue,
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
_resetWarnedComputedFns,
|
||||
_resetWarnedTemplatePaths,
|
||||
} from "./props";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
|
||||
expect(resolveActionParam(null, ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// $computed expressions
|
||||
// =============================================================================
|
||||
|
||||
describe("$computed expressions", () => {
|
||||
it("calls a registered function with resolved args", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
|
||||
functions: {
|
||||
fullName: (args) => `${args.first} ${args.last}`,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolvePropValue(
|
||||
{
|
||||
$computed: "fullName",
|
||||
args: {
|
||||
first: { $state: "/form/firstName" },
|
||||
last: { $state: "/form/lastName" },
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("calls function with no args", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: {},
|
||||
functions: {
|
||||
timestamp: () => 1234567890,
|
||||
},
|
||||
};
|
||||
expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown function", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: {},
|
||||
functions: {},
|
||||
};
|
||||
expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no functions in context", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {} };
|
||||
expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deduplicates warnings for the same unknown function", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
|
||||
resolvePropValue({ $computed: "dedupTest" }, ctx);
|
||||
resolvePropValue({ $computed: "dedupTest" }, ctx);
|
||||
const calls = warnSpy.mock.calls.filter((c) =>
|
||||
String(c[0]).includes("dedupTest"),
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resolves nested expressions in args", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { active: true, values: { a: 10, b: 20 } },
|
||||
functions: {
|
||||
conditionalSum: (args) => {
|
||||
if (args.enabled) return (args.x as number) + (args.y as number);
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolvePropValue(
|
||||
{
|
||||
$computed: "conditionalSum",
|
||||
args: {
|
||||
enabled: { $state: "/active" },
|
||||
x: { $state: "/values/a" },
|
||||
y: { $state: "/values/b" },
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// $template expressions
|
||||
// =============================================================================
|
||||
|
||||
describe("$template expressions", () => {
|
||||
it("interpolates state values into a string", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { user: { name: "Alice" }, count: 3 },
|
||||
};
|
||||
expect(
|
||||
resolvePropValue(
|
||||
{ $template: "Hello, ${/user/name}! You have ${/count} messages." },
|
||||
ctx,
|
||||
),
|
||||
).toBe("Hello, Alice! You have 3 messages.");
|
||||
});
|
||||
|
||||
it("replaces missing paths with empty string", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {} };
|
||||
expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
|
||||
});
|
||||
|
||||
it("handles template with no interpolations", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {} };
|
||||
expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
|
||||
"No variables here",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles multiple references to the same path", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { x: "A" },
|
||||
};
|
||||
expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
|
||||
"A and A",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts non-string values to strings", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { num: 42, bool: true },
|
||||
};
|
||||
expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
|
||||
"42 is true",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when path does not start with /", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
expect(result).toBe("Hi Bob!");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('$template path "name"'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
});
|
||||
|
||||
it("deduplicates warnings for the same $template path", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
const calls = warnSpy.mock.calls.filter((c) =>
|
||||
String(c[0]).includes('$template path "name"'),
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
});
|
||||
});
|
||||
|
||||
+100
-1
@@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
|
||||
* repeat item — resolves via `repeatBasePath + path` and exposes the
|
||||
* absolute state path for write-back.
|
||||
* - `{ $cond, $then, $else }` conditionally picks a value
|
||||
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
|
||||
* registered function with resolved args and returns the result
|
||||
* - `{ $template: string }` interpolates `${/path}` references in the
|
||||
* string with values from the state model
|
||||
* - Any other value is a literal (passthrough)
|
||||
*/
|
||||
export type PropExpression<T = unknown> =
|
||||
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
|
||||
$cond: VisibilityCondition;
|
||||
$then: PropExpression<T>;
|
||||
$else: PropExpression<T>;
|
||||
};
|
||||
}
|
||||
| { $computed: string; args?: Record<string, unknown> }
|
||||
| { $template: string };
|
||||
|
||||
/**
|
||||
* Function signature for `$computed` expressions.
|
||||
* Receives a record of resolved argument values and returns a computed result.
|
||||
*/
|
||||
export type ComputedFunction = (args: Record<string, unknown>) => unknown;
|
||||
|
||||
/**
|
||||
* Context for resolving prop expressions.
|
||||
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
|
||||
export interface PropResolutionContext extends VisibilityContext {
|
||||
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
|
||||
repeatBasePath?: string;
|
||||
/** Named functions available for `$computed` expressions. */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -110,6 +124,47 @@ function isCondExpression(
|
||||
);
|
||||
}
|
||||
|
||||
function isComputedExpression(
|
||||
value: unknown,
|
||||
): value is { $computed: string; args?: Record<string, unknown> } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"$computed" in value &&
|
||||
typeof (value as Record<string, unknown>).$computed === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isTemplateExpression(value: unknown): value is { $template: string } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"$template" in value &&
|
||||
typeof (value as Record<string, unknown>).$template === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// Module-level set to avoid spamming console.warn on every render for the same
|
||||
// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
|
||||
// new names are no longer deduplicated (warnings still fire) but the set stops
|
||||
// growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
|
||||
const WARNED_COMPUTED_MAX = 100;
|
||||
const warnedComputedFns = new Set<string>();
|
||||
|
||||
/** @internal Test-only: clear the deduplication set for $computed warnings. */
|
||||
export function _resetWarnedComputedFns(): void {
|
||||
warnedComputedFns.clear();
|
||||
}
|
||||
|
||||
// Same deduplication pattern for $template paths that don't start with "/".
|
||||
const WARNED_TEMPLATE_MAX = 100;
|
||||
const warnedTemplatePaths = new Set<string>();
|
||||
|
||||
/** @internal Test-only: clear the deduplication set for $template warnings. */
|
||||
export function _resetWarnedTemplatePaths(): void {
|
||||
warnedTemplatePaths.clear();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Prop Expression Resolution
|
||||
// =============================================================================
|
||||
@@ -193,6 +248,50 @@ export function resolvePropValue(
|
||||
return resolvePropValue(result ? value.$then : value.$else, ctx);
|
||||
}
|
||||
|
||||
// $computed: call a registered function with resolved args
|
||||
if (isComputedExpression(value)) {
|
||||
const fn = ctx.functions?.[value.$computed];
|
||||
if (!fn) {
|
||||
if (!warnedComputedFns.has(value.$computed)) {
|
||||
if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
|
||||
warnedComputedFns.add(value.$computed);
|
||||
}
|
||||
console.warn(`Unknown $computed function: "${value.$computed}"`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const resolvedArgs: Record<string, unknown> = {};
|
||||
if (value.args) {
|
||||
for (const [key, arg] of Object.entries(value.args)) {
|
||||
resolvedArgs[key] = resolvePropValue(arg, ctx);
|
||||
}
|
||||
}
|
||||
return fn(resolvedArgs);
|
||||
}
|
||||
|
||||
// $template: interpolate ${/path} references with state values
|
||||
if (isTemplateExpression(value)) {
|
||||
return value.$template.replace(
|
||||
/\$\{([^}]+)\}/g,
|
||||
(_match, rawPath: string) => {
|
||||
let path = rawPath;
|
||||
if (!path.startsWith("/")) {
|
||||
if (!warnedTemplatePaths.has(path)) {
|
||||
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
|
||||
warnedTemplatePaths.add(path);
|
||||
}
|
||||
console.warn(
|
||||
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
|
||||
);
|
||||
}
|
||||
path = "/" + path;
|
||||
}
|
||||
const resolved = getByPath(ctx.stateModel, path);
|
||||
return resolved != null ? String(resolved) : "";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Arrays: resolve each element
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolvePropValue(item, ctx));
|
||||
|
||||
@@ -900,6 +900,31 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
"Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
|
||||
);
|
||||
lines.push(
|
||||
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// $computed section — only emit when catalog defines functions
|
||||
const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
|
||||
if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
|
||||
lines.push(
|
||||
'5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
|
||||
);
|
||||
lines.push(
|
||||
' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
|
||||
);
|
||||
lines.push(" Available functions:");
|
||||
for (const name of Object.keys(
|
||||
catalogFunctions as Record<string, unknown>,
|
||||
)) {
|
||||
lines.push(` - ${name}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Validation section — only emit when at least one component has a `checks` prop
|
||||
const hasChecksComponents = allComponents
|
||||
@@ -930,7 +955,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
lines.push(" - numeric — value must be a number");
|
||||
lines.push(" - url — valid URL format");
|
||||
lines.push(
|
||||
' - matches — must equal another field (args: { "other": "value" })',
|
||||
' - matches — must equal another field (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Example:");
|
||||
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// State watchers section — only emit when actions are available (watchers
|
||||
// trigger actions, so the section is irrelevant without them).
|
||||
if (hasCustomActions || hasBuiltInActions) {
|
||||
lines.push("STATE WATCHERS:");
|
||||
lines.push(
|
||||
"Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
|
||||
);
|
||||
lines.push(
|
||||
"Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Example (cascading select — country changes trigger city loading):",
|
||||
);
|
||||
lines.push(
|
||||
` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
|
||||
);
|
||||
lines.push(
|
||||
"IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Rules
|
||||
lines.push("RULES:");
|
||||
const baseRules =
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Spec } from "./types";
|
||||
import { validateSpec, autoFixSpec } from "./spec-validator";
|
||||
|
||||
// =============================================================================
|
||||
// validateSpec
|
||||
// =============================================================================
|
||||
|
||||
describe("validateSpec", () => {
|
||||
it("returns valid for a correct spec", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["child1"] },
|
||||
child1: { type: "Text", props: { text: "hello" }, children: [] },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects missing root", () => {
|
||||
const spec = {
|
||||
root: "",
|
||||
elements: { a: { type: "T", props: {}, children: [] } },
|
||||
} as Spec;
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects root_not_found", () => {
|
||||
const spec: Spec = {
|
||||
root: "missing",
|
||||
elements: { a: { type: "T", props: {}, children: [] } },
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects empty spec", () => {
|
||||
const spec: Spec = { root: "r", elements: {} };
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects missing_child", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["nonexistent"] },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects visible_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { visible: { $state: "/show" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects on_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Button",
|
||||
props: { on: { press: { action: "doSomething" } } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects repeat_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { repeat: { statePath: "/items" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects watch_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Select",
|
||||
props: {
|
||||
watch: {
|
||||
"/form/country": { action: "loadCities" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
|
||||
expect(watchIssue).toBeDefined();
|
||||
expect(watchIssue!.elementKey).toBe("root");
|
||||
});
|
||||
|
||||
it("detects orphaned elements when checkOrphans is true", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: [] },
|
||||
orphan: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec, { checkOrphans: true });
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// autoFixSpec
|
||||
// =============================================================================
|
||||
|
||||
describe("autoFixSpec", () => {
|
||||
it("moves visible from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { text: "hi", visible: { $state: "/show" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).visible,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
|
||||
expect(fixes.some((f) => f.includes("visible"))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves on from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Button",
|
||||
props: { label: "OK", on: { press: { action: "submit" } } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).on,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
|
||||
expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves repeat from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { repeat: { statePath: "/items" } },
|
||||
children: ["child"],
|
||||
},
|
||||
child: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).repeat,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
|
||||
expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves watch from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
watch: {
|
||||
"/form/country": { action: "loadCities" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).watch,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.watch).toEqual({
|
||||
"/form/country": { action: "loadCities" },
|
||||
});
|
||||
expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no fixes for a correct spec", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical" },
|
||||
children: [],
|
||||
watch: { "/x": { action: "y" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { fixes } = autoFixSpec(spec);
|
||||
expect(fixes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ export interface SpecIssue {
|
||||
| "orphaned_element"
|
||||
| "empty_spec"
|
||||
| "on_in_props"
|
||||
| "repeat_in_props";
|
||||
| "repeat_in_props"
|
||||
| "watch_in_props";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +152,16 @@ export function validateSpec(
|
||||
code: "repeat_in_props",
|
||||
});
|
||||
}
|
||||
|
||||
// 3e. `watch` inside props (should be a top-level field)
|
||||
if (props && "watch" in props && props.watch !== undefined) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
|
||||
elementKey: key,
|
||||
code: "watch_in_props",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Orphaned elements (optional)
|
||||
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
|
||||
fixes.push(`Moved "repeat" from props to element level on "${key}".`);
|
||||
}
|
||||
|
||||
currentProps = fixed.props as Record<string, unknown> | undefined;
|
||||
if (
|
||||
currentProps &&
|
||||
"watch" in currentProps &&
|
||||
currentProps.watch !== undefined
|
||||
) {
|
||||
const { watch, ...restProps } = currentProps;
|
||||
fixed = {
|
||||
...fixed,
|
||||
props: restProps,
|
||||
watch: watch as UIElement["watch"],
|
||||
};
|
||||
fixes.push(`Moved "watch" from props to element level on "${key}".`);
|
||||
}
|
||||
|
||||
fixedElements[key] = fixed;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface UIElement<
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>;
|
||||
/** Repeat children once per item in a state array */
|
||||
repeat?: { statePath: string; key?: string };
|
||||
/**
|
||||
* State watchers — maps JSON Pointer state paths to action bindings.
|
||||
* When the value at a watched path changes, the bound actions fire.
|
||||
* Useful for cascading dependencies (e.g. country → city option loading).
|
||||
*/
|
||||
watch?: Record<string, ActionBinding | ActionBinding[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("equalTo", () => {
|
||||
it("passes when values are equal", () => {
|
||||
expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when values differ", () => {
|
||||
expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lessThan", () => {
|
||||
it("passes when value is less than other", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when value equals other", () => {
|
||||
expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when value is greater than other", () => {
|
||||
expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces numeric string vs number", () => {
|
||||
expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it("fails coercion when non-numeric string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes for string comparison (ISO dates)", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.lessThan("2026-01-01", {
|
||||
other: "2026-06-15",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails for equal strings", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.lessThan("2026-01-01", {
|
||||
other: "2026-01-01",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is null", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when value is null", () => {
|
||||
expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is undefined", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThan", () => {
|
||||
it("passes when value is greater than other", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when value equals other", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when value is less than other", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces numeric string vs number", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails coercion when non-numeric string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes for string comparison (ISO dates)", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.greaterThan("2026-06-15", {
|
||||
other: "2026-01-01",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails for lesser strings", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.greaterThan("2026-01-01", {
|
||||
other: "2026-06-15",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is null", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when value is undefined", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.greaterThan(undefined, { other: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is null", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredIf", () => {
|
||||
it("passes when condition is falsy (field not required)", () => {
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf("", { field: undefined }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when condition is truthy and value is empty", () => {
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf(null, { field: "yes" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf(undefined, { field: 1 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes when condition is truthy and value is present", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf("hello", { field: true }),
|
||||
).toBe(true);
|
||||
expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runValidationCheck", () => {
|
||||
@@ -391,6 +611,22 @@ describe("check helper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric", () => {
|
||||
it("creates numeric check with default message", () => {
|
||||
const c = check.numeric();
|
||||
|
||||
expect(c.type).toBe("numeric");
|
||||
expect(c.message).toBe("Must be a number");
|
||||
});
|
||||
|
||||
it("creates numeric check with custom message", () => {
|
||||
const c = check.numeric("Numbers only");
|
||||
|
||||
expect(c.type).toBe("numeric");
|
||||
expect(c.message).toBe("Numbers only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matches", () => {
|
||||
it("creates matches check with path reference", () => {
|
||||
const c = check.matches("/password", "Passwords must match");
|
||||
@@ -400,4 +636,109 @@ describe("check helper", () => {
|
||||
expect(c.message).toBe("Passwords must match");
|
||||
});
|
||||
});
|
||||
|
||||
describe("equalTo", () => {
|
||||
it("creates equalTo check with path reference", () => {
|
||||
const c = check.equalTo("/email", "Emails must match");
|
||||
|
||||
expect(c.type).toBe("equalTo");
|
||||
expect(c.args).toEqual({ other: { $state: "/email" } });
|
||||
expect(c.message).toBe("Emails must match");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lessThan", () => {
|
||||
it("creates lessThan check with path reference", () => {
|
||||
const c = check.lessThan("/maxValue", "Must be less");
|
||||
|
||||
expect(c.type).toBe("lessThan");
|
||||
expect(c.args).toEqual({ other: { $state: "/maxValue" } });
|
||||
expect(c.message).toBe("Must be less");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThan", () => {
|
||||
it("creates greaterThan check with path reference", () => {
|
||||
const c = check.greaterThan("/minValue");
|
||||
|
||||
expect(c.type).toBe("greaterThan");
|
||||
expect(c.args).toEqual({ other: { $state: "/minValue" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredIf", () => {
|
||||
it("creates requiredIf check with path reference", () => {
|
||||
const c = check.requiredIf("/toggle", "Required when toggle is on");
|
||||
|
||||
expect(c.type).toBe("requiredIf");
|
||||
expect(c.args).toEqual({ field: { $state: "/toggle" } });
|
||||
expect(c.message).toBe("Required when toggle is on");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Deep arg resolution in runValidationCheck
|
||||
// =============================================================================
|
||||
|
||||
describe("deep arg resolution", () => {
|
||||
it("resolves nested $state refs in validation args", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "matches",
|
||||
args: { other: { $state: "/form/password" } },
|
||||
message: "Passwords must match",
|
||||
},
|
||||
{
|
||||
value: "secret123",
|
||||
stateModel: { form: { password: "secret123" } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves $state in cross-field lessThan check", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "lessThan",
|
||||
args: { other: { $state: "/form/maxPrice" } },
|
||||
message: "Must be less than max price",
|
||||
},
|
||||
{
|
||||
value: 50,
|
||||
stateModel: { form: { maxPrice: 100 } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves $state in requiredIf check", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/enableEmail" } },
|
||||
message: "Email is required",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
stateModel: { form: { enableEmail: true } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("passes requiredIf when condition is false", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/enableEmail" } },
|
||||
message: "Email is required",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
stateModel: { form: { enableEmail: false } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import type { DynamicValue, StateModel, VisibilityCondition } from "./types";
|
||||
import { DynamicValueSchema, resolveDynamicValue } from "./types";
|
||||
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
|
||||
import { resolvePropValue } from "./props";
|
||||
|
||||
/**
|
||||
* Validation check definition
|
||||
@@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const matchesImpl: ValidationFunction = (
|
||||
value: unknown,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
const other = args?.other;
|
||||
return value === other;
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in validation functions
|
||||
*/
|
||||
@@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
|
||||
/**
|
||||
* Check if value matches another field
|
||||
*/
|
||||
matches: (value: unknown, args?: Record<string, unknown>) => {
|
||||
matches: matchesImpl,
|
||||
|
||||
/**
|
||||
* Alias for matches with a more descriptive name for cross-field equality
|
||||
*/
|
||||
equalTo: matchesImpl,
|
||||
|
||||
/**
|
||||
* Check if value is less than another field's value.
|
||||
* Supports numbers, strings (useful for ISO date comparison), and
|
||||
* cross-type numeric coercion (e.g. string "3" vs number 5).
|
||||
*/
|
||||
lessThan: (value: unknown, args?: Record<string, unknown>) => {
|
||||
const other = args?.other;
|
||||
return value === other;
|
||||
if (value == null || other == null || value === "" || other === "")
|
||||
return false;
|
||||
if (typeof value === "number" && typeof other === "number")
|
||||
return value < other;
|
||||
if (typeof value === "string" && typeof other === "string")
|
||||
return value < other;
|
||||
const numVal = Number(value);
|
||||
const numOther = Number(other);
|
||||
if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if value is greater than another field's value.
|
||||
* Supports numbers, strings (useful for ISO date comparison), and
|
||||
* cross-type numeric coercion (e.g. string "7" vs number 5).
|
||||
*/
|
||||
greaterThan: (value: unknown, args?: Record<string, unknown>) => {
|
||||
const other = args?.other;
|
||||
if (value == null || other == null || value === "" || other === "")
|
||||
return false;
|
||||
if (typeof value === "number" && typeof other === "number")
|
||||
return value > other;
|
||||
if (typeof value === "string" && typeof other === "string")
|
||||
return value > other;
|
||||
const numVal = Number(value);
|
||||
const numOther = Number(other);
|
||||
if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Required only when a condition is met.
|
||||
* Uses JS truthiness: 0, false, "", null, and undefined are all
|
||||
* treated as "condition not met" (field not required), matching
|
||||
* the visibility system's bare-condition semantics.
|
||||
*/
|
||||
requiredIf: (value: unknown, args?: Record<string, unknown>) => {
|
||||
const condition = args?.field;
|
||||
if (!condition) return true;
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,11 +273,12 @@ export function runValidationCheck(
|
||||
): ValidationCheckResult {
|
||||
const { value, stateModel, customFunctions } = ctx;
|
||||
|
||||
// Resolve args
|
||||
// Resolve args using resolvePropValue so nested $state refs (and any other
|
||||
// prop expressions) are handled consistently with the rest of the system.
|
||||
const resolvedArgs: Record<string, unknown> = {};
|
||||
if (check.args) {
|
||||
for (const [key, argValue] of Object.entries(check.args)) {
|
||||
resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
|
||||
resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +391,11 @@ export const check = {
|
||||
message,
|
||||
}),
|
||||
|
||||
numeric: (message = "Must be a number"): ValidationCheck => ({
|
||||
type: "numeric",
|
||||
message,
|
||||
}),
|
||||
|
||||
matches: (
|
||||
otherPath: string,
|
||||
message = "Fields must match",
|
||||
@@ -334,4 +404,34 @@ export const check = {
|
||||
args: { other: { $state: otherPath } },
|
||||
message,
|
||||
}),
|
||||
|
||||
equalTo: (
|
||||
otherPath: string,
|
||||
message = "Fields must match",
|
||||
): ValidationCheck => ({
|
||||
type: "equalTo",
|
||||
args: { other: { $state: otherPath } },
|
||||
message,
|
||||
}),
|
||||
|
||||
lessThan: (otherPath: string, message?: string): ValidationCheck => ({
|
||||
type: "lessThan",
|
||||
args: { other: { $state: otherPath } },
|
||||
message: message ?? "Must be less than the compared field",
|
||||
}),
|
||||
|
||||
greaterThan: (otherPath: string, message?: string): ValidationCheck => ({
|
||||
type: "greaterThan",
|
||||
args: { other: { $state: otherPath } },
|
||||
message: message ?? "Must be greater than the compared field",
|
||||
}),
|
||||
|
||||
requiredIf: (
|
||||
fieldPath: string,
|
||||
message = "This field is required",
|
||||
): ValidationCheck => ({
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: fieldPath } },
|
||||
message,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @json-render/jotai
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @json-render/react-native
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
@@ -56,10 +56,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*"
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"react-native": "0.83.1",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/schema.ts", "src/catalog.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "react-native", "@json-render/core", "zod"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @json-render/react-pdf
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
@@ -61,10 +61,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@react-pdf/renderer": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @internal/react-state
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@internal/react-state",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Shared React state context for json-render renderer packages",
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# @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
|
||||
|
||||
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor 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.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
@@ -50,10 +50,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*"
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ResolvedAction,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state";
|
||||
import { useOptionalValidation } from "./validation";
|
||||
|
||||
/**
|
||||
* Generate a unique ID for use with the "$id" token.
|
||||
@@ -137,6 +138,7 @@ export function ActionProvider({
|
||||
children,
|
||||
}: ActionProviderProps) {
|
||||
const { get, set, getSnapshot } = useStateStore();
|
||||
const validation = useOptionalValidation();
|
||||
|
||||
const [handlers, setHandlers] =
|
||||
useState<Record<string, ActionHandler>>(initialHandlers);
|
||||
@@ -227,13 +229,40 @@ export function ActionProvider({
|
||||
if (previousScreen) {
|
||||
set("/currentScreen", previousScreen);
|
||||
} else {
|
||||
// Sentinel empty string = clear currentScreen (return to default)
|
||||
set("/currentScreen", undefined);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: validateForm triggers validateAll from the ValidationProvider
|
||||
// and writes the result to a state path (default: /formValidation).
|
||||
// IMPORTANT: validateAll() is synchronous — it runs all registered field
|
||||
// validations and returns immediately. This guarantees that the next action
|
||||
// in a sequential list (e.g. [validateForm, submitForm]) can read the
|
||||
// validation result from state without awaiting an extra tick.
|
||||
if (resolved.action === "validateForm") {
|
||||
const validateAll = validation?.validateAll;
|
||||
if (!validateAll) {
|
||||
console.warn(
|
||||
"validateForm action was dispatched but no ValidationProvider is connected. " +
|
||||
"Ensure ValidationProvider is rendered inside the provider tree.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const valid = validateAll();
|
||||
const 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;
|
||||
}
|
||||
|
||||
const handler = handlers[resolved.action];
|
||||
|
||||
if (!handler) {
|
||||
@@ -300,7 +329,7 @@ export function ActionProvider({
|
||||
});
|
||||
}
|
||||
},
|
||||
[handlers, get, set, getSnapshot, navigate],
|
||||
[handlers, get, set, getSnapshot, navigate, validation],
|
||||
);
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -130,15 +130,14 @@ export function ValidationProvider({
|
||||
customFunctions = {},
|
||||
children,
|
||||
}: ValidationProviderProps) {
|
||||
const { state } = useStateStore();
|
||||
// Keep a ref to the latest state so `validate` doesn't change on every
|
||||
// state update — preventing the entire validation context from churning.
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const { state, getSnapshot } = useStateStore();
|
||||
|
||||
const [fieldStates, setFieldStates] = useState<
|
||||
Record<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>
|
||||
>({});
|
||||
@@ -160,8 +159,10 @@ export function ValidationProvider({
|
||||
|
||||
const validate = useCallback(
|
||||
(path: string, config: ValidationConfig): ValidationResult => {
|
||||
// Walk the nested state object using JSON Pointer segments
|
||||
const currentState = stateRef.current;
|
||||
// Read from the store directly so validation sees values written in the
|
||||
// same synchronous handler (e.g. setValue then validate in onChange).
|
||||
// Using React state would return the stale pre-render snapshot.
|
||||
const currentState = getSnapshot();
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
let value: unknown = currentState;
|
||||
for (const seg of segments) {
|
||||
@@ -178,37 +179,39 @@ 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;
|
||||
},
|
||||
[customFunctions],
|
||||
[customFunctions, getSnapshot],
|
||||
);
|
||||
|
||||
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(() => {
|
||||
@@ -227,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,
|
||||
@@ -236,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,
|
||||
@@ -263,6 +272,14 @@ export function useValidation(): ValidationContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing variant of useValidation.
|
||||
* Returns null when no ValidationProvider is present.
|
||||
*/
|
||||
export function useOptionalValidation(): ValidationContextValue | null {
|
||||
return useContext(ValidationContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get validation state for a field
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { render, act, fireEvent, screen } from "@testing-library/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
type ComponentRenderProps,
|
||||
} from "./renderer";
|
||||
import { useStateStore } from "./contexts/state";
|
||||
import { useFieldValidation } from "./contexts/validation";
|
||||
import { useBoundProp } from "./hooks";
|
||||
|
||||
// =============================================================================
|
||||
// Stub components
|
||||
// =============================================================================
|
||||
|
||||
function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
|
||||
return (
|
||||
<button data-testid="btn" onClick={() => emit("press")}>
|
||||
{element.props.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
|
||||
const value = element.props.text;
|
||||
return (
|
||||
<span data-testid="text">
|
||||
{value == null
|
||||
? ""
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({
|
||||
element,
|
||||
bindings,
|
||||
}: ComponentRenderProps<{
|
||||
label?: string;
|
||||
value?: string;
|
||||
checks?: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
args?: Record<string, unknown>;
|
||||
}>;
|
||||
}>) {
|
||||
const props = element.props;
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState("");
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const config = useMemo(
|
||||
() => (hasValidation ? { checks: props.checks ?? [] } : undefined),
|
||||
[hasValidation, props.checks],
|
||||
);
|
||||
const { errors } = useFieldValidation(bindings?.value ?? "", config);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{props.label && <label>{props.label}</label>}
|
||||
<input
|
||||
data-testid="input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
{errors.length > 0 && <span data-testid="input-error">{errors[0]}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
element,
|
||||
bindings,
|
||||
}: ComponentRenderProps<{ label?: string; value?: string }>) {
|
||||
const props = element.props;
|
||||
const [boundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return <span data-testid="select-value">{boundValue ?? ""}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select stub that mirrors the real shadcn Select validation behavior:
|
||||
* calls setValue then validate() synchronously in onValueChange.
|
||||
*/
|
||||
function ValidatedSelect({
|
||||
element,
|
||||
bindings,
|
||||
emit,
|
||||
}: ComponentRenderProps<{
|
||||
label?: string;
|
||||
name?: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
checks?: Array<{
|
||||
type: string;
|
||||
message: string;
|
||||
args?: Record<string, unknown>;
|
||||
}>;
|
||||
validateOn?: "change" | "blur" | "submit";
|
||||
}>) {
|
||||
const props = element.props;
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState("");
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const validateOn = props.validateOn ?? "change";
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const config = useMemo(
|
||||
() =>
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
[hasValidation, props.checks, validateOn],
|
||||
);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
config,
|
||||
);
|
||||
|
||||
const options = props.options ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{props.label && <label>{props.label}</label>}
|
||||
<select
|
||||
data-testid={`select-${props.name ?? "default"}`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<option value="">{props.placeholder ?? "Select..."}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.length > 0 && (
|
||||
<span data-testid={`select-error-${props.name ?? "default"}`}>
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateProbe() {
|
||||
const { state } = useStateStore();
|
||||
return <pre data-testid="state-probe">{JSON.stringify(state)}</pre>;
|
||||
}
|
||||
|
||||
const registry = { Button, Text, Input: InputField, Select: SelectField };
|
||||
|
||||
function getState(): Record<string, unknown> {
|
||||
return JSON.parse(screen.getByTestId("state-probe").textContent!);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// $computed expressions in rendering
|
||||
// =============================================================================
|
||||
|
||||
describe("$computed expressions in rendering", () => {
|
||||
it("resolves a $computed prop using provided functions", async () => {
|
||||
const spec: Spec = {
|
||||
state: { first: "Jane", last: "Doe" },
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$computed: "fullName",
|
||||
args: {
|
||||
first: { $state: "/first" },
|
||||
last: { $state: "/last" },
|
||||
},
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const functions = {
|
||||
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec.state}
|
||||
functions={functions}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("renders gracefully when functions prop is omitted", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const spec: Spec = {
|
||||
state: {},
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: { $computed: "missing" },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("text").textContent).toBe("");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// $template expressions in rendering
|
||||
// =============================================================================
|
||||
|
||||
describe("$template expressions in rendering", () => {
|
||||
it("interpolates state values into a template string", () => {
|
||||
const spec: Spec = {
|
||||
state: { user: { name: "Alice" }, count: 3 },
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template: "Hello, ${/user/name}! You have ${/count} messages.",
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("text").textContent).toBe(
|
||||
"Hello, Alice! You have 3 messages.",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves missing paths to empty string", () => {
|
||||
const spec: Spec = {
|
||||
state: {},
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: { $template: "Hi ${/name}!" },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("text").textContent).toBe("Hi !");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Watchers
|
||||
// =============================================================================
|
||||
|
||||
describe("watchers (watch field)", () => {
|
||||
it("does not fire on initial render, fires when watched state changes", async () => {
|
||||
const loadCities = vi.fn(async (params: Record<string, unknown>) => {
|
||||
// no-op, just tracking the call
|
||||
});
|
||||
|
||||
const spec: Spec = {
|
||||
state: { form: { country: "" }, citiesLoaded: false },
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Button",
|
||||
props: { label: "Set Country" },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "setState",
|
||||
params: { statePath: "/form/country", value: "US" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
watcher: {
|
||||
type: "Select",
|
||||
props: { value: { $state: "/form/country" } },
|
||||
watch: {
|
||||
"/form/country": {
|
||||
action: "loadCities",
|
||||
params: { country: { $state: "/form/country" } },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add watcher as a child of a wrapper so both render
|
||||
const wrapperSpec: Spec = {
|
||||
...spec,
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
...spec.elements,
|
||||
wrapper: {
|
||||
type: "Button",
|
||||
props: { label: "wrapper" },
|
||||
children: ["main", "watcher"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Use a Stack-like wrapper -- but since we only have Button/Text/Select
|
||||
// stubs, we need a container. Let's add a simple Stack stub.
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => {
|
||||
return <div data-testid="stack">{children}</div>;
|
||||
};
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const stackSpec: Spec = {
|
||||
state: { form: { country: "" }, citiesLoaded: false },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["btn", "watcher"],
|
||||
},
|
||||
btn: {
|
||||
type: "Button",
|
||||
props: { label: "Set Country" },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "setState",
|
||||
params: { statePath: "/form/country", value: "US" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
watcher: {
|
||||
type: "Select",
|
||||
props: { value: { $state: "/form/country" } },
|
||||
watch: {
|
||||
"/form/country": {
|
||||
action: "loadCities",
|
||||
params: { country: { $state: "/form/country" } },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider
|
||||
registry={reg}
|
||||
initialState={stackSpec.state}
|
||||
handlers={{ loadCities }}
|
||||
>
|
||||
<Renderer spec={stackSpec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
// Not called on initial render
|
||||
expect(loadCities).not.toHaveBeenCalled();
|
||||
|
||||
// Change the watched state path
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("btn"));
|
||||
});
|
||||
|
||||
expect(loadCities).toHaveBeenCalledTimes(1);
|
||||
expect(loadCities).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ country: "US" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fires multiple action bindings on the same watch path", async () => {
|
||||
const action1 = vi.fn();
|
||||
const action2 = vi.fn();
|
||||
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: { value: "a" },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["btn", "watcher"],
|
||||
},
|
||||
btn: {
|
||||
type: "Button",
|
||||
props: { label: "Change" },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "setState",
|
||||
params: { statePath: "/value", value: "b" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
watcher: {
|
||||
type: "Text",
|
||||
props: { text: { $state: "/value" } },
|
||||
watch: {
|
||||
"/value": [{ action: "action1" }, { action: "action2" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider
|
||||
registry={reg}
|
||||
initialState={spec.state}
|
||||
handlers={{ action1, action2 }}
|
||||
>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("btn"));
|
||||
});
|
||||
|
||||
expect(action1).toHaveBeenCalledTimes(1);
|
||||
expect(action2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// validateForm action
|
||||
// =============================================================================
|
||||
|
||||
describe("validateForm action", () => {
|
||||
it("writes { valid: false } when a required field is empty", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: { form: { email: "" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["emailInput", "submitBtn"],
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
value: { $bindState: "/form/email" },
|
||||
checks: [{ type: "required", message: "Email is required" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submitBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
// Click submit with empty email
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("btn"));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.result).toEqual({
|
||||
valid: false,
|
||||
errors: { "/form/email": ["Email is required"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes { valid: true } when all fields pass validation", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: { form: { email: "test@example.com" }, result: null },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["emailInput", "submitBtn"],
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
value: { $bindState: "/form/email" },
|
||||
checks: [{ type: "required", message: "Email is required" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submitBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("btn"));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.result).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
|
||||
it("defaults to /formValidation when no statePath is provided", async () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
const reg = { ...registry, Stack };
|
||||
|
||||
const spec: Spec = {
|
||||
state: { form: { name: "filled" } },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["nameInput", "submitBtn"],
|
||||
},
|
||||
nameInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: [{ type: "required", message: "Required" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
submitBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: {
|
||||
press: [{ action: "validateForm" }],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={reg} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={reg} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("btn"));
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Select validate-on-change timing (#151)
|
||||
// =============================================================================
|
||||
|
||||
describe("Select validate-on-change sees the new value, not the stale value", () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
|
||||
const regWithSelect = {
|
||||
...registry,
|
||||
Stack,
|
||||
Select: ValidatedSelect,
|
||||
};
|
||||
|
||||
it("does not show 'required' error when selecting the first value", async () => {
|
||||
const spec: Spec = {
|
||||
state: { form: { country: "" } },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["countrySelect"],
|
||||
},
|
||||
countrySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
name: "country",
|
||||
options: ["US", "Canada", "UK"],
|
||||
placeholder: "Choose a country",
|
||||
value: { $bindState: "/form/country" },
|
||||
checks: [{ type: "required", message: "Country is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={regWithSelect} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
// Select "US" for the first time (from empty)
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId("select-country"), {
|
||||
target: { value: "US" },
|
||||
});
|
||||
});
|
||||
|
||||
// The value should be set in state
|
||||
const state = getState();
|
||||
expect((state.form as Record<string, unknown>).country).toBe("US");
|
||||
|
||||
// No validation error should appear -- "US" is non-empty
|
||||
expect(screen.queryByTestId("select-error-country")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
|
||||
const spec: Spec = {
|
||||
state: {
|
||||
form: { country: "US", city: "" },
|
||||
availableCities: ["New York", "Chicago"],
|
||||
},
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["citySelect"],
|
||||
},
|
||||
citySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "City",
|
||||
name: "city",
|
||||
options: ["New York", "Chicago"],
|
||||
placeholder: "Select a city",
|
||||
value: { $bindState: "/form/city" },
|
||||
checks: [{ type: "required", message: "City is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={regWithSelect} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
// Select "New York" for the first time (from empty)
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId("select-city"), {
|
||||
target: { value: "New York" },
|
||||
});
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect((state.form as Record<string, unknown>).city).toBe("New York");
|
||||
|
||||
// No validation error should appear
|
||||
expect(screen.queryByTestId("select-error-city")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
export {
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
useOptionalValidation,
|
||||
useFieldValidation,
|
||||
type ValidationContextValue,
|
||||
type ValidationProviderProps,
|
||||
|
||||
+137
-31
@@ -5,7 +5,9 @@ import React, {
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type {
|
||||
UIElement,
|
||||
@@ -14,6 +16,7 @@ import type {
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
ComputedFunction,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -136,6 +139,19 @@ class ElementErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FunctionsContext – provides $computed functions to the element tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
|
||||
|
||||
const FunctionsContext =
|
||||
React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
|
||||
|
||||
function useFunctions(): Record<string, ComputedFunction> {
|
||||
return React.useContext(FunctionsContext);
|
||||
}
|
||||
|
||||
interface ElementRendererProps {
|
||||
element: UIElement;
|
||||
spec: Spec;
|
||||
@@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
const repeatScope = useRepeatScope();
|
||||
const { ctx } = useVisibility();
|
||||
const { execute } = useActions();
|
||||
const { getSnapshot } = useStateStore();
|
||||
const { getSnapshot, state: watchState } = useStateStore();
|
||||
const functions = useFunctions();
|
||||
|
||||
// Build context with repeat scope (used for both visibility and props)
|
||||
const fullCtx: PropResolutionContext = useMemo(
|
||||
() =>
|
||||
repeatScope
|
||||
? {
|
||||
...ctx,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: ctx,
|
||||
[ctx, repeatScope],
|
||||
);
|
||||
// Build context with repeat scope and $computed functions
|
||||
const fullCtx: PropResolutionContext = useMemo(() => {
|
||||
const base: PropResolutionContext = repeatScope
|
||||
? {
|
||||
...ctx,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: { ...ctx };
|
||||
base.functions = functions;
|
||||
return base;
|
||||
}, [ctx, repeatScope, functions]);
|
||||
|
||||
// Evaluate visibility (now supports $item/$index inside repeat scopes)
|
||||
const isVisible =
|
||||
@@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
[onBindings, emit],
|
||||
);
|
||||
|
||||
// Watch effect: fire actions when watched state paths change.
|
||||
// Must be called before any early return to satisfy Rules of Hooks.
|
||||
//
|
||||
// Two refs serve distinct roles:
|
||||
// - `stableWatchRef` (useMemo): holds the last emitted values object so we
|
||||
// can return the same reference when watched values haven't changed,
|
||||
// preventing the downstream useEffect from firing on unrelated state updates.
|
||||
// - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
|
||||
// for change detection. Starts as `null` to skip the initial mount.
|
||||
const watchConfig = element.watch;
|
||||
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
|
||||
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
|
||||
|
||||
const watchedValues = useMemo(() => {
|
||||
if (!watchConfig) return undefined;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const path of Object.keys(watchConfig)) {
|
||||
values[path] = getByPath(watchState, path);
|
||||
}
|
||||
const prev = stableWatchRef.current;
|
||||
if (prev) {
|
||||
const keys = Object.keys(values);
|
||||
if (
|
||||
keys.length === Object.keys(prev).length &&
|
||||
keys.every((k) => values[k] === prev[k])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
stableWatchRef.current = values;
|
||||
return values;
|
||||
}, [watchConfig, watchState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!watchConfig || !watchedValues) return;
|
||||
const paths = Object.keys(watchConfig);
|
||||
if (paths.length === 0) return;
|
||||
|
||||
const prev = prevWatchValues.current;
|
||||
prevWatchValues.current = watchedValues;
|
||||
|
||||
// Skip the initial mount — only fire on changes
|
||||
if (prev === null) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (const path of paths) {
|
||||
if (cancelled) break;
|
||||
if (watchedValues[path] !== prev[path]) {
|
||||
const binding = watchConfig[path];
|
||||
if (!binding) continue;
|
||||
const bindings = Array.isArray(binding) ? binding : [binding];
|
||||
for (const b of bindings) {
|
||||
if (cancelled) break;
|
||||
if (!b.params) {
|
||||
await execute(b);
|
||||
if (cancelled) break;
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...fullCtx,
|
||||
stateModel: getSnapshot(),
|
||||
};
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(b.params)) {
|
||||
resolved[key] = resolveActionParam(val, liveCtx);
|
||||
}
|
||||
await execute({ ...b, params: resolved });
|
||||
if (cancelled) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
@@ -419,6 +515,8 @@ export interface JSONUIProviderProps {
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
/** Named functions for `$computed` expressions in props */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
@@ -434,6 +532,7 @@ export function JSONUIProvider({
|
||||
handlers,
|
||||
navigate,
|
||||
validationFunctions,
|
||||
functions,
|
||||
onStateChange,
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
@@ -444,12 +543,14 @@ export function JSONUIProvider({
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
@@ -650,6 +751,8 @@ export interface CreateRendererProps {
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Named functions for `$computed` expressions in props */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
/** Fallback component for unknown types */
|
||||
@@ -703,6 +806,7 @@ export function createRenderer<
|
||||
state,
|
||||
onAction,
|
||||
onStateChange,
|
||||
functions,
|
||||
loading,
|
||||
fallback,
|
||||
}: CreateRendererProps) {
|
||||
@@ -730,17 +834,19 @@ export function createRenderer<
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
<ValidationProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/schema.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "react-dom", "@json-render/core"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @json-render/redux
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user