mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 20:38:21 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c11f19be4 | ||
|
|
db3a8b41e9 | ||
|
|
ea47b66dfc | ||
|
|
cd82f969c8 | ||
|
|
6bcaaad57d |
@@ -5,15 +5,18 @@
|
|||||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# for React
|
||||||
npm install @json-render/core @json-render/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
|
npm install @json-render/shadcn
|
||||||
# or for mobile
|
# or for React Native
|
||||||
npm install @json-render/core @json-render/react-native
|
npm install @json-render/core @json-render/react-native
|
||||||
# or for video
|
# or for video
|
||||||
npm install @json-render/core @json-render/remotion
|
npm install @json-render/core @json-render/remotion
|
||||||
# or for PDF documents
|
# or for PDF documents
|
||||||
npm install @json-render/core @json-render/react-pdf
|
npm install @json-render/core @json-render/react-pdf
|
||||||
|
# or for Vue
|
||||||
|
npm install @json-render/core @json-render/vue
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why json-render?
|
## 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
|
- **Guardrailed** - AI can only use components in your catalog
|
||||||
- **Predictable** - JSON output matches your schema, every time
|
- **Predictable** - JSON output matches your schema, every time
|
||||||
- **Fast** - Stream and render progressively as the model responds
|
- **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
|
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||||
| `@json-render/react` | React renderer, contexts, hooks |
|
| `@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/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
| `@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/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||||
|
| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` |
|
||||||
|
|
||||||
## Renderers
|
## Renderers
|
||||||
|
|
||||||
@@ -148,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
|
|||||||
<Renderer spec={spec} registry={registry} />
|
<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)
|
### shadcn/ui (Web)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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.
|
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
|
## Quick Reference
|
||||||
|
|
||||||
<div className="my-6 overflow-x-auto">
|
<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><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||||
<td>Form components inside repeat</td>
|
<td>Form components inside repeat</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
|
|||||||
|
|
||||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
<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
|
## For React UI with shadcn/ui
|
||||||
|
|
||||||
Pre-built components for fast prototyping and production use:
|
Pre-built components for fast prototyping and production use:
|
||||||
@@ -37,15 +49,6 @@ If you want to wire json-render to an existing state management library instead
|
|||||||
|
|
||||||
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
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
|
## For AI Integration
|
||||||
|
|
||||||
To use json-render with AI models, you'll also need the Vercel AI SDK:
|
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
|
- `required` — Value must be non-empty
|
||||||
- `email` — Valid email format
|
- `email` — Valid email format
|
||||||
- `minLength` — Minimum string length
|
- `minLength` — Minimum string length (args: `{ "min": N }`)
|
||||||
- `maxLength` — Maximum string length
|
- `maxLength` — Maximum string length (args: `{ "max": N }`)
|
||||||
- `pattern` — Match a regex pattern
|
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
|
||||||
- `min` — Minimum numeric value
|
- `min` — Minimum numeric value (args: `{ "min": N }`)
|
||||||
- `max` — Maximum numeric value
|
- `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
|
## 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.
|
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
|
## Validation Timing
|
||||||
|
|
||||||
Control when validation runs with `validateOn`:
|
Control when validation runs with `validateOn`:
|
||||||
|
|
||||||
- `change` — Validate on every input change
|
- `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
|
- `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
|
## 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: "Schemas", href: "/docs/schemas" },
|
||||||
{ title: "Catalog", href: "/docs/catalog" },
|
{ title: "Catalog", href: "/docs/catalog" },
|
||||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||||
|
{ title: "Computed Values", href: "/docs/computed-values" },
|
||||||
{ title: "Visibility", href: "/docs/visibility" },
|
{ title: "Visibility", href: "/docs/visibility" },
|
||||||
|
{ title: "Watchers", href: "/docs/watchers" },
|
||||||
{ title: "Validation", href: "/docs/validation" },
|
{ 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",
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||||
external: true,
|
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/shadcn", href: "/docs/api/shadcn" },
|
||||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||||
|
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
// API references
|
// API references
|
||||||
"docs/api/core": "@json-render/core API",
|
"docs/api/core": "@json-render/core API",
|
||||||
"docs/api/react": "@json-render/react 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-pdf": "@json-render/react-pdf API",
|
||||||
"docs/api/react-native": "@json-render/react-native API",
|
"docs/api/react-native": "@json-render/react-native API",
|
||||||
"docs/api/codegen": "@json-render/codegen API",
|
"docs/api/codegen": "@json-render/codegen API",
|
||||||
|
|||||||
+59
-41
@@ -2,33 +2,31 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||||
import ConfettiExplosion from "react-confetti-explosion";
|
import ConfettiExplosion from "react-confetti-explosion";
|
||||||
import {
|
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||||
Renderer,
|
|
||||||
StateProvider,
|
|
||||||
VisibilityProvider,
|
|
||||||
ActionProvider,
|
|
||||||
ValidationProvider,
|
|
||||||
} from "@json-render/react";
|
|
||||||
import type { Spec } from "@json-render/core";
|
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";
|
import { examples } from "@/lib/examples";
|
||||||
|
|
||||||
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
||||||
return (
|
return (
|
||||||
<StateProvider initialState={spec.state ?? {}}>
|
<JSONUIProvider
|
||||||
<VisibilityProvider>
|
registry={registry}
|
||||||
<ActionProvider handlers={actionHandlers}>
|
initialState={spec.state ?? {}}
|
||||||
<ValidationProvider>
|
handlers={actionHandlers}
|
||||||
<Renderer spec={spec} registry={registry} />
|
functions={computedFunctions}
|
||||||
</ValidationProvider>
|
>
|
||||||
</ActionProvider>
|
<Renderer spec={spec} registry={registry} />
|
||||||
</VisibilityProvider>
|
</JSONUIProvider>
|
||||||
</StateProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [selectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const selected = examples[selectedIndex]!;
|
const selected = examples[selectedIndex]!;
|
||||||
const [confettiKey, setConfettiKey] = useState(0);
|
const [confettiKey, setConfettiKey] = useState(0);
|
||||||
const [confettiActive, setConfettiActive] = useState(false);
|
const [confettiActive, setConfettiActive] = useState(false);
|
||||||
@@ -41,30 +39,50 @@ export default function Page() {
|
|||||||
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
|
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-muted/30">
|
<div className="h-screen flex flex-col bg-muted/30">
|
||||||
<div
|
{/* Example selector */}
|
||||||
className="relative bg-background border rounded-lg shadow-sm"
|
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
|
||||||
style={{ width: 960, height: 1080 }}
|
{examples.map((ex, i) => (
|
||||||
>
|
<button
|
||||||
{confettiActive && (
|
key={ex.name}
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
onClick={() => setSelectedIndex(i)}
|
||||||
<ConfettiExplosion
|
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
|
||||||
key={confettiKey}
|
i === selectedIndex
|
||||||
portal={false}
|
? "bg-primary text-primary-foreground"
|
||||||
force={0.8}
|
: "hover:bg-muted"
|
||||||
duration={3500}
|
}`}
|
||||||
particleCount={400}
|
>
|
||||||
particleSize={8}
|
{ex.name}
|
||||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
</button>
|
||||||
width={1600}
|
))}
|
||||||
height="200vh"
|
</nav>
|
||||||
zIndex={1}
|
|
||||||
onComplete={() => setConfettiActive(false)}
|
{/* 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>
|
||||||
)}
|
|
||||||
<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>
|
</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",
|
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";
|
"use client";
|
||||||
|
|
||||||
import { defineRegistry } from "@json-render/react";
|
import { defineRegistry } from "@json-render/react";
|
||||||
|
import type { ComputedFunction } from "@json-render/core";
|
||||||
import { shadcnComponents } from "@json-render/shadcn";
|
import { shadcnComponents } from "@json-render/shadcn";
|
||||||
import { catalog } from "./catalog";
|
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, {
|
export const { registry } = defineRegistry(catalog, {
|
||||||
components: {
|
components: {
|
||||||
...shadcnComponents,
|
...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?.(),
|
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] ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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.0",
|
||||||
|
"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,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": "vue",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"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": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --concurrency 15",
|
"dev": "turbo run dev --concurrency 20",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
"type-check": "turbo run check-types",
|
"type-check": "turbo run check-types",
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ export {
|
|||||||
} from "./visibility";
|
} from "./visibility";
|
||||||
|
|
||||||
// Prop Expressions
|
// Prop Expressions
|
||||||
export type { PropExpression, PropResolutionContext } from "./props";
|
export type {
|
||||||
|
PropExpression,
|
||||||
|
PropResolutionContext,
|
||||||
|
ComputedFunction,
|
||||||
|
} from "./props";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
resolvePropValue,
|
resolvePropValue,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolvePropValue,
|
resolvePropValue,
|
||||||
resolveElementProps,
|
resolveElementProps,
|
||||||
resolveBindings,
|
resolveBindings,
|
||||||
resolveActionParam,
|
resolveActionParam,
|
||||||
|
_resetWarnedComputedFns,
|
||||||
|
_resetWarnedTemplatePaths,
|
||||||
} from "./props";
|
} from "./props";
|
||||||
import type { PropResolutionContext } from "./props";
|
import type { PropResolutionContext } from "./props";
|
||||||
|
|
||||||
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
|
|||||||
expect(resolveActionParam(null, ctx)).toBeNull();
|
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
|
* repeat item — resolves via `repeatBasePath + path` and exposes the
|
||||||
* absolute state path for write-back.
|
* absolute state path for write-back.
|
||||||
* - `{ $cond, $then, $else }` conditionally picks a value
|
* - `{ $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)
|
* - Any other value is a literal (passthrough)
|
||||||
*/
|
*/
|
||||||
export type PropExpression<T = unknown> =
|
export type PropExpression<T = unknown> =
|
||||||
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
|
|||||||
$cond: VisibilityCondition;
|
$cond: VisibilityCondition;
|
||||||
$then: PropExpression<T>;
|
$then: PropExpression<T>;
|
||||||
$else: 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.
|
* Context for resolving prop expressions.
|
||||||
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
|
|||||||
export interface PropResolutionContext extends VisibilityContext {
|
export interface PropResolutionContext extends VisibilityContext {
|
||||||
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
|
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
|
||||||
repeatBasePath?: string;
|
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
|
// Prop Expression Resolution
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -193,6 +248,50 @@ export function resolvePropValue(
|
|||||||
return resolvePropValue(result ? value.$then : value.$else, ctx);
|
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
|
// Arrays: resolve each element
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) => resolvePropValue(item, ctx));
|
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.",
|
"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("");
|
||||||
|
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
|
// Validation section — only emit when at least one component has a `checks` prop
|
||||||
const hasChecksComponents = allComponents
|
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(" - numeric — value must be a number");
|
||||||
lines.push(" - url — valid URL format");
|
lines.push(" - url — valid URL format");
|
||||||
lines.push(
|
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("");
|
||||||
lines.push("Example:");
|
lines.push("Example:");
|
||||||
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
|||||||
lines.push("");
|
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
|
// Rules
|
||||||
lines.push("RULES:");
|
lines.push("RULES:");
|
||||||
const baseRules =
|
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"
|
| "orphaned_element"
|
||||||
| "empty_spec"
|
| "empty_spec"
|
||||||
| "on_in_props"
|
| "on_in_props"
|
||||||
| "repeat_in_props";
|
| "repeat_in_props"
|
||||||
|
| "watch_in_props";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +152,16 @@ export function validateSpec(
|
|||||||
code: "repeat_in_props",
|
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)
|
// 4. Orphaned elements (optional)
|
||||||
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
|
|||||||
fixes.push(`Moved "repeat" from props to element level on "${key}".`);
|
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;
|
fixedElements[key] = fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export interface UIElement<
|
|||||||
on?: Record<string, ActionBinding | ActionBinding[]>;
|
on?: Record<string, ActionBinding | ActionBinding[]>;
|
||||||
/** Repeat children once per item in a state array */
|
/** Repeat children once per item in a state array */
|
||||||
repeat?: { statePath: string; key?: string };
|
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);
|
).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", () => {
|
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", () => {
|
describe("matches", () => {
|
||||||
it("creates matches check with path reference", () => {
|
it("creates matches check with path reference", () => {
|
||||||
const c = check.matches("/password", "Passwords must match");
|
const c = check.matches("/password", "Passwords must match");
|
||||||
@@ -400,4 +636,109 @@ describe("check helper", () => {
|
|||||||
expect(c.message).toBe("Passwords must match");
|
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 type { DynamicValue, StateModel, VisibilityCondition } from "./types";
|
||||||
import { DynamicValueSchema, resolveDynamicValue } from "./types";
|
import { DynamicValueSchema, resolveDynamicValue } from "./types";
|
||||||
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
|
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
|
||||||
|
import { resolvePropValue } from "./props";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation check definition
|
* Validation check definition
|
||||||
@@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matchesImpl: ValidationFunction = (
|
||||||
|
value: unknown,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const other = args?.other;
|
||||||
|
return value === other;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in validation functions
|
* Built-in validation functions
|
||||||
*/
|
*/
|
||||||
@@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
|
|||||||
/**
|
/**
|
||||||
* Check if value matches another field
|
* 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;
|
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 {
|
): ValidationCheckResult {
|
||||||
const { value, stateModel, customFunctions } = ctx;
|
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> = {};
|
const resolvedArgs: Record<string, unknown> = {};
|
||||||
if (check.args) {
|
if (check.args) {
|
||||||
for (const [key, argValue] of Object.entries(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,
|
message,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
numeric: (message = "Must be a number"): ValidationCheck => ({
|
||||||
|
type: "numeric",
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
|
||||||
matches: (
|
matches: (
|
||||||
otherPath: string,
|
otherPath: string,
|
||||||
message = "Fields must match",
|
message = "Fields must match",
|
||||||
@@ -334,4 +404,34 @@ export const check = {
|
|||||||
args: { other: { $state: otherPath } },
|
args: { other: { $state: otherPath } },
|
||||||
message,
|
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,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type ResolvedAction,
|
type ResolvedAction,
|
||||||
} from "@json-render/core";
|
} from "@json-render/core";
|
||||||
import { useStateStore } from "./state";
|
import { useStateStore } from "./state";
|
||||||
|
import { useOptionalValidation } from "./validation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique ID for use with the "$id" token.
|
* Generate a unique ID for use with the "$id" token.
|
||||||
@@ -137,6 +138,7 @@ export function ActionProvider({
|
|||||||
children,
|
children,
|
||||||
}: ActionProviderProps) {
|
}: ActionProviderProps) {
|
||||||
const { get, set, getSnapshot } = useStateStore();
|
const { get, set, getSnapshot } = useStateStore();
|
||||||
|
const validation = useOptionalValidation();
|
||||||
|
|
||||||
const [handlers, setHandlers] =
|
const [handlers, setHandlers] =
|
||||||
useState<Record<string, ActionHandler>>(initialHandlers);
|
useState<Record<string, ActionHandler>>(initialHandlers);
|
||||||
@@ -227,13 +229,40 @@ export function ActionProvider({
|
|||||||
if (previousScreen) {
|
if (previousScreen) {
|
||||||
set("/currentScreen", previousScreen);
|
set("/currentScreen", previousScreen);
|
||||||
} else {
|
} else {
|
||||||
// Sentinel empty string = clear currentScreen (return to default)
|
|
||||||
set("/currentScreen", undefined);
|
set("/currentScreen", undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
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];
|
const handler = handlers[resolved.action];
|
||||||
|
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
@@ -300,7 +329,7 @@ export function ActionProvider({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handlers, get, set, getSnapshot, navigate],
|
[handlers, get, set, getSnapshot, navigate, validation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirm = useCallback(() => {
|
const confirm = useCallback(() => {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
@@ -130,15 +130,14 @@ export function ValidationProvider({
|
|||||||
customFunctions = {},
|
customFunctions = {},
|
||||||
children,
|
children,
|
||||||
}: ValidationProviderProps) {
|
}: ValidationProviderProps) {
|
||||||
const { state } = useStateStore();
|
const { state, getSnapshot } = 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 [fieldStates, setFieldStates] = useState<
|
const [fieldStates, setFieldStates] = useState<
|
||||||
Record<string, FieldValidationState>
|
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<
|
const [fieldConfigs, setFieldConfigs] = useState<
|
||||||
Record<string, ValidationConfig>
|
Record<string, ValidationConfig>
|
||||||
>({});
|
>({});
|
||||||
@@ -160,8 +159,10 @@ export function ValidationProvider({
|
|||||||
|
|
||||||
const validate = useCallback(
|
const validate = useCallback(
|
||||||
(path: string, config: ValidationConfig): ValidationResult => {
|
(path: string, config: ValidationConfig): ValidationResult => {
|
||||||
// Walk the nested state object using JSON Pointer segments
|
// Read from the store directly so validation sees values written in the
|
||||||
const currentState = stateRef.current;
|
// 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);
|
const segments = path.split("/").filter(Boolean);
|
||||||
let value: unknown = currentState;
|
let value: unknown = currentState;
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
@@ -178,37 +179,39 @@ export function ValidationProvider({
|
|||||||
customFunctions,
|
customFunctions,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFieldStates((prev) => ({
|
const newFieldState: FieldValidationState = {
|
||||||
...prev,
|
touched: fieldStatesRef.current[path]?.touched ?? true,
|
||||||
[path]: {
|
validated: true,
|
||||||
touched: prev[path]?.touched ?? true,
|
result,
|
||||||
validated: true,
|
};
|
||||||
result,
|
fieldStatesRef.current = {
|
||||||
},
|
...fieldStatesRef.current,
|
||||||
}));
|
[path]: newFieldState,
|
||||||
|
};
|
||||||
|
setFieldStates(fieldStatesRef.current);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
[customFunctions],
|
[customFunctions, getSnapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const touch = useCallback((path: string) => {
|
const touch = useCallback((path: string) => {
|
||||||
setFieldStates((prev) => ({
|
fieldStatesRef.current = {
|
||||||
...prev,
|
...fieldStatesRef.current,
|
||||||
[path]: {
|
[path]: {
|
||||||
...prev[path],
|
...fieldStatesRef.current[path],
|
||||||
touched: true,
|
touched: true,
|
||||||
validated: prev[path]?.validated ?? false,
|
validated: fieldStatesRef.current[path]?.validated ?? false,
|
||||||
result: prev[path]?.result ?? null,
|
result: fieldStatesRef.current[path]?.result ?? null,
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
setFieldStates(fieldStatesRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clear = useCallback((path: string) => {
|
const clear = useCallback((path: string) => {
|
||||||
setFieldStates((prev) => {
|
const { [path]: _, ...rest } = fieldStatesRef.current;
|
||||||
const { [path]: _, ...rest } = prev;
|
fieldStatesRef.current = rest;
|
||||||
return rest;
|
setFieldStates(rest);
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validateAll = useCallback(() => {
|
const validateAll = useCallback(() => {
|
||||||
@@ -227,7 +230,11 @@ export function ValidationProvider({
|
|||||||
const value = useMemo<ValidationContextValue>(
|
const value = useMemo<ValidationContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
customFunctions,
|
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,
|
validate,
|
||||||
touch,
|
touch,
|
||||||
clear,
|
clear,
|
||||||
@@ -236,6 +243,8 @@ export function ValidationProvider({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
customFunctions,
|
customFunctions,
|
||||||
|
// fieldStates (React state) stays in deps so the context value object
|
||||||
|
// is recreated on re-render, triggering downstream consumers.
|
||||||
fieldStates,
|
fieldStates,
|
||||||
validate,
|
validate,
|
||||||
touch,
|
touch,
|
||||||
@@ -263,6 +272,14 @@ export function useValidation(): ValidationContextValue {
|
|||||||
return ctx;
|
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
|
* 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 {
|
export {
|
||||||
ValidationProvider,
|
ValidationProvider,
|
||||||
useValidation,
|
useValidation,
|
||||||
|
useOptionalValidation,
|
||||||
useFieldValidation,
|
useFieldValidation,
|
||||||
type ValidationContextValue,
|
type ValidationContextValue,
|
||||||
type ValidationProviderProps,
|
type ValidationProviderProps,
|
||||||
|
|||||||
+137
-31
@@ -5,7 +5,9 @@ import React, {
|
|||||||
type ErrorInfo,
|
type ErrorInfo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type {
|
import type {
|
||||||
UIElement,
|
UIElement,
|
||||||
@@ -14,6 +16,7 @@ import type {
|
|||||||
Catalog,
|
Catalog,
|
||||||
SchemaDefinition,
|
SchemaDefinition,
|
||||||
StateStore,
|
StateStore,
|
||||||
|
ComputedFunction,
|
||||||
} from "@json-render/core";
|
} from "@json-render/core";
|
||||||
import {
|
import {
|
||||||
resolveElementProps,
|
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 {
|
interface ElementRendererProps {
|
||||||
element: UIElement;
|
element: UIElement;
|
||||||
spec: Spec;
|
spec: Spec;
|
||||||
@@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
|||||||
const repeatScope = useRepeatScope();
|
const repeatScope = useRepeatScope();
|
||||||
const { ctx } = useVisibility();
|
const { ctx } = useVisibility();
|
||||||
const { execute } = useActions();
|
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)
|
// Build context with repeat scope and $computed functions
|
||||||
const fullCtx: PropResolutionContext = useMemo(
|
const fullCtx: PropResolutionContext = useMemo(() => {
|
||||||
() =>
|
const base: PropResolutionContext = repeatScope
|
||||||
repeatScope
|
? {
|
||||||
? {
|
...ctx,
|
||||||
...ctx,
|
repeatItem: repeatScope.item,
|
||||||
repeatItem: repeatScope.item,
|
repeatIndex: repeatScope.index,
|
||||||
repeatIndex: repeatScope.index,
|
repeatBasePath: repeatScope.basePath,
|
||||||
repeatBasePath: repeatScope.basePath,
|
}
|
||||||
}
|
: { ...ctx };
|
||||||
: ctx,
|
base.functions = functions;
|
||||||
[ctx, repeatScope],
|
return base;
|
||||||
);
|
}, [ctx, repeatScope, functions]);
|
||||||
|
|
||||||
// Evaluate visibility (now supports $item/$index inside repeat scopes)
|
// Evaluate visibility (now supports $item/$index inside repeat scopes)
|
||||||
const isVisible =
|
const isVisible =
|
||||||
@@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
|||||||
[onBindings, emit],
|
[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
|
// Don't render if not visible
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -419,6 +515,8 @@ export interface JSONUIProviderProps {
|
|||||||
string,
|
string,
|
||||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||||
>;
|
>;
|
||||||
|
/** Named functions for `$computed` expressions in props */
|
||||||
|
functions?: Record<string, ComputedFunction>;
|
||||||
/** Callback when state changes (uncontrolled mode) */
|
/** Callback when state changes (uncontrolled mode) */
|
||||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -434,6 +532,7 @@ export function JSONUIProvider({
|
|||||||
handlers,
|
handlers,
|
||||||
navigate,
|
navigate,
|
||||||
validationFunctions,
|
validationFunctions,
|
||||||
|
functions,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
children,
|
children,
|
||||||
}: JSONUIProviderProps) {
|
}: JSONUIProviderProps) {
|
||||||
@@ -444,12 +543,14 @@ export function JSONUIProvider({
|
|||||||
onStateChange={onStateChange}
|
onStateChange={onStateChange}
|
||||||
>
|
>
|
||||||
<VisibilityProvider>
|
<VisibilityProvider>
|
||||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
<ValidationProvider customFunctions={validationFunctions}>
|
||||||
<ValidationProvider customFunctions={validationFunctions}>
|
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||||
{children}
|
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||||
<ConfirmationDialogManager />
|
{children}
|
||||||
</ValidationProvider>
|
<ConfirmationDialogManager />
|
||||||
</ActionProvider>
|
</FunctionsContext.Provider>
|
||||||
|
</ActionProvider>
|
||||||
|
</ValidationProvider>
|
||||||
</VisibilityProvider>
|
</VisibilityProvider>
|
||||||
</StateProvider>
|
</StateProvider>
|
||||||
);
|
);
|
||||||
@@ -650,6 +751,8 @@ export interface CreateRendererProps {
|
|||||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||||
/** Callback when state changes (uncontrolled mode) */
|
/** Callback when state changes (uncontrolled mode) */
|
||||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
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 */
|
/** Whether the spec is currently loading/streaming */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Fallback component for unknown types */
|
/** Fallback component for unknown types */
|
||||||
@@ -703,6 +806,7 @@ export function createRenderer<
|
|||||||
state,
|
state,
|
||||||
onAction,
|
onAction,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
|
functions,
|
||||||
loading,
|
loading,
|
||||||
fallback,
|
fallback,
|
||||||
}: CreateRendererProps) {
|
}: CreateRendererProps) {
|
||||||
@@ -730,17 +834,19 @@ export function createRenderer<
|
|||||||
onStateChange={onStateChange}
|
onStateChange={onStateChange}
|
||||||
>
|
>
|
||||||
<VisibilityProvider>
|
<VisibilityProvider>
|
||||||
<ActionProvider handlers={actionHandlers}>
|
<ValidationProvider>
|
||||||
<ValidationProvider>
|
<ActionProvider handlers={actionHandlers}>
|
||||||
<Renderer
|
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||||
spec={spec}
|
<Renderer
|
||||||
registry={registry}
|
spec={spec}
|
||||||
loading={loading}
|
registry={registry}
|
||||||
fallback={fallback}
|
loading={loading}
|
||||||
/>
|
fallback={fallback}
|
||||||
<ConfirmationDialogManager />
|
/>
|
||||||
</ValidationProvider>
|
<ConfirmationDialogManager />
|
||||||
</ActionProvider>
|
</FunctionsContext.Provider>
|
||||||
|
</ActionProvider>
|
||||||
|
</ValidationProvider>
|
||||||
</VisibilityProvider>
|
</VisibilityProvider>
|
||||||
</StateProvider>
|
</StateProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ export const schema = defineSchema(
|
|||||||
description:
|
description:
|
||||||
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
|
"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: [
|
defaultRules: [
|
||||||
// Element integrity
|
// Element integrity
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Shared validation schemas used across form components
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const validationCheckSchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
type: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
args: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.nullable();
|
||||||
|
|
||||||
|
const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable();
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// shadcn/ui Component Definitions
|
// shadcn/ui Component Definitions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -272,19 +288,12 @@ export const shadcnComponentDefinitions = {
|
|||||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||||
placeholder: z.string().nullable(),
|
placeholder: z.string().nullable(),
|
||||||
value: z.string().nullable(),
|
value: z.string().nullable(),
|
||||||
checks: z
|
checks: validationCheckSchema,
|
||||||
.array(
|
validateOn: validateOnSchema,
|
||||||
z.object({
|
|
||||||
type: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
args: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.nullable(),
|
|
||||||
}),
|
}),
|
||||||
events: ["submit", "focus", "blur"],
|
events: ["submit", "focus", "blur"],
|
||||||
description:
|
description:
|
||||||
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
|
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).",
|
||||||
example: {
|
example: {
|
||||||
label: "Email",
|
label: "Email",
|
||||||
name: "email",
|
name: "email",
|
||||||
@@ -300,18 +309,11 @@ export const shadcnComponentDefinitions = {
|
|||||||
placeholder: z.string().nullable(),
|
placeholder: z.string().nullable(),
|
||||||
rows: z.number().nullable(),
|
rows: z.number().nullable(),
|
||||||
value: z.string().nullable(),
|
value: z.string().nullable(),
|
||||||
checks: z
|
checks: validationCheckSchema,
|
||||||
.array(
|
validateOn: validateOnSchema,
|
||||||
z.object({
|
|
||||||
type: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
args: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.nullable(),
|
|
||||||
}),
|
}),
|
||||||
description:
|
description:
|
||||||
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
|
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).",
|
||||||
},
|
},
|
||||||
|
|
||||||
Select: {
|
Select: {
|
||||||
@@ -321,19 +323,12 @@ export const shadcnComponentDefinitions = {
|
|||||||
options: z.array(z.string()),
|
options: z.array(z.string()),
|
||||||
placeholder: z.string().nullable(),
|
placeholder: z.string().nullable(),
|
||||||
value: z.string().nullable(),
|
value: z.string().nullable(),
|
||||||
checks: z
|
checks: validationCheckSchema,
|
||||||
.array(
|
validateOn: validateOnSchema,
|
||||||
z.object({
|
|
||||||
type: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
args: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.nullable(),
|
|
||||||
}),
|
}),
|
||||||
events: ["change"],
|
events: ["change"],
|
||||||
description:
|
description:
|
||||||
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
|
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||||
},
|
},
|
||||||
|
|
||||||
Checkbox: {
|
Checkbox: {
|
||||||
@@ -341,9 +336,12 @@ export const shadcnComponentDefinitions = {
|
|||||||
label: z.string(),
|
label: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
checked: z.boolean().nullable(),
|
checked: z.boolean().nullable(),
|
||||||
|
checks: validationCheckSchema,
|
||||||
|
validateOn: validateOnSchema,
|
||||||
}),
|
}),
|
||||||
events: ["change"],
|
events: ["change"],
|
||||||
description: "Checkbox input. Use { $bindState } on checked for binding.",
|
description:
|
||||||
|
"Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||||
},
|
},
|
||||||
|
|
||||||
Radio: {
|
Radio: {
|
||||||
@@ -352,9 +350,12 @@ export const shadcnComponentDefinitions = {
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
options: z.array(z.string()),
|
options: z.array(z.string()),
|
||||||
value: z.string().nullable(),
|
value: z.string().nullable(),
|
||||||
|
checks: validationCheckSchema,
|
||||||
|
validateOn: validateOnSchema,
|
||||||
}),
|
}),
|
||||||
events: ["change"],
|
events: ["change"],
|
||||||
description: "Radio button group. Use { $bindState } on value for binding.",
|
description:
|
||||||
|
"Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||||
},
|
},
|
||||||
|
|
||||||
Switch: {
|
Switch: {
|
||||||
@@ -362,9 +363,12 @@ export const shadcnComponentDefinitions = {
|
|||||||
label: z.string(),
|
label: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
checked: z.boolean().nullable(),
|
checked: z.boolean().nullable(),
|
||||||
|
checks: validationCheckSchema,
|
||||||
|
validateOn: validateOnSchema,
|
||||||
}),
|
}),
|
||||||
events: ["change"],
|
events: ["change"],
|
||||||
description: "Toggle switch. Use { $bindState } on checked for binding.",
|
description:
|
||||||
|
"Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||||
},
|
},
|
||||||
|
|
||||||
Slider: {
|
Slider: {
|
||||||
|
|||||||
@@ -664,11 +664,12 @@ export const shadcnComponents = {
|
|||||||
const isBound = !!bindings?.value;
|
const isBound = !!bindings?.value;
|
||||||
const value = isBound ? (boundValue ?? "") : localValue;
|
const value = isBound ? (boundValue ?? "") : localValue;
|
||||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||||
|
const validateOn = props.validateOn ?? "blur";
|
||||||
|
|
||||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||||
const { errors, validate } = useFieldValidation(
|
const { errors, validate } = useFieldValidation(
|
||||||
bindings?.value ?? "",
|
bindings?.value ?? "",
|
||||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -682,13 +683,16 @@ export const shadcnComponents = {
|
|||||||
type={props.type ?? "text"}
|
type={props.type ?? "text"}
|
||||||
placeholder={props.placeholder ?? ""}
|
placeholder={props.placeholder ?? ""}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
if (hasValidation && validateOn === "change") validate();
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") emit("submit");
|
if (e.key === "Enter") emit("submit");
|
||||||
}}
|
}}
|
||||||
onFocus={() => emit("focus")}
|
onFocus={() => emit("focus")}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (hasValidation) validate();
|
if (hasValidation && validateOn === "blur") validate();
|
||||||
emit("blur");
|
emit("blur");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -711,11 +715,12 @@ export const shadcnComponents = {
|
|||||||
const isBound = !!bindings?.value;
|
const isBound = !!bindings?.value;
|
||||||
const value = isBound ? (boundValue ?? "") : localValue;
|
const value = isBound ? (boundValue ?? "") : localValue;
|
||||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||||
|
const validateOn = props.validateOn ?? "blur";
|
||||||
|
|
||||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||||
const { errors, validate } = useFieldValidation(
|
const { errors, validate } = useFieldValidation(
|
||||||
bindings?.value ?? "",
|
bindings?.value ?? "",
|
||||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -729,9 +734,12 @@ export const shadcnComponents = {
|
|||||||
placeholder={props.placeholder ?? ""}
|
placeholder={props.placeholder ?? ""}
|
||||||
rows={props.rows ?? 3}
|
rows={props.rows ?? 3}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
if (hasValidation && validateOn === "change") validate();
|
||||||
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (hasValidation) validate();
|
if (hasValidation && validateOn === "blur") validate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
@@ -758,11 +766,12 @@ export const shadcnComponents = {
|
|||||||
const options = rawOptions.map((opt) =>
|
const options = rawOptions.map((opt) =>
|
||||||
typeof opt === "string" ? opt : String(opt ?? ""),
|
typeof opt === "string" ? opt : String(opt ?? ""),
|
||||||
);
|
);
|
||||||
|
const validateOn = props.validateOn ?? "change";
|
||||||
|
|
||||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||||
const { errors, validate } = useFieldValidation(
|
const { errors, validate } = useFieldValidation(
|
||||||
bindings?.value ?? "",
|
bindings?.value ?? "",
|
||||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -772,7 +781,8 @@ export const shadcnComponents = {
|
|||||||
value={value}
|
value={value}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
if (hasValidation) validate();
|
// Select has no native blur event, so only validate on "change"
|
||||||
|
if (hasValidation && validateOn === "change") validate();
|
||||||
emit("change");
|
emit("change");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -808,19 +818,32 @@ export const shadcnComponents = {
|
|||||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||||
|
|
||||||
|
const validateOn = props.validateOn ?? "change";
|
||||||
|
const hasValidation = !!(bindings?.checked && props.checks?.length);
|
||||||
|
const { errors, validate } = useFieldValidation(
|
||||||
|
bindings?.checked ?? "",
|
||||||
|
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="space-y-1">
|
||||||
<Checkbox
|
<div className="flex items-center space-x-2">
|
||||||
id={props.name ?? undefined}
|
<Checkbox
|
||||||
checked={checked}
|
id={props.name ?? undefined}
|
||||||
onCheckedChange={(c) => {
|
checked={checked}
|
||||||
setChecked(c === true);
|
onCheckedChange={(c) => {
|
||||||
emit("change");
|
setChecked(c === true);
|
||||||
}}
|
if (hasValidation && validateOn === "change") validate();
|
||||||
/>
|
emit("change");
|
||||||
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
}}
|
||||||
{props.label}
|
/>
|
||||||
</Label>
|
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
||||||
|
{props.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -843,6 +866,13 @@ export const shadcnComponents = {
|
|||||||
const value = isBound ? (boundValue ?? "") : localValue;
|
const value = isBound ? (boundValue ?? "") : localValue;
|
||||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||||
|
|
||||||
|
const validateOn = props.validateOn ?? "change";
|
||||||
|
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||||
|
const { errors, validate } = useFieldValidation(
|
||||||
|
bindings?.value ?? "",
|
||||||
|
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{props.label && <Label>{props.label}</Label>}
|
{props.label && <Label>{props.label}</Label>}
|
||||||
@@ -850,6 +880,7 @@ export const shadcnComponents = {
|
|||||||
value={value}
|
value={value}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setValue(v);
|
setValue(v);
|
||||||
|
if (hasValidation && validateOn === "change") validate();
|
||||||
emit("change");
|
emit("change");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -868,6 +899,9 @@ export const shadcnComponents = {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -886,19 +920,32 @@ export const shadcnComponents = {
|
|||||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||||
|
|
||||||
|
const validateOn = props.validateOn ?? "change";
|
||||||
|
const hasValidation = !!(bindings?.checked && props.checks?.length);
|
||||||
|
const { errors, validate } = useFieldValidation(
|
||||||
|
bindings?.checked ?? "",
|
||||||
|
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between space-x-2">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
<div className="flex items-center justify-between space-x-2">
|
||||||
{props.label}
|
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
||||||
</Label>
|
{props.label}
|
||||||
<Switch
|
</Label>
|
||||||
id={props.name ?? undefined}
|
<Switch
|
||||||
checked={checked}
|
id={props.name ?? undefined}
|
||||||
onCheckedChange={(c) => {
|
checked={checked}
|
||||||
setChecked(c);
|
onCheckedChange={(c) => {
|
||||||
emit("change");
|
setChecked(c);
|
||||||
}}
|
if (hasValidation && validateOn === "change") validate();
|
||||||
/>
|
emit("change");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
# @json-render/vue
|
||||||
|
|
||||||
|
Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/vue @json-render/core zod
|
||||||
|
```
|
||||||
|
|
||||||
|
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create a Catalog
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from "@json-render/core";
|
||||||
|
import { schema } from "@json-render/vue/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const catalog = defineCatalog(schema, {
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
props: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
description: "A card container",
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
props: z.object({
|
||||||
|
label: z.string(),
|
||||||
|
action: z.string(),
|
||||||
|
}),
|
||||||
|
description: "A clickable button",
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
props: z.object({
|
||||||
|
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
|
||||||
|
label: z.string(),
|
||||||
|
placeholder: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
description: "Text input field with optional value binding",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
submit: { description: "Submit the form" },
|
||||||
|
cancel: { description: "Cancel and close" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define Component Implementations
|
||||||
|
|
||||||
|
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
|
||||||
|
|
||||||
|
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { h } from "vue";
|
||||||
|
import { defineRegistry } from "@json-render/vue";
|
||||||
|
import { catalog } from "./catalog";
|
||||||
|
|
||||||
|
export const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: ({ props, children }) =>
|
||||||
|
h("div", { class: "card" }, [
|
||||||
|
h("h3", null, props.title),
|
||||||
|
props.description ? h("p", null, props.description) : null,
|
||||||
|
children,
|
||||||
|
]),
|
||||||
|
Button: ({ props, emit }) =>
|
||||||
|
h("button", { onClick: () => emit("press") }, props.label),
|
||||||
|
Input: ({ props, bindings }) => {
|
||||||
|
// Use bindings?.value with a watcher to implement two-way binding
|
||||||
|
return h("label", null, [
|
||||||
|
props.label,
|
||||||
|
h("input", {
|
||||||
|
placeholder: props.placeholder ?? "",
|
||||||
|
value: props.value ?? "",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// actions stubs are required when the catalog declares actions:
|
||||||
|
actions: {
|
||||||
|
submit: async () => {},
|
||||||
|
cancel: async () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
|
||||||
|
|
||||||
|
### 3. Render Specs
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { StateProvider, ActionProvider, Renderer } from "@json-render/vue";
|
||||||
|
import { registry } from "./registry";
|
||||||
|
|
||||||
|
const spec = { /* ... */ };
|
||||||
|
|
||||||
|
function handleSubmit(params) {
|
||||||
|
console.log("Submit", params);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<StateProvider :initial-state="{ form: { name: '' } }">
|
||||||
|
<ActionProvider :handlers="{ submit: handleSubmit }">
|
||||||
|
<Renderer :spec="spec" :registry="registry" />
|
||||||
|
</ActionProvider>
|
||||||
|
</StateProvider>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spec Format
|
||||||
|
|
||||||
|
The Vue renderer uses the same flat element map format as the React renderer:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Spec {
|
||||||
|
root: string; // Key of the root element
|
||||||
|
elements: Record<string, UIElement>; // Flat map of elements by key
|
||||||
|
state?: Record<string, unknown>; // Optional initial state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UIElement {
|
||||||
|
type: string; // Component name from catalog
|
||||||
|
props: Record<string, unknown>; // Component props
|
||||||
|
children?: string[]; // Keys of child elements
|
||||||
|
visible?: VisibilityCondition; // Visibility condition
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example spec:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "card-1",
|
||||||
|
"elements": {
|
||||||
|
"card-1": {
|
||||||
|
"type": "Card",
|
||||||
|
"props": { "title": "Welcome" },
|
||||||
|
"children": ["input-1", "btn-1"]
|
||||||
|
},
|
||||||
|
"input-1": {
|
||||||
|
"type": "Input",
|
||||||
|
"props": {
|
||||||
|
"value": { "$bindState": "/form/name" },
|
||||||
|
"label": "Name",
|
||||||
|
"placeholder": "Enter name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"btn-1": {
|
||||||
|
"type": "Button",
|
||||||
|
"props": { "label": "Submit" },
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### StateProvider
|
||||||
|
|
||||||
|
Share data across components with JSON Pointer paths:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<StateProvider :initial-state="{ user: { name: 'John' } }">
|
||||||
|
<!-- children -->
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In composables:
|
||||||
|
const { state, get, set } = useStateStore();
|
||||||
|
const name = get("/user/name"); // "John"
|
||||||
|
set("/user/age", 25);
|
||||||
|
|
||||||
|
// state is a ShallowRef<StateModel> — access with state.value
|
||||||
|
console.log(state.value);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/vue";
|
||||||
|
|
||||||
|
// Option 1: Use the built-in store outside of Vue
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<StateProvider :store="store">
|
||||||
|
<!-- children -->
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Mutate from anywhere — Vue will re-render automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
|
||||||
|
// Option 2: Implement the StateStore interface with your own backend
|
||||||
|
const piniaStore: StateStore = {
|
||||||
|
get: (path) => getByPath(myStore.$state, path),
|
||||||
|
set: (path, value) => myStore.$patch(/* ... */),
|
||||||
|
update: (updates) => myStore.$patch(/* ... */),
|
||||||
|
getSnapshot: () => myStore.$state,
|
||||||
|
subscribe: (listener) => myStore.$subscribe(listener),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `initialState` and `onStateChange` are ignored.
|
||||||
|
|
||||||
|
### ActionProvider
|
||||||
|
|
||||||
|
Handle actions from components:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ActionProvider :handlers="{ submit: handleSubmit, cancel: handleCancel }">
|
||||||
|
<!-- children -->
|
||||||
|
</ActionProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### VisibilityProvider
|
||||||
|
|
||||||
|
Control element visibility based on data:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<VisibilityProvider>
|
||||||
|
<!-- children -->
|
||||||
|
</VisibilityProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Alert",
|
||||||
|
"props": { "message": "Error!" },
|
||||||
|
"visible": { "$state": "/form/hasError" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ValidationProvider
|
||||||
|
|
||||||
|
Add field validation:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ValidationProvider>
|
||||||
|
<!-- children -->
|
||||||
|
</ValidationProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use validation composable:
|
||||||
|
const { errors, validate } = useFieldValidation("/form/email", {
|
||||||
|
checks: [
|
||||||
|
{ type: "required", message: "Email required" },
|
||||||
|
{ type: "email", message: "Invalid email" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composables
|
||||||
|
|
||||||
|
| Composable | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| `useStateStore()` | Access state context (`state` as `ShallowRef`, `get`, `set`, `update`) |
|
||||||
|
| `useStateValue(path)` | Get single value from state |
|
||||||
|
| `useStateBinding(path)` | Two-way data binding (deprecated — use `$bindState` instead) |
|
||||||
|
| `useIsVisible(condition)` | Check if a visibility condition is met |
|
||||||
|
| `useActions()` | Access action context |
|
||||||
|
| `useAction(binding)` | Get a single action dispatch function |
|
||||||
|
| `useFieldValidation(path, config)` | Field validation state |
|
||||||
|
|
||||||
|
> **Note:** `useStateStore().state` returns a `ShallowRef<StateModel>` — use `state.value` to access the underlying object. This differs from the React renderer where `state` is a plain object.
|
||||||
|
|
||||||
|
## Visibility Conditions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Truthiness check
|
||||||
|
{ "$state": "/user/isAdmin" }
|
||||||
|
|
||||||
|
// Auth state (use state path)
|
||||||
|
{ "$state": "/auth/isSignedIn" }
|
||||||
|
|
||||||
|
// Comparisons (flat style)
|
||||||
|
{ "$state": "/status", "eq": "active" }
|
||||||
|
{ "$state": "/count", "gt": 10 }
|
||||||
|
|
||||||
|
// Negation
|
||||||
|
{ "$state": "/maintenance", "not": true }
|
||||||
|
|
||||||
|
// Multiple conditions (implicit AND)
|
||||||
|
[
|
||||||
|
{ "$state": "/feature/enabled" },
|
||||||
|
{ "$state": "/maintenance", "not": true }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Always / never
|
||||||
|
true // always visible
|
||||||
|
false // never visible
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeScript helpers from `@json-render/core`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { visibility } from "@json-render/core";
|
||||||
|
|
||||||
|
visibility.when("/path") // { $state: "/path" }
|
||||||
|
visibility.unless("/path") // { $state: "/path", not: true }
|
||||||
|
visibility.eq("/path", val) // { $state: "/path", eq: val }
|
||||||
|
visibility.neq("/path", val) // { $state: "/path", neq: val }
|
||||||
|
visibility.and(cond1, cond2) // { $and: [cond1, cond2] }
|
||||||
|
visibility.always // true
|
||||||
|
visibility.never // false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Prop Expressions
|
||||||
|
|
||||||
|
Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Badge",
|
||||||
|
"props": {
|
||||||
|
"label": { "$state": "/user/role" },
|
||||||
|
"color": {
|
||||||
|
"$cond": { "$state": "/user/role", "eq": "admin" },
|
||||||
|
"$then": "red",
|
||||||
|
"$else": "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop. Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop.
|
||||||
|
|
||||||
|
See [@json-render/core](../core/README.md) for full expression syntax.
|
||||||
|
|
||||||
|
## Built-in Actions
|
||||||
|
|
||||||
|
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Button",
|
||||||
|
"props": { "label": "Switch Tab" },
|
||||||
|
"on": {
|
||||||
|
"press": {
|
||||||
|
"action": "setState",
|
||||||
|
"params": { "statePath": "/activeTab", "value": "settings" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Props
|
||||||
|
|
||||||
|
When using `defineRegistry`, components receive these props via their render function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { VNode } from "vue";
|
||||||
|
|
||||||
|
interface ComponentContext<P> {
|
||||||
|
props: P; // Typed props from the catalog (expressions resolved)
|
||||||
|
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; // Whether the parent is loading
|
||||||
|
bindings?: Record<string, string>; // State paths for $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 to check metadata like `shouldPreventDefault` or `bound`:
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic `BaseComponentProps` type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { BaseComponentProps } from "@json-render/vue";
|
||||||
|
|
||||||
|
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
|
||||||
|
h("div", null, [props.title, children]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generate AI Prompts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const systemPrompt = catalog.prompt();
|
||||||
|
// Returns detailed prompt with component/action descriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { h } from "vue";
|
||||||
|
import { defineCatalog } from "@json-render/core";
|
||||||
|
import { schema } from "@json-render/vue/schema";
|
||||||
|
import { defineRegistry, Renderer, StateProvider } from "@json-render/vue";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: {
|
||||||
|
Greeting: {
|
||||||
|
props: z.object({ name: z.string() }),
|
||||||
|
description: "Displays a greeting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = {
|
||||||
|
root: "greeting-1",
|
||||||
|
elements: {
|
||||||
|
"greeting-1": {
|
||||||
|
type: "Greeting",
|
||||||
|
props: { name: "World" },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// In your App.vue:
|
||||||
|
// <StateProvider>
|
||||||
|
// <Renderer :spec="spec" :registry="registry" />
|
||||||
|
// </StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Exports
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `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`) |
|
||||||
|
| `useStateStore` | Access state context (`state` is `ShallowRef<StateModel>`) |
|
||||||
|
| `useStateValue` | Get single value from state |
|
||||||
|
| `useActions` | Access actions context |
|
||||||
|
| `useAction` | Get a single action dispatch function |
|
||||||
|
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `ComponentContext` | Typed component render function context (catalog-aware) |
|
||||||
|
| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
|
||||||
|
| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
|
||||||
|
| `ActionProviderProps` | Props for `ActionProvider` |
|
||||||
|
| `ValidationProviderProps` | Props for `ValidationProvider` |
|
||||||
|
| `ComponentFn` | Component render function type |
|
||||||
|
| `SetState` | State setter type |
|
||||||
|
| `StateModel` | State model type |
|
||||||
|
| `StateStore` | Interface for plugging in external state management |
|
||||||
|
|
||||||
|
## Differences from `@json-render/react`
|
||||||
|
|
||||||
|
| API | React | Vue | Note |
|
||||||
|
|-----|-------|-----|------|
|
||||||
|
| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
|
||||||
|
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
|
||||||
|
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
|
||||||
|
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "@json-render/vue",
|
||||||
|
"version": "0.9.1",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
|
||||||
|
"keywords": [
|
||||||
|
"json",
|
||||||
|
"ui",
|
||||||
|
"vue",
|
||||||
|
"ai",
|
||||||
|
"generative-ui",
|
||||||
|
"llm",
|
||||||
|
"renderer",
|
||||||
|
"streaming",
|
||||||
|
"components"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||||
|
"directory": "packages/vue"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./schema": {
|
||||||
|
"types": "./dist/schema.d.ts",
|
||||||
|
"import": "./dist/schema.mjs",
|
||||||
|
"require": "./dist/schema.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@json-render/core": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/typescript-config": "workspace:*",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"tsup": "^8.0.2",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { VNode } from "vue";
|
||||||
|
import type {
|
||||||
|
Catalog,
|
||||||
|
InferCatalogComponents,
|
||||||
|
InferCatalogActions,
|
||||||
|
InferComponentProps,
|
||||||
|
InferActionParams,
|
||||||
|
StateModel,
|
||||||
|
} from "@json-render/core";
|
||||||
|
|
||||||
|
export type { StateModel };
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// State Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State setter function for updating application state
|
||||||
|
*/
|
||||||
|
export type SetState = (
|
||||||
|
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle returned by the `on()` function for a specific event.
|
||||||
|
* Provides metadata about the event binding and a method to fire it.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const press = on("press");
|
||||||
|
* if (press.shouldPreventDefault) e.preventDefault();
|
||||||
|
* press.emit();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface EventHandle {
|
||||||
|
/** Fire the event (resolve action bindings) */
|
||||||
|
emit: () => void;
|
||||||
|
/** Whether any binding requested preventDefault */
|
||||||
|
shouldPreventDefault: boolean;
|
||||||
|
/** Whether any handler is bound to this event */
|
||||||
|
bound: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog-agnostic base type for component render function arguments.
|
||||||
|
* Use this when building reusable component libraries that are not tied to a specific catalog.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
|
||||||
|
* h('div', null, [props.title, children])
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface BaseComponentProps<P = Record<string, unknown>> {
|
||||||
|
props: P;
|
||||||
|
/** Rendered children (from the default slot) */
|
||||||
|
children?: VNode | VNode[];
|
||||||
|
/** Simple event emitter (shorthand). Fires the event and returns void. */
|
||||||
|
emit: (event: string) => void;
|
||||||
|
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
|
||||||
|
on: (event: string) => EventHandle;
|
||||||
|
/**
|
||||||
|
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
|
||||||
|
* Maps prop name → absolute state path for write-back.
|
||||||
|
*/
|
||||||
|
bindings?: Record<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to component render functions
|
||||||
|
* @example
|
||||||
|
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
|
||||||
|
* return h('button', { onClick: () => ctx.emit("press") }, ctx.props.label)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export interface ComponentContext<
|
||||||
|
C extends Catalog,
|
||||||
|
K extends keyof InferCatalogComponents<C>,
|
||||||
|
> extends BaseComponentProps<InferComponentProps<C, K>> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component render function type for Vue
|
||||||
|
* @example
|
||||||
|
* const Button: ComponentFn<typeof catalog, 'Button'> = ({ props, emit }) =>
|
||||||
|
* h('button', { onClick: () => emit("press") }, props.label)
|
||||||
|
*/
|
||||||
|
export type ComponentFn<
|
||||||
|
C extends Catalog,
|
||||||
|
K extends keyof InferCatalogComponents<C>,
|
||||||
|
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of all component render functions for a catalog
|
||||||
|
* @example
|
||||||
|
* const components: Components<typeof myCatalog> = {
|
||||||
|
* Button: ({ props }) => h('button', null, props.label),
|
||||||
|
* Input: ({ props }) => h('input', { placeholder: props.placeholder }),
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export type Components<C extends Catalog> = {
|
||||||
|
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Action Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action handler function type
|
||||||
|
* @example
|
||||||
|
* const viewCustomers: ActionFn<typeof catalog, 'viewCustomers'> = async (params, setState) => {
|
||||||
|
* const data = await fetch('/api/customers');
|
||||||
|
* setState(prev => ({ ...prev, customers: data }));
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export type ActionFn<
|
||||||
|
C extends Catalog,
|
||||||
|
K extends keyof InferCatalogActions<C>,
|
||||||
|
> = (
|
||||||
|
params: InferActionParams<C, K> | undefined,
|
||||||
|
setState: SetState,
|
||||||
|
state: StateModel,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of all action handlers for a catalog
|
||||||
|
* @example
|
||||||
|
* const actions: Actions<typeof myCatalog> = {
|
||||||
|
* viewCustomers: async (params, setState) => { ... },
|
||||||
|
* createCustomer: async (params, setState) => { ... },
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export type Actions<C extends Catalog> = {
|
||||||
|
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the catalog declares at least one action, false otherwise.
|
||||||
|
* Used by defineRegistry to conditionally require the `actions` field.
|
||||||
|
*/
|
||||||
|
export type CatalogHasActions<C extends Catalog> = [
|
||||||
|
InferCatalogActions<C>,
|
||||||
|
] extends [never]
|
||||||
|
? false
|
||||||
|
: [keyof InferCatalogActions<C>] extends [never]
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { defineComponent, h, type Component } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { StateProvider, useStateStore } from "./state";
|
||||||
|
import { ActionProvider, useActions, useAction } from "./actions";
|
||||||
|
|
||||||
|
/** Mount StateProvider → ActionProvider with a child that captures context. */
|
||||||
|
function withProviders<T>(
|
||||||
|
composable: () => T,
|
||||||
|
handlers: Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => Promise<void>
|
||||||
|
> = {},
|
||||||
|
initialState: Record<string, unknown> = {},
|
||||||
|
): { result: T } {
|
||||||
|
let result!: T;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = composable();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ActionProvider as Component, { handlers } as any, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ActionProvider — provide/inject", () => {
|
||||||
|
it("useActions() throws outside a provider", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(() => useActions()).toThrow(
|
||||||
|
"useActions must be used within an ActionProvider",
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ActionProvider — custom handler dispatch", () => {
|
||||||
|
it("calling execute invokes the registered handler", async () => {
|
||||||
|
const handler = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { result } = withProviders(() => useActions(), { myAction: handler });
|
||||||
|
await result.execute({ action: "myAction", params: { x: 1 } });
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handler receives the resolved params", async () => {
|
||||||
|
const handler = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { result } = withProviders(() => useActions(), { myAction: handler });
|
||||||
|
await result.execute({ action: "myAction", params: { x: 1, y: "hello" } });
|
||||||
|
expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("console.warn is called for unknown actions", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const { result } = withProviders(() => useActions());
|
||||||
|
await result.execute({ action: "unknownAction" });
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknownAction"));
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ActionProvider — built-in setState integration", () => {
|
||||||
|
it("executing setState updates state via the provider chain", async () => {
|
||||||
|
let stateCtx!: ReturnType<typeof useStateStore>;
|
||||||
|
let actionsCtx!: ReturnType<typeof useActions>;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
stateCtx = useStateStore();
|
||||||
|
actionsCtx = useActions();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: { v: 0 } } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ActionProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await actionsCtx.execute({
|
||||||
|
action: "setState",
|
||||||
|
params: { statePath: "/v", value: 42 },
|
||||||
|
});
|
||||||
|
expect(stateCtx.state.value).toEqual({ v: 42 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAction", () => {
|
||||||
|
it("returns { execute, isLoading: false } before execution", () => {
|
||||||
|
const { result } = withProviders(() => useAction({ action: "myAction" }));
|
||||||
|
expect(typeof result.execute).toBe("function");
|
||||||
|
expect(result.isLoading.value).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
inject,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
type ComputedRef,
|
||||||
|
type PropType,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
resolveAction,
|
||||||
|
executeAction,
|
||||||
|
type ActionBinding,
|
||||||
|
type ActionHandler,
|
||||||
|
type ActionConfirm,
|
||||||
|
type ResolvedAction,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import { useStateStore } from "./state";
|
||||||
|
import { useOptionalValidation } from "./validation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique ID for use with the "$id" token.
|
||||||
|
*/
|
||||||
|
let idCounter = 0;
|
||||||
|
function generateUniqueId(): string {
|
||||||
|
idCounter += 1;
|
||||||
|
return `${Date.now()}-${idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-resolve dynamic value references within an object.
|
||||||
|
*
|
||||||
|
* Supported tokens:
|
||||||
|
* - `{ $state: "/statePath" }` - read a value from state
|
||||||
|
* - `"$id"` (string) or `{ "$id": true }` - generate a unique ID
|
||||||
|
*/
|
||||||
|
function deepResolveValue(
|
||||||
|
value: unknown,
|
||||||
|
get: (path: string) => unknown,
|
||||||
|
): unknown {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
|
||||||
|
if (value === "$id") {
|
||||||
|
return generateUniqueId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
|
||||||
|
if (keys.length === 1 && typeof obj.$state === "string") {
|
||||||
|
return get(obj.$state as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keys.length === 1 && "$id" in obj) {
|
||||||
|
return generateUniqueId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => deepResolveValue(item, get));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
resolved[key] = deepResolveValue(val, get);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending confirmation state
|
||||||
|
*/
|
||||||
|
export interface PendingConfirmation {
|
||||||
|
action: ResolvedAction;
|
||||||
|
handler: ActionHandler;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action context value
|
||||||
|
*/
|
||||||
|
export interface ActionContextValue {
|
||||||
|
handlers: Record<string, ActionHandler>;
|
||||||
|
loadingActions: Set<string>;
|
||||||
|
pendingConfirmation: PendingConfirmation | null;
|
||||||
|
execute: (binding: ActionBinding) => Promise<void>;
|
||||||
|
confirm: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
registerHandler: (name: string, handler: ActionHandler) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS_KEY = Symbol("json-render:actions");
|
||||||
|
|
||||||
|
export interface ActionProviderProps {
|
||||||
|
handlers?: Record<string, ActionHandler>;
|
||||||
|
navigate?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for action execution
|
||||||
|
*/
|
||||||
|
export const ActionProvider = defineComponent({
|
||||||
|
name: "ActionProvider",
|
||||||
|
props: {
|
||||||
|
handlers: {
|
||||||
|
type: Object as PropType<Record<string, ActionHandler>>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
navigate: {
|
||||||
|
type: Function as PropType<(path: string) => void>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const { get, set, getSnapshot } = useStateStore();
|
||||||
|
const validation = useOptionalValidation();
|
||||||
|
|
||||||
|
const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
|
||||||
|
const loadingActions = ref<Set<string>>(new Set());
|
||||||
|
const pendingConfirmation = ref<PendingConfirmation | null>(null);
|
||||||
|
|
||||||
|
// Sync handlers when prop changes
|
||||||
|
watch(
|
||||||
|
() => props.handlers,
|
||||||
|
(newHandlers) => {
|
||||||
|
if (newHandlers) handlers.value = newHandlers;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerHandler = (name: string, handler: ActionHandler) => {
|
||||||
|
handlers.value = { ...handlers.value, [name]: handler };
|
||||||
|
};
|
||||||
|
|
||||||
|
const execute = async (binding: ActionBinding): Promise<void> => {
|
||||||
|
const resolved = resolveAction(binding, getSnapshot());
|
||||||
|
|
||||||
|
// Built-in: setState
|
||||||
|
if (resolved.action === "setState" && resolved.params) {
|
||||||
|
const statePath = resolved.params.statePath as string;
|
||||||
|
const value = resolved.params.value;
|
||||||
|
if (statePath) {
|
||||||
|
set(statePath, value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in: pushState
|
||||||
|
if (resolved.action === "pushState" && resolved.params) {
|
||||||
|
const statePath = resolved.params.statePath as string;
|
||||||
|
const rawValue = resolved.params.value;
|
||||||
|
if (statePath) {
|
||||||
|
const resolvedValue = deepResolveValue(rawValue, get);
|
||||||
|
const arr = (get(statePath) as unknown[] | undefined) ?? [];
|
||||||
|
set(statePath, [...arr, resolvedValue]);
|
||||||
|
const clearStatePath = resolved.params.clearStatePath as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
if (clearStatePath) {
|
||||||
|
set(clearStatePath, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in: removeState
|
||||||
|
if (resolved.action === "removeState" && resolved.params) {
|
||||||
|
const statePath = resolved.params.statePath as string;
|
||||||
|
const index = resolved.params.index as number;
|
||||||
|
if (statePath !== undefined && index !== undefined) {
|
||||||
|
const arr = (get(statePath) as unknown[] | undefined) ?? [];
|
||||||
|
set(
|
||||||
|
statePath,
|
||||||
|
arr.filter((_, i) => i !== index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in: validateForm — triggers validateAll and writes result to state
|
||||||
|
if (resolved.action === "validateForm") {
|
||||||
|
const validateAll = validation?.validateAll;
|
||||||
|
if (!validateAll) {
|
||||||
|
console.warn(
|
||||||
|
"validateForm action was dispatched but no ValidationProvider is connected. " +
|
||||||
|
"Ensure ValidationProvider is rendered inside the provider tree.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = validateAll();
|
||||||
|
const errors: Record<string, string[]> = {};
|
||||||
|
for (const [path, fs] of Object.entries(validation.fieldStates)) {
|
||||||
|
if (fs.result && !fs.result.valid) {
|
||||||
|
errors[path] = fs.result.errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statePath =
|
||||||
|
(resolved.params?.statePath as string) || "/formValidation";
|
||||||
|
set(statePath, { valid, errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in: push (navigation)
|
||||||
|
if (resolved.action === "push" && resolved.params) {
|
||||||
|
const screen = resolved.params.screen as string;
|
||||||
|
if (screen) {
|
||||||
|
const currentScreen = get("/currentScreen") as string | undefined;
|
||||||
|
const navStack = (get("/navStack") as string[] | undefined) ?? [];
|
||||||
|
if (currentScreen) {
|
||||||
|
set("/navStack", [...navStack, currentScreen]);
|
||||||
|
} else {
|
||||||
|
set("/navStack", [...navStack, ""]);
|
||||||
|
}
|
||||||
|
set("/currentScreen", screen);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in: pop (navigation)
|
||||||
|
if (resolved.action === "pop") {
|
||||||
|
const navStack = (get("/navStack") as string[] | undefined) ?? [];
|
||||||
|
if (navStack.length > 0) {
|
||||||
|
const previousScreen = navStack[navStack.length - 1];
|
||||||
|
set("/navStack", navStack.slice(0, -1));
|
||||||
|
if (previousScreen) {
|
||||||
|
set("/currentScreen", previousScreen);
|
||||||
|
} else {
|
||||||
|
set("/currentScreen", undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = handlers.value[resolved.action];
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
console.warn(`No handler registered for action: ${resolved.action}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If confirmation is required, show dialog first
|
||||||
|
if (resolved.confirm) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
pendingConfirmation.value = {
|
||||||
|
action: resolved,
|
||||||
|
handler,
|
||||||
|
resolve: () => {
|
||||||
|
pendingConfirmation.value = null;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
reject: () => {
|
||||||
|
pendingConfirmation.value = null;
|
||||||
|
reject(new Error("Action cancelled"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLoading = new Set(loadingActions.value);
|
||||||
|
addLoading.add(resolved.action);
|
||||||
|
loadingActions.value = addLoading;
|
||||||
|
try {
|
||||||
|
await executeAction({
|
||||||
|
action: resolved,
|
||||||
|
handler,
|
||||||
|
setState: set,
|
||||||
|
navigate: props.navigate,
|
||||||
|
executeAction: async (name) => {
|
||||||
|
const subBinding: ActionBinding = { action: name };
|
||||||
|
await execute(subBinding);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const removeLoading = new Set(loadingActions.value);
|
||||||
|
removeLoading.delete(resolved.action);
|
||||||
|
loadingActions.value = removeLoading;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute immediately
|
||||||
|
const addLoading = new Set(loadingActions.value);
|
||||||
|
addLoading.add(resolved.action);
|
||||||
|
loadingActions.value = addLoading;
|
||||||
|
try {
|
||||||
|
await executeAction({
|
||||||
|
action: resolved,
|
||||||
|
handler,
|
||||||
|
setState: set,
|
||||||
|
navigate: props.navigate,
|
||||||
|
executeAction: async (name) => {
|
||||||
|
const subBinding: ActionBinding = { action: name };
|
||||||
|
await execute(subBinding);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const removeLoading = new Set(loadingActions.value);
|
||||||
|
removeLoading.delete(resolved.action);
|
||||||
|
loadingActions.value = removeLoading;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = () => pendingConfirmation.value?.resolve();
|
||||||
|
const cancel = () => pendingConfirmation.value?.reject();
|
||||||
|
|
||||||
|
provide<ActionContextValue>(ACTIONS_KEY, {
|
||||||
|
get handlers() {
|
||||||
|
return handlers.value;
|
||||||
|
},
|
||||||
|
get loadingActions() {
|
||||||
|
return loadingActions.value;
|
||||||
|
},
|
||||||
|
get pendingConfirmation() {
|
||||||
|
return pendingConfirmation.value;
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
confirm,
|
||||||
|
cancel,
|
||||||
|
registerHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to access action context
|
||||||
|
*/
|
||||||
|
export function useActions(): ActionContextValue {
|
||||||
|
const ctx = inject<ActionContextValue>(ACTIONS_KEY);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useActions must be used within an ActionProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to execute an action binding
|
||||||
|
*/
|
||||||
|
export function useAction(binding: ActionBinding): {
|
||||||
|
execute: () => Promise<void>;
|
||||||
|
isLoading: ComputedRef<boolean>;
|
||||||
|
} {
|
||||||
|
const ctx = useActions();
|
||||||
|
return {
|
||||||
|
execute: () => ctx.execute(binding),
|
||||||
|
isLoading: computed(() => ctx.loadingActions.has(binding.action)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ConfirmDialog component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for ConfirmDialog component
|
||||||
|
*/
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
confirm: ActionConfirm;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default confirmation dialog component
|
||||||
|
*/
|
||||||
|
export const ConfirmDialog = defineComponent({
|
||||||
|
name: "ConfirmDialog",
|
||||||
|
props: {
|
||||||
|
confirm: {
|
||||||
|
type: Object as PropType<ActionConfirm>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onConfirm: {
|
||||||
|
type: Function as PropType<() => void>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
type: Function as PropType<() => void>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
const isDanger = props.confirm.variant === "danger";
|
||||||
|
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
position: "fixed",
|
||||||
|
inset: "0",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: "50",
|
||||||
|
},
|
||||||
|
onClick: props.onCancel,
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "24px",
|
||||||
|
maxWidth: "400px",
|
||||||
|
width: "100%",
|
||||||
|
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
onClick: (e: MouseEvent) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
"h3",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props.confirm.title,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{
|
||||||
|
style: { margin: "0 0 24px 0", color: "#6b7280" },
|
||||||
|
},
|
||||||
|
props.confirm.message,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "12px",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
backgroundColor: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
onClick: props.onCancel,
|
||||||
|
},
|
||||||
|
props.confirm.cancelLabel ?? "Cancel",
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: isDanger ? "#dc2626" : "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
onClick: props.onConfirm,
|
||||||
|
},
|
||||||
|
props.confirm.confirmLabel ?? "Confirm",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineComponent, inject, provide, type PropType } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repeat scope value provided to child elements inside a repeated element.
|
||||||
|
*/
|
||||||
|
export interface RepeatScopeValue {
|
||||||
|
/** The current array item object */
|
||||||
|
item: unknown;
|
||||||
|
/** Index of the current item in the array */
|
||||||
|
index: number;
|
||||||
|
/** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */
|
||||||
|
basePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPEAT_SCOPE_KEY = Symbol("json-render:repeat-scope");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides repeat scope to child elements so $item and $index expressions resolve correctly.
|
||||||
|
*/
|
||||||
|
export const RepeatScopeProvider = defineComponent({
|
||||||
|
name: "RepeatScopeProvider",
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
basePath: {
|
||||||
|
type: String as PropType<string>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
provide(REPEAT_SCOPE_KEY, props as RepeatScopeValue);
|
||||||
|
return () => slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current repeat scope (or null if not inside a repeated element).
|
||||||
|
*/
|
||||||
|
export function useRepeatScope(): RepeatScopeValue | null {
|
||||||
|
return (
|
||||||
|
inject<RepeatScopeValue>(
|
||||||
|
REPEAT_SCOPE_KEY,
|
||||||
|
null as unknown as RepeatScopeValue,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { defineComponent, h, type Component } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { createStateStore } from "@json-render/core";
|
||||||
|
import { StateProvider, useStateStore } from "./state";
|
||||||
|
|
||||||
|
/** Mount a StateProvider with a child that captures the injected context. */
|
||||||
|
function withProvider<T>(
|
||||||
|
composable: () => T,
|
||||||
|
props: Record<string, unknown> = {},
|
||||||
|
): { result: T } {
|
||||||
|
let result!: T;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = composable();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: props as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StateProvider — provide/inject", () => {
|
||||||
|
it("useStateStore() throws outside a provider", () => {
|
||||||
|
// inject() returns undefined outside of component setup; our guard throws
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(() => useStateStore()).toThrow(
|
||||||
|
"useStateStore must be used within a StateProvider",
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("child receives the context from StateProvider", () => {
|
||||||
|
const { result } = withProvider(() => useStateStore());
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.state).toBeDefined();
|
||||||
|
expect(typeof result.get).toBe("function");
|
||||||
|
expect(typeof result.set).toBe("function");
|
||||||
|
expect(typeof result.update).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("state.value is a plain object (not a wrapper type)", () => {
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
initialState: { x: 1 },
|
||||||
|
});
|
||||||
|
expect(result.state.value).toEqual({ x: 1 });
|
||||||
|
// Should be a plain object, not a Vue Proxy of a ShallowRef wrapper
|
||||||
|
expect(typeof result.state.value).toBe("object");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StateProvider (uncontrolled) — reactivity", () => {
|
||||||
|
it("state.value reflects initialState on mount", () => {
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
initialState: { count: 5 },
|
||||||
|
});
|
||||||
|
expect(result.state.value).toEqual({ count: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after set(), state.value is updated synchronously", () => {
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
initialState: { x: 0 },
|
||||||
|
});
|
||||||
|
result.set("/x", 42);
|
||||||
|
expect(result.state.value).toEqual({ x: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after update(), all paths are reflected in state.value", () => {
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
initialState: {},
|
||||||
|
});
|
||||||
|
result.update({ "/a": 1, "/b": "hello" });
|
||||||
|
expect(result.state.value).toEqual({ a: 1, b: "hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onStateChange is fired with the changes array on set", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
initialState: {},
|
||||||
|
onStateChange: onChange,
|
||||||
|
});
|
||||||
|
result.set("/name", "Alice");
|
||||||
|
expect(onChange).toHaveBeenCalledOnce();
|
||||||
|
expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onStateChange is fired once with all changed entries on update", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
initialState: {},
|
||||||
|
onStateChange: onChange,
|
||||||
|
});
|
||||||
|
result.update({ "/a": 1, "/b": 2 });
|
||||||
|
expect(onChange).toHaveBeenCalledOnce();
|
||||||
|
const [changes] = onChange.mock.calls[0]!;
|
||||||
|
expect(changes).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ path: "/a", value: 1 },
|
||||||
|
{ path: "/b", value: 2 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StateProvider (controlled mode)", () => {
|
||||||
|
it("state.value reads from the external store snapshot", () => {
|
||||||
|
const store = createStateStore({ x: 10 });
|
||||||
|
const { result } = withProvider(() => useStateStore(), { store });
|
||||||
|
expect(result.state.value).toEqual({ x: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set() writes through to the external store", () => {
|
||||||
|
const store = createStateStore({ x: 0 });
|
||||||
|
const { result } = withProvider(() => useStateStore(), { store });
|
||||||
|
result.set("/x", 99);
|
||||||
|
expect(store.getSnapshot()).toEqual({ x: 99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("external store mutation triggers state.value update", () => {
|
||||||
|
const store = createStateStore({ x: 0 });
|
||||||
|
const { result } = withProvider(() => useStateStore(), { store });
|
||||||
|
store.set("/x", 99);
|
||||||
|
expect(result.state.value).toEqual({ x: 99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onStateChange is NOT called in controlled mode", () => {
|
||||||
|
const store = createStateStore({ x: 0 });
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { result } = withProvider(() => useStateStore(), {
|
||||||
|
store,
|
||||||
|
onStateChange: onChange,
|
||||||
|
});
|
||||||
|
result.set("/x", 99);
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
inject,
|
||||||
|
onUnmounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
type ComputedRef,
|
||||||
|
type PropType,
|
||||||
|
type ShallowRef,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
createStateStore,
|
||||||
|
getByPath,
|
||||||
|
type StateModel,
|
||||||
|
type StateStore,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import { flattenToPointers } from "@json-render/core/store-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State context value
|
||||||
|
*/
|
||||||
|
export interface StateContextValue {
|
||||||
|
/** Reactive state snapshot — use state.value in reactive contexts */
|
||||||
|
state: ShallowRef<StateModel>;
|
||||||
|
/** Get a value by path */
|
||||||
|
get: (path: string) => unknown;
|
||||||
|
/** Set a value by path */
|
||||||
|
set: (path: string, value: unknown) => void;
|
||||||
|
/** Update multiple values at once */
|
||||||
|
update: (updates: Record<string, unknown>) => void;
|
||||||
|
/** Return the live state snapshot from the underlying store (not through Vue reactivity). */
|
||||||
|
getSnapshot: () => StateModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_KEY = Symbol("json-render:state");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for StateProvider
|
||||||
|
*/
|
||||||
|
export interface StateProviderProps {
|
||||||
|
/**
|
||||||
|
* External store that owns the state. When provided, the provider operates
|
||||||
|
* in **controlled mode** — `initialState` and `onStateChange` are ignored
|
||||||
|
* and the store is the single source of truth.
|
||||||
|
*/
|
||||||
|
store?: StateStore;
|
||||||
|
/** Initial state model (used only in uncontrolled mode) */
|
||||||
|
initialState?: StateModel;
|
||||||
|
/**
|
||||||
|
* Callback when state changes (used only in uncontrolled mode).
|
||||||
|
* Called once per `set` or `update` with all changed entries.
|
||||||
|
*/
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for state model context.
|
||||||
|
*
|
||||||
|
* Supports two modes:
|
||||||
|
* - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
|
||||||
|
* - **Uncontrolled** (default): omit `store` and optionally pass
|
||||||
|
* `initialState` / `onStateChange`.
|
||||||
|
*/
|
||||||
|
export const StateProvider = defineComponent({
|
||||||
|
name: "StateProvider",
|
||||||
|
props: {
|
||||||
|
store: {
|
||||||
|
type: Object as PropType<StateStore>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
type: Object as PropType<StateModel>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
onStateChange: {
|
||||||
|
type: Function as PropType<
|
||||||
|
(changes: Array<{ path: string; value: unknown }>) => void
|
||||||
|
>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const isControlled = !!props.store;
|
||||||
|
|
||||||
|
// Use external store (controlled) or create internal store (uncontrolled)
|
||||||
|
const internalStore = !isControlled
|
||||||
|
? createStateStore(props.initialState ?? {})
|
||||||
|
: null;
|
||||||
|
const store: StateStore = props.store ?? internalStore!;
|
||||||
|
|
||||||
|
const state = shallowRef<StateModel>(store.getSnapshot());
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe(() => {
|
||||||
|
state.value = store.getSnapshot();
|
||||||
|
});
|
||||||
|
onUnmounted(unsubscribe);
|
||||||
|
|
||||||
|
// Sync external initialState changes (uncontrolled mode only)
|
||||||
|
if (!isControlled) {
|
||||||
|
let prevFlat: Record<string, unknown> =
|
||||||
|
props.initialState && Object.keys(props.initialState).length > 0
|
||||||
|
? flattenToPointers(props.initialState)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialState,
|
||||||
|
(newInitialState) => {
|
||||||
|
if (!newInitialState) return;
|
||||||
|
const nextFlat =
|
||||||
|
Object.keys(newInitialState).length > 0
|
||||||
|
? flattenToPointers(newInitialState)
|
||||||
|
: {};
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(prevFlat),
|
||||||
|
...Object.keys(nextFlat),
|
||||||
|
]);
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (prevFlat[key] !== nextFlat[key]) {
|
||||||
|
updates[key] = key in nextFlat ? nextFlat[key] : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevFlat = nextFlat;
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
store.update(updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep onStateChange in a ref so it always reads the latest callback
|
||||||
|
const onStateChangeRef = ref(props.onStateChange);
|
||||||
|
watch(
|
||||||
|
() => props.onStateChange,
|
||||||
|
(fn) => {
|
||||||
|
onStateChangeRef.value = fn;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const get = (path: string) => store.get(path);
|
||||||
|
const getSnapshot = () => store.getSnapshot();
|
||||||
|
|
||||||
|
const set = (path: string, value: unknown) => {
|
||||||
|
const prev = store.getSnapshot();
|
||||||
|
store.set(path, value);
|
||||||
|
if (!isControlled && store.getSnapshot() !== prev) {
|
||||||
|
onStateChangeRef.value?.([{ path, value }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (updates: Record<string, unknown>) => {
|
||||||
|
const prev = store.getSnapshot();
|
||||||
|
store.update(updates);
|
||||||
|
if (!isControlled && store.getSnapshot() !== prev) {
|
||||||
|
const changes: Array<{ path: string; value: unknown }> = [];
|
||||||
|
for (const [path, value] of Object.entries(updates)) {
|
||||||
|
if (getByPath(prev, path) !== value) {
|
||||||
|
changes.push({ path, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.length > 0) {
|
||||||
|
onStateChangeRef.value?.(changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
provide<StateContextValue>(STATE_KEY, {
|
||||||
|
state,
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
getSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to access the state context
|
||||||
|
*/
|
||||||
|
export function useStateStore(): StateContextValue {
|
||||||
|
const ctx = inject<StateContextValue>(STATE_KEY);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useStateStore must be used within a StateProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to get a value from the state model (reactive)
|
||||||
|
*/
|
||||||
|
export function useStateValue<T>(path: string): ComputedRef<T | undefined> {
|
||||||
|
const { state } = useStateStore();
|
||||||
|
return computed(() => getByPath(state.value, path) as T | undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to get and set a value from the state model by path.
|
||||||
|
*
|
||||||
|
* This is the path-based variant for use in arbitrary composables. For
|
||||||
|
* registry components that receive `bindings` from the renderer, prefer
|
||||||
|
* `useBoundProp` which reads the already-resolved prop value and writes back
|
||||||
|
* to the bound path.
|
||||||
|
*/
|
||||||
|
export function useStateBinding<T>(
|
||||||
|
path: string,
|
||||||
|
): [ComputedRef<T | undefined>, (value: T) => void] {
|
||||||
|
const { state, set } = useStateStore();
|
||||||
|
const value = computed(() => getByPath(state.value, path) as T | undefined);
|
||||||
|
const setValue = (newValue: T) => set(path, newValue);
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { defineComponent, h, type Component } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { StateProvider } from "./state";
|
||||||
|
import {
|
||||||
|
ValidationProvider,
|
||||||
|
useOptionalValidation,
|
||||||
|
useValidation,
|
||||||
|
useFieldValidation,
|
||||||
|
} from "./validation";
|
||||||
|
|
||||||
|
/** Mount StateProvider → ValidationProvider with a child that captures context. */
|
||||||
|
function withProviders<T>(
|
||||||
|
composable: () => T,
|
||||||
|
initialState: Record<string, unknown> = {},
|
||||||
|
): { result: T } {
|
||||||
|
let result!: T;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = composable();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount only inside StateProvider (no ValidationProvider) */
|
||||||
|
function withStateOnly<T>(composable: () => T): { result: T } {
|
||||||
|
let result!: T;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = composable();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: {} } as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ValidationProvider — provide/inject", () => {
|
||||||
|
it("useValidation() throws outside a provider", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(() => useValidation()).toThrow(
|
||||||
|
"useValidation must be used within a ValidationProvider",
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useOptionalValidation", () => {
|
||||||
|
it("returns null outside a ValidationProvider", () => {
|
||||||
|
const { result } = withStateOnly(() => useOptionalValidation());
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns context inside a ValidationProvider", () => {
|
||||||
|
const { result } = withProviders(() => useOptionalValidation());
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(typeof result!.validate).toBe("function");
|
||||||
|
expect(typeof result!.validateAll).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useFieldValidation — lifecycle", () => {
|
||||||
|
it("after validate(), isValid and errors reflect the result", () => {
|
||||||
|
const { result } = withProviders(
|
||||||
|
() =>
|
||||||
|
useFieldValidation("/name", {
|
||||||
|
checks: [{ type: "required", message: "Name is required" }],
|
||||||
|
}),
|
||||||
|
{ name: "" },
|
||||||
|
);
|
||||||
|
const validationResult = result.validate();
|
||||||
|
expect(validationResult.valid).toBe(false);
|
||||||
|
expect(validationResult.errors).toContain("Name is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after validate() with valid value, isValid is true", () => {
|
||||||
|
const { result } = withProviders(
|
||||||
|
() =>
|
||||||
|
useFieldValidation("/name", {
|
||||||
|
checks: [{ type: "required", message: "Name is required" }],
|
||||||
|
}),
|
||||||
|
{ name: "Alice" },
|
||||||
|
);
|
||||||
|
const validationResult = result.validate();
|
||||||
|
expect(validationResult.valid).toBe(true);
|
||||||
|
expect(validationResult.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touch() sets touched: true in the validation context", () => {
|
||||||
|
let validationCtx!: ReturnType<typeof useValidation>;
|
||||||
|
let fieldCtx!: ReturnType<typeof useFieldValidation>;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
validationCtx = useValidation();
|
||||||
|
fieldCtx = useFieldValidation("/email");
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: {} } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldCtx.touch();
|
||||||
|
expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear() resets the field state from the validation context", () => {
|
||||||
|
let validationCtx!: ReturnType<typeof useValidation>;
|
||||||
|
let fieldCtx!: ReturnType<typeof useFieldValidation>;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
validationCtx = useValidation();
|
||||||
|
fieldCtx = useFieldValidation("/email", {
|
||||||
|
checks: [{ type: "required", message: "Required" }],
|
||||||
|
});
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: { email: "" } } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldCtx.validate(); // populate fieldStates
|
||||||
|
fieldCtx.clear();
|
||||||
|
expect(validationCtx.fieldStates["/email"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateAll() returns true when all registered fields pass", () => {
|
||||||
|
let validationCtx!: ReturnType<typeof useValidation>;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
validationCtx = useValidation();
|
||||||
|
useFieldValidation("/name", {
|
||||||
|
checks: [{ type: "required", message: "Required" }],
|
||||||
|
});
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: { name: "Alice" } } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validationCtx.validateAll()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateAll() returns false when any field fails", () => {
|
||||||
|
let validationCtx!: ReturnType<typeof useValidation>;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
validationCtx = useValidation();
|
||||||
|
useFieldValidation("/name", {
|
||||||
|
checks: [{ type: "required", message: "Required" }],
|
||||||
|
});
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: { name: "" } } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validationCtx.validateAll()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
inject,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
type ComputedRef,
|
||||||
|
type PropType,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
runValidation,
|
||||||
|
type ValidationConfig,
|
||||||
|
type ValidationFunction,
|
||||||
|
type ValidationResult,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import { useStateStore } from "./state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field validation state
|
||||||
|
*/
|
||||||
|
export interface FieldValidationState {
|
||||||
|
touched: boolean;
|
||||||
|
validated: boolean;
|
||||||
|
result: ValidationResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation context value
|
||||||
|
*/
|
||||||
|
export interface ValidationContextValue {
|
||||||
|
customFunctions: Record<string, ValidationFunction>;
|
||||||
|
fieldStates: Record<string, FieldValidationState>;
|
||||||
|
validate: (path: string, config: ValidationConfig) => ValidationResult;
|
||||||
|
touch: (path: string) => void;
|
||||||
|
clear: (path: string) => void;
|
||||||
|
validateAll: () => boolean;
|
||||||
|
registerField: (path: string, config: ValidationConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALIDATION_KEY = Symbol("json-render:validation");
|
||||||
|
|
||||||
|
export interface ValidationProviderProps {
|
||||||
|
customFunctions?: Record<string, ValidationFunction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two args records shallowly.
|
||||||
|
*/
|
||||||
|
function dynamicArgsEqual(
|
||||||
|
a: Record<string, unknown> | undefined,
|
||||||
|
b: Record<string, unknown> | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
const keysA = Object.keys(a);
|
||||||
|
const keysB = Object.keys(b);
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
for (const key of keysA) {
|
||||||
|
const va = a[key];
|
||||||
|
const vb = b[key];
|
||||||
|
if (va === vb) continue;
|
||||||
|
if (
|
||||||
|
typeof va === "object" &&
|
||||||
|
va !== null &&
|
||||||
|
typeof vb === "object" &&
|
||||||
|
vb !== null
|
||||||
|
) {
|
||||||
|
const sa = (va as Record<string, unknown>).$state;
|
||||||
|
const sb = (vb as Record<string, unknown>).$state;
|
||||||
|
if (typeof sa === "string" && sa === sb) continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural equality check for ValidationConfig.
|
||||||
|
*/
|
||||||
|
function validationConfigEqual(
|
||||||
|
a: ValidationConfig,
|
||||||
|
b: ValidationConfig,
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a.validateOn !== b.validateOn) return false;
|
||||||
|
const ac = a.checks ?? [];
|
||||||
|
const bc = b.checks ?? [];
|
||||||
|
if (ac.length !== bc.length) return false;
|
||||||
|
for (let i = 0; i < ac.length; i++) {
|
||||||
|
const ca = ac[i]!;
|
||||||
|
const cb = bc[i]!;
|
||||||
|
if (ca.type !== cb.type) return false;
|
||||||
|
if (ca.message !== cb.message) return false;
|
||||||
|
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for validation
|
||||||
|
*/
|
||||||
|
export const ValidationProvider = defineComponent({
|
||||||
|
name: "ValidationProvider",
|
||||||
|
props: {
|
||||||
|
customFunctions: {
|
||||||
|
type: Object as PropType<Record<string, ValidationFunction>>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const { state } = useStateStore();
|
||||||
|
|
||||||
|
const fieldStates = ref<Record<string, FieldValidationState>>({});
|
||||||
|
const fieldConfigs = ref<Record<string, ValidationConfig>>({});
|
||||||
|
|
||||||
|
const registerField = (path: string, config: ValidationConfig) => {
|
||||||
|
const existing = fieldConfigs.value[path];
|
||||||
|
if (existing && validationConfigEqual(existing, config)) return;
|
||||||
|
fieldConfigs.value = { ...fieldConfigs.value, [path]: config };
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (
|
||||||
|
path: string,
|
||||||
|
config: ValidationConfig,
|
||||||
|
): ValidationResult => {
|
||||||
|
const currentState = state.value;
|
||||||
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
let value: unknown = currentState;
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (value != null && typeof value === "object") {
|
||||||
|
value = (value as Record<string, unknown>)[seg];
|
||||||
|
} else {
|
||||||
|
value = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = runValidation(config, {
|
||||||
|
value,
|
||||||
|
stateModel: currentState,
|
||||||
|
customFunctions: props.customFunctions,
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldStates.value = {
|
||||||
|
...fieldStates.value,
|
||||||
|
[path]: {
|
||||||
|
touched: fieldStates.value[path]?.touched ?? true,
|
||||||
|
validated: true,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const touch = (path: string) => {
|
||||||
|
fieldStates.value = {
|
||||||
|
...fieldStates.value,
|
||||||
|
[path]: {
|
||||||
|
...fieldStates.value[path],
|
||||||
|
touched: true,
|
||||||
|
validated: fieldStates.value[path]?.validated ?? false,
|
||||||
|
result: fieldStates.value[path]?.result ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = (path: string) => {
|
||||||
|
const { [path]: _, ...rest } = fieldStates.value;
|
||||||
|
fieldStates.value = rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAll = (): boolean => {
|
||||||
|
let allValid = true;
|
||||||
|
for (const [path, config] of Object.entries(fieldConfigs.value)) {
|
||||||
|
const result = validate(path, config);
|
||||||
|
if (!result.valid) {
|
||||||
|
allValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
provide<ValidationContextValue>(VALIDATION_KEY, {
|
||||||
|
get customFunctions() {
|
||||||
|
return props.customFunctions;
|
||||||
|
},
|
||||||
|
get fieldStates() {
|
||||||
|
return fieldStates.value;
|
||||||
|
},
|
||||||
|
validate,
|
||||||
|
touch,
|
||||||
|
clear,
|
||||||
|
validateAll,
|
||||||
|
registerField,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to access validation context (or null if not inside a ValidationProvider).
|
||||||
|
* Useful for components that optionally participate in form validation.
|
||||||
|
*/
|
||||||
|
export function useOptionalValidation(): ValidationContextValue | null {
|
||||||
|
return (
|
||||||
|
inject<ValidationContextValue>(
|
||||||
|
VALIDATION_KEY,
|
||||||
|
null as unknown as ValidationContextValue,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to access validation context
|
||||||
|
*/
|
||||||
|
export function useValidation(): ValidationContextValue {
|
||||||
|
const ctx = inject<ValidationContextValue>(VALIDATION_KEY);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useValidation must be used within a ValidationProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to get validation state for a field
|
||||||
|
*/
|
||||||
|
export function useFieldValidation(
|
||||||
|
path: string,
|
||||||
|
config?: ValidationConfig,
|
||||||
|
): {
|
||||||
|
state: ComputedRef<FieldValidationState>;
|
||||||
|
validate: () => ValidationResult;
|
||||||
|
touch: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
errors: ComputedRef<string[]>;
|
||||||
|
isValid: ComputedRef<boolean>;
|
||||||
|
} {
|
||||||
|
const ctx = useValidation();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (path && config) {
|
||||||
|
ctx.registerField(path, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
ctx.clear(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultState: FieldValidationState = {
|
||||||
|
touched: false,
|
||||||
|
validated: false,
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: computed(() => ctx.fieldStates[path] ?? defaultState),
|
||||||
|
validate: () => ctx.validate(path, config ?? { checks: [] }),
|
||||||
|
touch: () => ctx.touch(path),
|
||||||
|
clear: () => ctx.clear(path),
|
||||||
|
errors: computed(() => ctx.fieldStates[path]?.result?.errors ?? []),
|
||||||
|
isValid: computed(() => ctx.fieldStates[path]?.result?.valid ?? true),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { defineComponent, h, type Component, type ComputedRef } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { StateProvider, useStateStore } from "./state";
|
||||||
|
import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
|
||||||
|
|
||||||
|
/** Mount StateProvider → VisibilityProvider with a child that captures context. */
|
||||||
|
function withProviders<T>(
|
||||||
|
composable: () => T,
|
||||||
|
initialState: Record<string, unknown> = {},
|
||||||
|
): { result: T } {
|
||||||
|
let result!: T;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = composable();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(VisibilityProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VisibilityProvider — provide/inject", () => {
|
||||||
|
it("useVisibility() throws outside a VisibilityProvider", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(() => useVisibility()).toThrow(
|
||||||
|
"useVisibility must be used within a VisibilityProvider",
|
||||||
|
);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useVisibility() returns a context with isVisible and ctx", () => {
|
||||||
|
const { result } = withProviders(() => useVisibility());
|
||||||
|
expect(typeof result.isVisible).toBe("function");
|
||||||
|
expect(result.ctx).toBeDefined();
|
||||||
|
expect(result.ctx.value).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useIsVisible — state integration", () => {
|
||||||
|
it("undefined condition returns true", () => {
|
||||||
|
const { result } = withProviders(() => useVisibility());
|
||||||
|
expect(result.isVisible(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{ $state: '/flag' } returns true when state flag is truthy", () => {
|
||||||
|
const { result } = withProviders(() => useVisibility(), { flag: true });
|
||||||
|
expect(result.isVisible({ $state: "/flag" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("{ $state: '/flag' } returns false when state flag is falsy", () => {
|
||||||
|
const { result } = withProviders(() => useVisibility(), { flag: false });
|
||||||
|
expect(result.isVisible({ $state: "/flag" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ctx is a ComputedRef whose .value reflects current state", () => {
|
||||||
|
const { result } = withProviders(() => useVisibility(), { count: 3 });
|
||||||
|
expect(result.ctx.value.stateModel).toEqual({ count: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useIsVisible — reactivity", () => {
|
||||||
|
it("returns a ComputedRef<boolean> that updates when state changes", () => {
|
||||||
|
let storeCtx!: ReturnType<typeof useStateStore>;
|
||||||
|
let isVisible!: ComputedRef<boolean>;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
storeCtx = useStateStore();
|
||||||
|
isVisible = useIsVisible({ $state: "/flag" });
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: { flag: false } } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(VisibilityProvider as Component, null, {
|
||||||
|
default: () => h(Child),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible.value).toBe(false);
|
||||||
|
storeCtx.set("/flag", true);
|
||||||
|
expect(isVisible.value).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
inject,
|
||||||
|
provide,
|
||||||
|
type ComputedRef,
|
||||||
|
} from "vue";
|
||||||
|
import {
|
||||||
|
evaluateVisibility,
|
||||||
|
type VisibilityCondition,
|
||||||
|
type VisibilityContext as CoreVisibilityContext,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import { useStateStore } from "./state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visibility context value
|
||||||
|
*/
|
||||||
|
export interface VisibilityContextValue {
|
||||||
|
/** Evaluate a visibility condition */
|
||||||
|
isVisible: (condition: VisibilityCondition | undefined) => boolean;
|
||||||
|
/** The underlying visibility context (reactive) */
|
||||||
|
ctx: ComputedRef<CoreVisibilityContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBILITY_KEY = Symbol("json-render:visibility");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for visibility evaluation
|
||||||
|
*/
|
||||||
|
export const VisibilityProvider = defineComponent({
|
||||||
|
name: "VisibilityProvider",
|
||||||
|
setup(_, { slots }) {
|
||||||
|
const { state } = useStateStore();
|
||||||
|
|
||||||
|
const ctx = computed<CoreVisibilityContext>(() => ({
|
||||||
|
stateModel: state.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isVisible = (condition: VisibilityCondition | undefined): boolean =>
|
||||||
|
evaluateVisibility(condition, ctx.value);
|
||||||
|
|
||||||
|
provide<VisibilityContextValue>(VISIBILITY_KEY, { isVisible, ctx });
|
||||||
|
|
||||||
|
return () => slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to access visibility evaluation
|
||||||
|
*/
|
||||||
|
export function useVisibility(): VisibilityContextValue {
|
||||||
|
const ctx = inject<VisibilityContextValue>(VISIBILITY_KEY);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useVisibility must be used within a VisibilityProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable to check if a condition is visible. Returns a reactive
|
||||||
|
* `ComputedRef<boolean>` so the result updates whenever state changes.
|
||||||
|
*/
|
||||||
|
export function useIsVisible(
|
||||||
|
condition: VisibilityCondition | undefined,
|
||||||
|
): ComputedRef<boolean> {
|
||||||
|
const { ctx } = useVisibility();
|
||||||
|
return computed(() => evaluateVisibility(condition, ctx.value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { defineComponent, h, nextTick, type Component } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import type { Spec } from "@json-render/core";
|
||||||
|
import { StateProvider, useStateStore } from "./composables/state";
|
||||||
|
import { VisibilityProvider } from "./composables/visibility";
|
||||||
|
import { ActionProvider } from "./composables/actions";
|
||||||
|
import { ValidationProvider } from "./composables/validation";
|
||||||
|
import { useFieldValidation } from "./composables/validation";
|
||||||
|
import { useBoundProp } from "./hooks";
|
||||||
|
import {
|
||||||
|
Renderer,
|
||||||
|
JSONUIProvider,
|
||||||
|
type ComponentRegistry,
|
||||||
|
type ComponentRenderProps,
|
||||||
|
} from "./renderer";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Stub components
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const Button = defineComponent({
|
||||||
|
name: "Button",
|
||||||
|
props: {
|
||||||
|
element: { type: Object, required: true },
|
||||||
|
emit: { type: Function, required: true },
|
||||||
|
on: { type: Function, required: true },
|
||||||
|
bindings: { type: Object, default: undefined },
|
||||||
|
loading: { type: Boolean, default: undefined },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{ "data-testid": "btn", onClick: () => props.emit("press") },
|
||||||
|
String((props.element as any).props?.label ?? ""),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Text = defineComponent({
|
||||||
|
name: "Text",
|
||||||
|
props: {
|
||||||
|
element: { type: Object, required: true },
|
||||||
|
emit: { type: Function, required: true },
|
||||||
|
on: { type: Function, required: true },
|
||||||
|
bindings: { type: Object, default: undefined },
|
||||||
|
loading: { type: Boolean, default: undefined },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
const value = (props.element as any).props?.text;
|
||||||
|
return h(
|
||||||
|
"span",
|
||||||
|
{ "data-testid": "text" },
|
||||||
|
value == null
|
||||||
|
? ""
|
||||||
|
: typeof value === "string"
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Stack = defineComponent({
|
||||||
|
name: "Stack",
|
||||||
|
props: {
|
||||||
|
element: { type: Object, required: true },
|
||||||
|
emit: { type: Function, required: true },
|
||||||
|
on: { type: Function, required: true },
|
||||||
|
bindings: { type: Object, default: undefined },
|
||||||
|
loading: { type: Boolean, default: undefined },
|
||||||
|
},
|
||||||
|
setup(_props, { slots }) {
|
||||||
|
return () => h("div", { "data-testid": "stack" }, slots.default?.());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const InputField = defineComponent({
|
||||||
|
name: "InputField",
|
||||||
|
props: {
|
||||||
|
element: { type: Object, required: true },
|
||||||
|
emit: { type: Function, required: true },
|
||||||
|
on: { type: Function, required: true },
|
||||||
|
bindings: { type: Object, default: undefined },
|
||||||
|
loading: { type: Boolean, default: undefined },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const elProps = (props.element as any).props ?? {};
|
||||||
|
const bindingPath = (props.bindings as Record<string, string>)?.value;
|
||||||
|
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||||
|
elProps.value as string | undefined,
|
||||||
|
bindingPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasValidation = !!(bindingPath && elProps.checks?.length);
|
||||||
|
const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined;
|
||||||
|
const { errors } = useFieldValidation(bindingPath ?? "", config);
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h("div", null, [
|
||||||
|
elProps.label ? h("label", null, elProps.label) : null,
|
||||||
|
h("input", {
|
||||||
|
"data-testid": "input",
|
||||||
|
value: boundValue ?? "",
|
||||||
|
onInput: (e: Event) =>
|
||||||
|
setBoundValue((e.target as HTMLInputElement).value),
|
||||||
|
}),
|
||||||
|
errors.value.length > 0
|
||||||
|
? h("span", { "data-testid": "input-error" }, errors.value[0])
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Select = defineComponent({
|
||||||
|
name: "Select",
|
||||||
|
props: {
|
||||||
|
element: { type: Object, required: true },
|
||||||
|
emit: { type: Function, required: true },
|
||||||
|
on: { type: Function, required: true },
|
||||||
|
bindings: { type: Object, default: undefined },
|
||||||
|
loading: { type: Boolean, default: undefined },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const elProps = (props.element as any).props ?? {};
|
||||||
|
const bindingPath = (props.bindings as Record<string, string>)?.value;
|
||||||
|
const [boundValue] = useBoundProp<string>(
|
||||||
|
elProps.value as string | undefined,
|
||||||
|
bindingPath,
|
||||||
|
);
|
||||||
|
return () => h("span", { "data-testid": "select-value" }, boundValue ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry: ComponentRegistry = {
|
||||||
|
Button: Button as unknown as Component,
|
||||||
|
Text: Text as unknown as Component,
|
||||||
|
Input: InputField as unknown as Component,
|
||||||
|
Select: Select as unknown as Component,
|
||||||
|
Stack: Stack as unknown as Component,
|
||||||
|
};
|
||||||
|
|
||||||
|
// State probe to read state from tests
|
||||||
|
const StateProbe = defineComponent({
|
||||||
|
name: "StateProbe",
|
||||||
|
setup() {
|
||||||
|
const { state } = useStateStore();
|
||||||
|
return () =>
|
||||||
|
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function getState(wrapper: ReturnType<typeof mount>): Record<string, unknown> {
|
||||||
|
return JSON.parse(wrapper.find("[data-testid='state-probe']").text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mount helper
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function mountWithProviders(
|
||||||
|
spec: Spec,
|
||||||
|
opts: {
|
||||||
|
functions?: Record<string, (args: Record<string, unknown>) => unknown>;
|
||||||
|
handlers?: Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => Promise<void> | void
|
||||||
|
>;
|
||||||
|
initialState?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
return mount(JSONUIProvider as Component, {
|
||||||
|
props: {
|
||||||
|
registry,
|
||||||
|
initialState: opts.initialState ?? spec.state ?? {},
|
||||||
|
functions: opts.functions,
|
||||||
|
handlers: opts.handlers,
|
||||||
|
} as any,
|
||||||
|
slots: {
|
||||||
|
default: () => [h(Renderer, { spec, registry }), h(StateProbe)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// $computed expressions in rendering
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("$computed expressions in rendering", () => {
|
||||||
|
it("resolves a $computed prop using provided functions", () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
state: { first: "Jane", last: "Doe" },
|
||||||
|
root: "main",
|
||||||
|
elements: {
|
||||||
|
main: {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: {
|
||||||
|
$computed: "fullName",
|
||||||
|
args: {
|
||||||
|
first: { $state: "/first" },
|
||||||
|
last: { $state: "/last" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const functions = {
|
||||||
|
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec, { functions });
|
||||||
|
expect(wrapper.find("[data-testid='text']").text()).toBe("Jane Doe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders gracefully when functions prop is omitted", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const spec: Spec = {
|
||||||
|
state: {},
|
||||||
|
root: "main",
|
||||||
|
elements: {
|
||||||
|
main: {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: { $computed: "missing" },
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec);
|
||||||
|
expect(wrapper.find("[data-testid='text']").text()).toBe("");
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Watchers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("watchers (watch field)", () => {
|
||||||
|
it("does not fire on initial render, fires when watched state changes", async () => {
|
||||||
|
const loadCities = vi.fn();
|
||||||
|
|
||||||
|
const spec: Spec = {
|
||||||
|
state: { form: { country: "" }, citiesLoaded: false },
|
||||||
|
root: "wrapper",
|
||||||
|
elements: {
|
||||||
|
wrapper: {
|
||||||
|
type: "Stack",
|
||||||
|
props: {},
|
||||||
|
children: ["btn", "watcher"],
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
type: "Button",
|
||||||
|
props: { label: "Set Country" },
|
||||||
|
on: {
|
||||||
|
press: [
|
||||||
|
{
|
||||||
|
action: "setState",
|
||||||
|
params: { statePath: "/form/country", value: "US" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
watcher: {
|
||||||
|
type: "Select",
|
||||||
|
props: { value: { $state: "/form/country" } },
|
||||||
|
watch: {
|
||||||
|
"/form/country": {
|
||||||
|
action: "loadCities",
|
||||||
|
params: { country: { $state: "/form/country" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec, { handlers: { loadCities } });
|
||||||
|
|
||||||
|
expect(loadCities).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(loadCities).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadCities).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ country: "US" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires multiple action bindings on the same watch path", async () => {
|
||||||
|
const action1 = vi.fn();
|
||||||
|
const action2 = vi.fn();
|
||||||
|
|
||||||
|
const spec: Spec = {
|
||||||
|
state: { value: "a" },
|
||||||
|
root: "wrapper",
|
||||||
|
elements: {
|
||||||
|
wrapper: {
|
||||||
|
type: "Stack",
|
||||||
|
props: {},
|
||||||
|
children: ["btn", "watcher"],
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
type: "Button",
|
||||||
|
props: { label: "Change" },
|
||||||
|
on: {
|
||||||
|
press: [
|
||||||
|
{
|
||||||
|
action: "setState",
|
||||||
|
params: { statePath: "/value", value: "b" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
watcher: {
|
||||||
|
type: "Text",
|
||||||
|
props: { text: { $state: "/value" } },
|
||||||
|
watch: {
|
||||||
|
"/value": [{ action: "action1" }, { action: "action2" }],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec, {
|
||||||
|
handlers: { action1, action2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(action1).toHaveBeenCalledTimes(1);
|
||||||
|
expect(action2).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// validateForm action
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("validateForm action", () => {
|
||||||
|
it("writes { valid: false, errors } when a required field is empty", async () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
state: { form: { email: "" }, result: null },
|
||||||
|
root: "wrapper",
|
||||||
|
elements: {
|
||||||
|
wrapper: {
|
||||||
|
type: "Stack",
|
||||||
|
props: {},
|
||||||
|
children: ["emailInput", "submitBtn"],
|
||||||
|
},
|
||||||
|
emailInput: {
|
||||||
|
type: "Input",
|
||||||
|
props: {
|
||||||
|
label: "Email",
|
||||||
|
value: { $bindState: "/form/email" },
|
||||||
|
checks: [{ type: "required", message: "Email is required" }],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
type: "Button",
|
||||||
|
props: { label: "Submit" },
|
||||||
|
on: {
|
||||||
|
press: [
|
||||||
|
{
|
||||||
|
action: "validateForm",
|
||||||
|
params: { statePath: "/result" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec);
|
||||||
|
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const state = getState(wrapper);
|
||||||
|
expect(state.result).toEqual({
|
||||||
|
valid: false,
|
||||||
|
errors: { "/form/email": ["Email is required"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes { valid: true, errors: {} } when all fields pass validation", async () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
state: { form: { email: "test@example.com" }, result: null },
|
||||||
|
root: "wrapper",
|
||||||
|
elements: {
|
||||||
|
wrapper: {
|
||||||
|
type: "Stack",
|
||||||
|
props: {},
|
||||||
|
children: ["emailInput", "submitBtn"],
|
||||||
|
},
|
||||||
|
emailInput: {
|
||||||
|
type: "Input",
|
||||||
|
props: {
|
||||||
|
label: "Email",
|
||||||
|
value: { $bindState: "/form/email" },
|
||||||
|
checks: [{ type: "required", message: "Email is required" }],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
type: "Button",
|
||||||
|
props: { label: "Submit" },
|
||||||
|
on: {
|
||||||
|
press: [
|
||||||
|
{
|
||||||
|
action: "validateForm",
|
||||||
|
params: { statePath: "/result" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec);
|
||||||
|
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const state = getState(wrapper);
|
||||||
|
expect(state.result).toEqual({ valid: true, errors: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to /formValidation when no statePath is provided", async () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
state: { form: { name: "filled" } },
|
||||||
|
root: "wrapper",
|
||||||
|
elements: {
|
||||||
|
wrapper: {
|
||||||
|
type: "Stack",
|
||||||
|
props: {},
|
||||||
|
children: ["nameInput", "submitBtn"],
|
||||||
|
},
|
||||||
|
nameInput: {
|
||||||
|
type: "Input",
|
||||||
|
props: {
|
||||||
|
label: "Name",
|
||||||
|
value: { $bindState: "/form/name" },
|
||||||
|
checks: [{ type: "required", message: "Required" }],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
type: "Button",
|
||||||
|
props: { label: "Submit" },
|
||||||
|
on: {
|
||||||
|
press: [{ action: "validateForm" }],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mountWithProviders(spec);
|
||||||
|
await wrapper.find("[data-testid='btn']").trigger("click");
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const state = getState(wrapper);
|
||||||
|
expect(state.formValidation).toEqual({ valid: true, errors: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { defineComponent, h, ref, type Component } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { SPEC_DATA_PART_TYPE } from "@json-render/core";
|
||||||
|
import { StateProvider, useStateStore } from "./composables/state";
|
||||||
|
import {
|
||||||
|
flatToTree,
|
||||||
|
buildSpecFromParts,
|
||||||
|
getTextFromParts,
|
||||||
|
useBoundProp,
|
||||||
|
useUIStream,
|
||||||
|
useJsonRenderMessage,
|
||||||
|
type DataPart,
|
||||||
|
} from "./hooks";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Mount inside a StateProvider and capture a composable's result. */
|
||||||
|
function withStateProvider<T>(
|
||||||
|
composable: () => T,
|
||||||
|
initialState: Record<string, unknown> = {},
|
||||||
|
): { result: T } {
|
||||||
|
let result!: T;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = composable();
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState } as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
return { result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a simple ReadableStream from a string (for fetch mocks). */
|
||||||
|
function makeReadableStream(text: string): ReadableStream<Uint8Array> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(text));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// flatToTree
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("flatToTree", () => {
|
||||||
|
it("converts flat elements into a Spec with root and children", () => {
|
||||||
|
const elements = [
|
||||||
|
{ key: "root", type: "Stack", props: {}, parentKey: undefined },
|
||||||
|
{
|
||||||
|
key: "child1",
|
||||||
|
type: "Text",
|
||||||
|
props: { content: "hello" },
|
||||||
|
parentKey: "root",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const spec = flatToTree(elements as any);
|
||||||
|
expect(spec.root).toBe("root");
|
||||||
|
expect(spec.elements["root"]?.children).toContain("child1");
|
||||||
|
expect(spec.elements["child1"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a single root element with no children", () => {
|
||||||
|
const elements = [
|
||||||
|
{ key: "root", type: "Text", props: {}, parentKey: undefined },
|
||||||
|
];
|
||||||
|
const spec = flatToTree(elements as any);
|
||||||
|
expect(spec.root).toBe("root");
|
||||||
|
expect(spec.elements["root"]?.children).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildSpecFromParts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("buildSpecFromParts", () => {
|
||||||
|
it("returns null when no spec parts present", () => {
|
||||||
|
const parts: DataPart[] = [{ type: "text", text: "hello" }];
|
||||||
|
expect(buildSpecFromParts(parts)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty array", () => {
|
||||||
|
expect(buildSpecFromParts([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a Spec when a snapshot spec data part is present", () => {
|
||||||
|
const part: DataPart = {
|
||||||
|
type: SPEC_DATA_PART_TYPE,
|
||||||
|
data: {
|
||||||
|
type: "flat",
|
||||||
|
spec: {
|
||||||
|
root: "r",
|
||||||
|
elements: { r: { type: "Text", props: { content: "hi" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = buildSpecFromParts([part]);
|
||||||
|
expect(result?.root).toBe("r");
|
||||||
|
expect(result?.elements["r"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies patch operations incrementally", () => {
|
||||||
|
const parts: DataPart[] = [
|
||||||
|
{
|
||||||
|
type: SPEC_DATA_PART_TYPE,
|
||||||
|
data: {
|
||||||
|
type: "patch",
|
||||||
|
patch: { op: "add", path: "/root", value: "myRoot" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = buildSpecFromParts(parts);
|
||||||
|
expect(result?.root).toBe("myRoot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips malformed data parts silently", () => {
|
||||||
|
const parts: DataPart[] = [
|
||||||
|
{ type: SPEC_DATA_PART_TYPE, data: { type: "unknown" } },
|
||||||
|
];
|
||||||
|
expect(buildSpecFromParts(parts)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getTextFromParts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("getTextFromParts", () => {
|
||||||
|
it("concatenates text parts with double newlines", () => {
|
||||||
|
const parts: DataPart[] = [
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
];
|
||||||
|
expect(getTextFromParts(parts)).toBe("hello\n\nworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-text parts", () => {
|
||||||
|
const parts: DataPart[] = [
|
||||||
|
{ type: "other", data: {} },
|
||||||
|
{ type: "text", text: "hi" },
|
||||||
|
];
|
||||||
|
expect(getTextFromParts(parts)).toBe("hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty array", () => {
|
||||||
|
expect(getTextFromParts([])).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out empty/whitespace text parts", () => {
|
||||||
|
const parts: DataPart[] = [
|
||||||
|
{ type: "text", text: " " },
|
||||||
|
{ type: "text", text: "real" },
|
||||||
|
];
|
||||||
|
expect(getTextFromParts(parts)).toBe("real");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useBoundProp
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("useBoundProp", () => {
|
||||||
|
it("returns the prop value and a no-op setter when no binding path", () => {
|
||||||
|
const { result } = withStateProvider(() =>
|
||||||
|
useBoundProp("hello", undefined),
|
||||||
|
);
|
||||||
|
const [value, setValue] = result;
|
||||||
|
expect(value).toBe("hello");
|
||||||
|
expect(() => setValue("new")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined prop value when propValue is undefined", () => {
|
||||||
|
const { result } = withStateProvider(() =>
|
||||||
|
useBoundProp<string>(undefined, undefined),
|
||||||
|
);
|
||||||
|
expect(result[0]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setter writes to state at the binding path", () => {
|
||||||
|
let storeCtx!: ReturnType<typeof useStateStore>;
|
||||||
|
let setter!: (v: string) => void;
|
||||||
|
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
storeCtx = useStateStore();
|
||||||
|
const [, s] = useBoundProp<string>("Alice", "/name");
|
||||||
|
setter = s;
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: { name: "Alice" } } as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
|
||||||
|
setter("Bob");
|
||||||
|
expect(storeCtx.get("/name")).toBe("Bob");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useUIStream
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("useUIStream", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("send() sets isStreaming true then false after completion", async () => {
|
||||||
|
const patchLine =
|
||||||
|
JSON.stringify({ op: "add", path: "/root", value: "myRoot" }) +
|
||||||
|
"\n" +
|
||||||
|
JSON.stringify({
|
||||||
|
op: "add",
|
||||||
|
path: "/elements/myRoot",
|
||||||
|
value: { type: "Text", props: {} },
|
||||||
|
}) +
|
||||||
|
"\n";
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: makeReadableStream(patchLine),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let streamResult!: ReturnType<typeof useUIStream>;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
streamResult = useUIStream({ api: "/api/ui" });
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: {} } as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamResult.isStreaming.value).toBe(false);
|
||||||
|
const promise = streamResult.send("build me a UI");
|
||||||
|
expect(streamResult.isStreaming.value).toBe(true);
|
||||||
|
await promise;
|
||||||
|
expect(streamResult.isStreaming.value).toBe(false);
|
||||||
|
expect(streamResult.spec.value?.root).toBe("myRoot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error is set when fetch fails", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockRejectedValue(new Error("Network error")),
|
||||||
|
);
|
||||||
|
|
||||||
|
let streamResult!: ReturnType<typeof useUIStream>;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
streamResult = useUIStream({ api: "/api/ui" });
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: {} } as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await streamResult.send("fail");
|
||||||
|
expect(streamResult.error.value?.message).toBe("Network error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear() resets spec and error", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("oops")));
|
||||||
|
|
||||||
|
let streamResult!: ReturnType<typeof useUIStream>;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
streamResult = useUIStream({ api: "/api/ui" });
|
||||||
|
return () => h("div");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(StateProvider as Component, {
|
||||||
|
props: { initialState: {} } as any,
|
||||||
|
slots: { default: () => h(Child) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await streamResult.send("fail");
|
||||||
|
expect(streamResult.error.value).not.toBeNull();
|
||||||
|
streamResult.clear();
|
||||||
|
expect(streamResult.error.value).toBeNull();
|
||||||
|
expect(streamResult.spec.value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useJsonRenderMessage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("useJsonRenderMessage", () => {
|
||||||
|
it("returns null spec and false hasSpec when no spec parts", () => {
|
||||||
|
const { spec, hasSpec } = useJsonRenderMessage([
|
||||||
|
{ type: "text", text: "hi" },
|
||||||
|
]);
|
||||||
|
expect(spec.value).toBeNull();
|
||||||
|
expect(hasSpec.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts text from text parts", () => {
|
||||||
|
const { text } = useJsonRenderMessage([
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
]);
|
||||||
|
expect(text.value).toBe("hello\n\nworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is reactive when passed a Ref<DataPart[]>", () => {
|
||||||
|
const parts = ref<DataPart[]>([]);
|
||||||
|
const { text, spec, hasSpec } = useJsonRenderMessage(parts);
|
||||||
|
expect(text.value).toBe("");
|
||||||
|
expect(spec.value).toBeNull();
|
||||||
|
|
||||||
|
parts.value = [{ type: "text", text: "hello" }];
|
||||||
|
expect(text.value).toBe("hello");
|
||||||
|
|
||||||
|
parts.value = [
|
||||||
|
...parts.value,
|
||||||
|
{
|
||||||
|
type: SPEC_DATA_PART_TYPE,
|
||||||
|
data: {
|
||||||
|
type: "flat",
|
||||||
|
spec: { root: "r", elements: { r: { type: "Text", props: {} } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(spec.value?.root).toBe("r");
|
||||||
|
expect(hasSpec.value).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
import {
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
computed,
|
||||||
|
onUnmounted,
|
||||||
|
isRef,
|
||||||
|
type Ref,
|
||||||
|
type ComputedRef,
|
||||||
|
} from "vue";
|
||||||
|
import { useStateStore } from "./composables/state";
|
||||||
|
import type {
|
||||||
|
Spec,
|
||||||
|
UIElement,
|
||||||
|
FlatElement,
|
||||||
|
JsonPatch,
|
||||||
|
SpecDataPart,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import {
|
||||||
|
setByPath,
|
||||||
|
getByPath,
|
||||||
|
addByPath,
|
||||||
|
removeByPath,
|
||||||
|
createMixedStreamParser,
|
||||||
|
applySpecPatch,
|
||||||
|
nestedToFlat,
|
||||||
|
SPEC_DATA_PART_TYPE,
|
||||||
|
} from "@json-render/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token usage metadata from AI generation
|
||||||
|
*/
|
||||||
|
export interface TokenUsage {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse result for a single line -- either a patch or usage metadata
|
||||||
|
*/
|
||||||
|
type ParsedLine =
|
||||||
|
| { type: "patch"; patch: JsonPatch }
|
||||||
|
| { type: "usage"; usage: TokenUsage }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single JSON line (patch or metadata)
|
||||||
|
*/
|
||||||
|
function parseLine(line: string): ParsedLine {
|
||||||
|
try {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("//")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
|
||||||
|
// Check for usage metadata
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
return {
|
||||||
|
type: "usage",
|
||||||
|
usage: {
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "patch", patch: parsed as JsonPatch };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value at a spec path (for add/replace operations).
|
||||||
|
*/
|
||||||
|
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
|
||||||
|
if (path === "/root") {
|
||||||
|
newSpec.root = value as string;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/state") {
|
||||||
|
newSpec.state = value as Record<string, unknown>;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/state/")) {
|
||||||
|
if (!newSpec.state) newSpec.state = {};
|
||||||
|
const statePath = path.slice("/state".length); // e.g. "/posts"
|
||||||
|
setByPath(newSpec.state as Record<string, unknown>, statePath, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
newSpec.elements[elementKey] = value as UIElement;
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const propPath = "/" + pathParts.slice(1).join("/");
|
||||||
|
const newElement = { ...element };
|
||||||
|
setByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
propPath,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a value at a spec path.
|
||||||
|
*/
|
||||||
|
function removeSpecValue(newSpec: Spec, path: string): void {
|
||||||
|
if (path === "/state") {
|
||||||
|
delete newSpec.state;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/state/") && newSpec.state) {
|
||||||
|
const statePath = path.slice("/state".length);
|
||||||
|
removeByPath(newSpec.state as Record<string, unknown>, statePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
const { [elementKey]: _, ...rest } = newSpec.elements;
|
||||||
|
newSpec.elements = rest;
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const propPath = "/" + pathParts.slice(1).join("/");
|
||||||
|
const newElement = { ...element };
|
||||||
|
removeByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
propPath,
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value at a spec path.
|
||||||
|
*/
|
||||||
|
function getSpecValue(spec: Spec, path: string): unknown {
|
||||||
|
if (path === "/root") return spec.root;
|
||||||
|
if (path === "/state") return spec.state;
|
||||||
|
if (path.startsWith("/state/") && spec.state) {
|
||||||
|
const statePath = path.slice("/state".length);
|
||||||
|
return getByPath(spec.state as Record<string, unknown>, statePath);
|
||||||
|
}
|
||||||
|
return getByPath(spec as unknown as Record<string, unknown>, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an RFC 6902 JSON patch to the current spec.
|
||||||
|
* Supports add, remove, replace, move, copy, and test operations.
|
||||||
|
*/
|
||||||
|
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||||
|
const newSpec = {
|
||||||
|
...spec,
|
||||||
|
elements: { ...spec.elements },
|
||||||
|
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (patch.op) {
|
||||||
|
case "add":
|
||||||
|
case "replace": {
|
||||||
|
setSpecValue(newSpec, patch.path, patch.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
removeSpecValue(newSpec, patch.path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "move": {
|
||||||
|
if (!patch.from) break;
|
||||||
|
const moveValue = getSpecValue(newSpec, patch.from);
|
||||||
|
removeSpecValue(newSpec, patch.from);
|
||||||
|
setSpecValue(newSpec, patch.path, moveValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "copy": {
|
||||||
|
if (!patch.from) break;
|
||||||
|
const copyValue = getSpecValue(newSpec, patch.from);
|
||||||
|
setSpecValue(newSpec, patch.path, copyValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "test": {
|
||||||
|
// test is a no-op for rendering purposes (validation only)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSpec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for useUIStream
|
||||||
|
*/
|
||||||
|
export interface UseUIStreamOptions {
|
||||||
|
/** API endpoint */
|
||||||
|
api: string;
|
||||||
|
/** Callback when complete */
|
||||||
|
onComplete?: (spec: Spec) => void;
|
||||||
|
/** Callback on error */
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for useUIStream
|
||||||
|
*/
|
||||||
|
export interface UseUIStreamReturn {
|
||||||
|
/** Current UI spec */
|
||||||
|
spec: Ref<Spec | null>;
|
||||||
|
/** Whether currently streaming */
|
||||||
|
isStreaming: Ref<boolean>;
|
||||||
|
/** Error if any */
|
||||||
|
error: Ref<Error | null>;
|
||||||
|
/** Token usage from the last generation */
|
||||||
|
usage: Ref<TokenUsage | null>;
|
||||||
|
/** Raw JSONL lines received from the stream (JSON patch lines) */
|
||||||
|
rawLines: Ref<string[]>;
|
||||||
|
/** Send a prompt to generate UI */
|
||||||
|
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
|
||||||
|
/** Clear the current spec */
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for streaming UI generation
|
||||||
|
*/
|
||||||
|
export function useUIStream({
|
||||||
|
api,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: UseUIStreamOptions): UseUIStreamReturn {
|
||||||
|
const spec = shallowRef<Spec | null>(null);
|
||||||
|
const isStreaming = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
const usage = ref<TokenUsage | null>(null);
|
||||||
|
const rawLines = ref<string[]>([]);
|
||||||
|
|
||||||
|
const onCompleteRef = ref(onComplete);
|
||||||
|
const onErrorRef = ref(onError);
|
||||||
|
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
spec.value = null;
|
||||||
|
error.value = null;
|
||||||
|
usage.value = null;
|
||||||
|
rawLines.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (
|
||||||
|
prompt: string,
|
||||||
|
context?: Record<string, unknown>,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Abort any existing request
|
||||||
|
abortController?.abort();
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
isStreaming.value = true;
|
||||||
|
error.value = null;
|
||||||
|
usage.value = null;
|
||||||
|
rawLines.value = [];
|
||||||
|
|
||||||
|
// Start with previous spec if provided, otherwise empty spec
|
||||||
|
const previousSpec = context?.previousSpec as Spec | undefined;
|
||||||
|
let currentSpec: Spec =
|
||||||
|
previousSpec && previousSpec.root
|
||||||
|
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
||||||
|
: { root: "", elements: {} };
|
||||||
|
spec.value = currentSpec;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(api, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
currentSpec,
|
||||||
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Try to parse JSON error response for better error messages
|
||||||
|
let errorMessage = `HTTP error: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parsing errors, use default message
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete lines
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const result = parseLine(trimmed);
|
||||||
|
if (!result) continue;
|
||||||
|
if (result.type === "usage") {
|
||||||
|
usage.value = result.usage;
|
||||||
|
} else {
|
||||||
|
rawLines.value = [...rawLines.value, trimmed];
|
||||||
|
currentSpec = applyPatch(currentSpec, result.patch);
|
||||||
|
spec.value = { ...currentSpec };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining buffer
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const trimmed = buffer.trim();
|
||||||
|
const result = parseLine(trimmed);
|
||||||
|
if (result) {
|
||||||
|
if (result.type === "usage") {
|
||||||
|
usage.value = result.usage;
|
||||||
|
} else {
|
||||||
|
rawLines.value = [...rawLines.value, trimmed];
|
||||||
|
currentSpec = applyPatch(currentSpec, result.patch);
|
||||||
|
spec.value = { ...currentSpec };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.value?.(currentSpec);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
error.value = resolvedError;
|
||||||
|
onErrorRef.value?.(resolvedError);
|
||||||
|
} finally {
|
||||||
|
isStreaming.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
abortController?.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
spec,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
usage,
|
||||||
|
rawLines,
|
||||||
|
send,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a flat element list to a Spec.
|
||||||
|
* Input elements use key/parentKey to establish identity and relationships.
|
||||||
|
* Output spec uses the map-based format where key is the map entry key
|
||||||
|
* and parent-child relationships are expressed through children arrays.
|
||||||
|
*/
|
||||||
|
export function flatToTree(elements: FlatElement[]): Spec {
|
||||||
|
const elementMap: Record<string, UIElement> = {};
|
||||||
|
let root = "";
|
||||||
|
|
||||||
|
// First pass: add all elements to map
|
||||||
|
for (const element of elements) {
|
||||||
|
elementMap[element.key] = {
|
||||||
|
type: element.type,
|
||||||
|
props: element.props,
|
||||||
|
children: [],
|
||||||
|
visible: element.visible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: build parent-child relationships
|
||||||
|
for (const element of elements) {
|
||||||
|
if (element.parentKey) {
|
||||||
|
const parent = elementMap[element.parentKey];
|
||||||
|
if (parent) {
|
||||||
|
if (!parent.children) {
|
||||||
|
parent.children = [];
|
||||||
|
}
|
||||||
|
parent.children.push(element.key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root = element.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { root, elements: elementMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for two-way bound props. Returns `[value, setValue]` where:
|
||||||
|
*
|
||||||
|
* - `value` is the already-resolved prop value (passed through from render props)
|
||||||
|
* - `setValue` writes back to the bound state path (no-op if not bound)
|
||||||
|
*
|
||||||
|
* Designed to work with the `bindings` map that the renderer provides when
|
||||||
|
* a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { useBoundProp } from "@json-render/vue";
|
||||||
|
*
|
||||||
|
* const Input: ComponentFn<AppCatalog, "Input"> = ({ props, bindings }) => {
|
||||||
|
* const [value, setValue] = useBoundProp<string>(props.value as string, bindings?.value);
|
||||||
|
* return h("input", { value: value ?? "", onInput: (e) => setValue(e.target.value) });
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useBoundProp<T>(
|
||||||
|
propValue: T | undefined,
|
||||||
|
bindingPath: string | undefined,
|
||||||
|
): [T | undefined, (value: T) => void] {
|
||||||
|
const { set } = useStateStore();
|
||||||
|
return [
|
||||||
|
propValue,
|
||||||
|
(value: T) => {
|
||||||
|
if (bindingPath) set(bindingPath, value);
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// buildSpecFromParts — Derive Spec from AI SDK data parts
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single part from the AI SDK's `message.parts` array. This is a minimal
|
||||||
|
* structural type so that library helpers do not depend on the AI SDK.
|
||||||
|
* Fields are optional because different part types carry different data:
|
||||||
|
* - Text parts have `text`
|
||||||
|
* - Data parts have `data`
|
||||||
|
*/
|
||||||
|
export interface DataPart {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard that validates a data part payload looks like a valid
|
||||||
|
* SpecDataPart before we cast it.
|
||||||
|
*/
|
||||||
|
function isSpecDataPart(data: unknown): data is SpecDataPart {
|
||||||
|
if (typeof data !== "object" || data === null) return false;
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
switch (obj.type) {
|
||||||
|
case "patch":
|
||||||
|
return typeof obj.patch === "object" && obj.patch !== null;
|
||||||
|
case "flat":
|
||||||
|
case "nested":
|
||||||
|
return typeof obj.spec === "object" && obj.spec !== null;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `Spec` by replaying all spec data parts from a message's
|
||||||
|
* parts array. Returns `null` if no spec data parts are present.
|
||||||
|
*
|
||||||
|
* Works with the AI SDK's `UIMessage.parts` array. Picks out parts whose
|
||||||
|
* `type` is `SPEC_DATA_PART_TYPE` and processes them based on the payload's
|
||||||
|
* `type` discriminator: `"patch"`, `"flat"`, or `"nested"`.
|
||||||
|
*/
|
||||||
|
export function buildSpecFromParts(parts: DataPart[]): Spec | null {
|
||||||
|
const spec: Spec = { root: "", elements: {} };
|
||||||
|
let hasSpec = false;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type === SPEC_DATA_PART_TYPE) {
|
||||||
|
if (!isSpecDataPart(part.data)) continue;
|
||||||
|
const payload = part.data;
|
||||||
|
if (payload.type === "patch") {
|
||||||
|
hasSpec = true;
|
||||||
|
applySpecPatch(spec, payload.patch);
|
||||||
|
} else if (payload.type === "flat") {
|
||||||
|
hasSpec = true;
|
||||||
|
Object.assign(spec, payload.spec);
|
||||||
|
} else if (payload.type === "nested") {
|
||||||
|
hasSpec = true;
|
||||||
|
const flat = nestedToFlat(payload.spec);
|
||||||
|
Object.assign(spec, flat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasSpec ? spec : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and join all text content from a message's parts array.
|
||||||
|
*
|
||||||
|
* Filters for parts with `type === "text"`, trims each one, and joins them
|
||||||
|
* with double newlines so that text from separate agent steps renders as
|
||||||
|
* distinct paragraphs in markdown.
|
||||||
|
*/
|
||||||
|
export function getTextFromParts(parts: DataPart[]): string {
|
||||||
|
return parts
|
||||||
|
.filter(
|
||||||
|
(p): p is DataPart & { text: string } =>
|
||||||
|
p.type === "text" && typeof p.text === "string",
|
||||||
|
)
|
||||||
|
.map((p) => p.text.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useJsonRenderMessage — extract spec + text from message parts
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that extracts both the json-render spec and text content from a
|
||||||
|
* message's parts array. Accepts a plain `DataPart[]` or a `Ref<DataPart[]>`
|
||||||
|
* for reactive use in streaming scenarios.
|
||||||
|
*
|
||||||
|
* Returns `ComputedRef`s that recompute whenever `parts` changes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { useJsonRenderMessage } from "@json-render/vue";
|
||||||
|
*
|
||||||
|
* const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useJsonRenderMessage(parts: DataPart[] | Ref<DataPart[]>): {
|
||||||
|
spec: ComputedRef<Spec | null>;
|
||||||
|
text: ComputedRef<string>;
|
||||||
|
hasSpec: ComputedRef<boolean>;
|
||||||
|
} {
|
||||||
|
const partsRef = isRef(parts) ? parts : ref(parts);
|
||||||
|
const spec = computed(() => buildSpecFromParts(partsRef.value));
|
||||||
|
const text = computed(() => getTextFromParts(partsRef.value));
|
||||||
|
const hasSpec = computed(
|
||||||
|
() =>
|
||||||
|
spec.value !== null && Object.keys(spec.value.elements || {}).length > 0,
|
||||||
|
);
|
||||||
|
return { spec, text, hasSpec };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useChatUI — Chat + GenUI composable
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single message in the chat, which may contain text, a rendered UI spec, or both.
|
||||||
|
*/
|
||||||
|
export interface ChatMessage {
|
||||||
|
/** Unique message ID */
|
||||||
|
id: string;
|
||||||
|
/** Who sent this message */
|
||||||
|
role: "user" | "assistant";
|
||||||
|
/** Text content (conversational prose) */
|
||||||
|
text: string;
|
||||||
|
/** json-render Spec built from JSONL patches (null if no UI was generated) */
|
||||||
|
spec: Spec | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for useChatUI
|
||||||
|
*/
|
||||||
|
export interface UseChatUIOptions {
|
||||||
|
/** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */
|
||||||
|
api: string;
|
||||||
|
/** Callback when streaming completes for a message */
|
||||||
|
onComplete?: (message: ChatMessage) => void;
|
||||||
|
/** Callback on error */
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for useChatUI
|
||||||
|
*/
|
||||||
|
export interface UseChatUIReturn {
|
||||||
|
/** All messages in the conversation */
|
||||||
|
messages: Ref<ChatMessage[]>;
|
||||||
|
/** Whether currently streaming an assistant response */
|
||||||
|
isStreaming: Ref<boolean>;
|
||||||
|
/** Error from the last request, if any */
|
||||||
|
error: Ref<Error | null>;
|
||||||
|
/** Send a user message */
|
||||||
|
send: (text: string) => Promise<void>;
|
||||||
|
/** Clear all messages and reset the conversation */
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chatMessageIdCounter = 0;
|
||||||
|
function generateChatId(): string {
|
||||||
|
if (
|
||||||
|
typeof crypto !== "undefined" &&
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
chatMessageIdCounter += 1;
|
||||||
|
return `msg-${Date.now()}-${chatMessageIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for chat + GenUI experiences.
|
||||||
|
*
|
||||||
|
* Manages a multi-turn conversation where each assistant message can contain
|
||||||
|
* both conversational text and a json-render UI spec. Sends the full message
|
||||||
|
* history to the API endpoint, reads the streamed response, and separates
|
||||||
|
* text lines from JSONL patch lines using `createMixedStreamParser`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { messages, isStreaming, send, clear } = useChatUI({ api: "/api/chat" });
|
||||||
|
*
|
||||||
|
* await send("Compare weather in NYC and Tokyo");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useChatUI({
|
||||||
|
api,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: UseChatUIOptions): UseChatUIReturn {
|
||||||
|
const messages = ref<ChatMessage[]>([]);
|
||||||
|
const isStreaming = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
|
||||||
|
const onCompleteRef = ref(onComplete);
|
||||||
|
const onErrorRef = ref(onError);
|
||||||
|
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
messages.value = [];
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (text: string): Promise<void> => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
// Abort any existing request
|
||||||
|
abortController?.abort();
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
const userMessage: ChatMessage = {
|
||||||
|
id: generateChatId(),
|
||||||
|
role: "user",
|
||||||
|
text: text.trim(),
|
||||||
|
spec: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assistantId = generateChatId();
|
||||||
|
const assistantMessage: ChatMessage = {
|
||||||
|
id: assistantId,
|
||||||
|
role: "assistant",
|
||||||
|
text: "",
|
||||||
|
spec: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append user message and empty assistant placeholder
|
||||||
|
messages.value = [...messages.value, userMessage, assistantMessage];
|
||||||
|
isStreaming.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
// Build messages array for the API (full conversation history + new message).
|
||||||
|
// Vue refs are always current — no stale closure issue unlike React useRef.
|
||||||
|
const historyForApi = [
|
||||||
|
...messages.value
|
||||||
|
.filter((m) => m.id !== assistantId)
|
||||||
|
.map((m) => ({ role: m.role, content: m.text })),
|
||||||
|
{ role: "user" as const, content: text.trim() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mutable state for accumulating the assistant response
|
||||||
|
let accumulatedText = "";
|
||||||
|
let currentSpec: Spec = { root: "", elements: {} };
|
||||||
|
let hasSpec = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(api, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: historyForApi }),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP error: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) {
|
||||||
|
errorMessage = errorData.message;
|
||||||
|
} else if (errorData.error) {
|
||||||
|
errorMessage = errorData.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parsing errors
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("No response body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
// Use createMixedStreamParser to classify lines
|
||||||
|
const parser = createMixedStreamParser({
|
||||||
|
onPatch(patch) {
|
||||||
|
hasSpec = true;
|
||||||
|
applySpecPatch(currentSpec, patch);
|
||||||
|
messages.value = messages.value.map((m) =>
|
||||||
|
m.id === assistantId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
spec: {
|
||||||
|
root: currentSpec.root,
|
||||||
|
elements: { ...currentSpec.elements },
|
||||||
|
...(currentSpec.state
|
||||||
|
? { state: { ...currentSpec.state } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onText(line) {
|
||||||
|
accumulatedText += (accumulatedText ? "\n" : "") + line;
|
||||||
|
messages.value = messages.value.map((m) =>
|
||||||
|
m.id === assistantId ? { ...m, text: accumulatedText } : m,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
parser.push(decoder.decode(value, { stream: true }));
|
||||||
|
}
|
||||||
|
parser.flush();
|
||||||
|
|
||||||
|
// Build final message for onComplete callback
|
||||||
|
const finalMessage: ChatMessage = {
|
||||||
|
id: assistantId,
|
||||||
|
role: "assistant",
|
||||||
|
text: accumulatedText,
|
||||||
|
spec: hasSpec
|
||||||
|
? {
|
||||||
|
root: currentSpec.root,
|
||||||
|
elements: { ...currentSpec.elements },
|
||||||
|
...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
onCompleteRef.value?.(finalMessage);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
error.value = resolvedError;
|
||||||
|
// Remove empty assistant message on error
|
||||||
|
messages.value = messages.value.filter(
|
||||||
|
(m) => m.id !== assistantId || m.text.length > 0,
|
||||||
|
);
|
||||||
|
onErrorRef.value?.(resolvedError);
|
||||||
|
} finally {
|
||||||
|
isStreaming.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
onUnmounted(() => {
|
||||||
|
abortController?.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
error,
|
||||||
|
send,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Composables & Providers
|
||||||
|
export {
|
||||||
|
StateProvider,
|
||||||
|
useStateStore,
|
||||||
|
useStateValue,
|
||||||
|
useStateBinding,
|
||||||
|
type StateContextValue,
|
||||||
|
type StateProviderProps,
|
||||||
|
} from "./composables/state";
|
||||||
|
|
||||||
|
export {
|
||||||
|
VisibilityProvider,
|
||||||
|
useVisibility,
|
||||||
|
useIsVisible,
|
||||||
|
type VisibilityContextValue,
|
||||||
|
} from "./composables/visibility";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ActionProvider,
|
||||||
|
useActions,
|
||||||
|
useAction,
|
||||||
|
ConfirmDialog,
|
||||||
|
type ActionContextValue,
|
||||||
|
type ActionProviderProps,
|
||||||
|
type PendingConfirmation,
|
||||||
|
type ConfirmDialogProps,
|
||||||
|
} from "./composables/actions";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ValidationProvider,
|
||||||
|
useOptionalValidation,
|
||||||
|
useValidation,
|
||||||
|
useFieldValidation,
|
||||||
|
type ValidationContextValue,
|
||||||
|
type ValidationProviderProps,
|
||||||
|
type FieldValidationState,
|
||||||
|
} from "./composables/validation";
|
||||||
|
|
||||||
|
export {
|
||||||
|
RepeatScopeProvider,
|
||||||
|
useRepeatScope,
|
||||||
|
type RepeatScopeValue,
|
||||||
|
} from "./composables/repeat-scope";
|
||||||
|
|
||||||
|
// Schema
|
||||||
|
export { schema, type VueSchema, type VueSpec } from "./schema";
|
||||||
|
|
||||||
|
// Core types (re-exported for convenience)
|
||||||
|
export type { Spec, StateStore, ComputedFunction } from "@json-render/core";
|
||||||
|
export { createStateStore } from "@json-render/core";
|
||||||
|
|
||||||
|
// Catalog-aware types for Vue
|
||||||
|
export type {
|
||||||
|
EventHandle,
|
||||||
|
BaseComponentProps,
|
||||||
|
SetState,
|
||||||
|
StateModel,
|
||||||
|
ComponentContext,
|
||||||
|
ComponentFn,
|
||||||
|
Components,
|
||||||
|
ActionFn,
|
||||||
|
Actions,
|
||||||
|
} from "./catalog-types";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export {
|
||||||
|
useUIStream,
|
||||||
|
useChatUI,
|
||||||
|
useBoundProp,
|
||||||
|
flatToTree,
|
||||||
|
buildSpecFromParts,
|
||||||
|
getTextFromParts,
|
||||||
|
useJsonRenderMessage,
|
||||||
|
type UseUIStreamOptions,
|
||||||
|
type UseUIStreamReturn,
|
||||||
|
type UseChatUIOptions,
|
||||||
|
type UseChatUIReturn,
|
||||||
|
type ChatMessage,
|
||||||
|
type DataPart,
|
||||||
|
type TokenUsage,
|
||||||
|
} from "./hooks";
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
export {
|
||||||
|
// Registry
|
||||||
|
defineRegistry,
|
||||||
|
type DefineRegistryResult,
|
||||||
|
// createRenderer (higher-level, includes providers)
|
||||||
|
createRenderer,
|
||||||
|
type CreateRendererProps,
|
||||||
|
type ComponentMap,
|
||||||
|
// Low-level
|
||||||
|
Renderer,
|
||||||
|
JSONUIProvider,
|
||||||
|
type ComponentRenderProps,
|
||||||
|
type ComponentRegistry,
|
||||||
|
type RendererProps,
|
||||||
|
type JSONUIProviderProps,
|
||||||
|
} from "./renderer";
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { defineComponent, h, type Component } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import type { Spec } from "@json-render/core";
|
||||||
|
import { StateProvider } from "./composables/state";
|
||||||
|
import { VisibilityProvider } from "./composables/visibility";
|
||||||
|
import { ActionProvider } from "./composables/actions";
|
||||||
|
import { ValidationProvider } from "./composables/validation";
|
||||||
|
import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal test catalog and registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// defineRegistry ignores the catalog object at runtime — use any cast
|
||||||
|
const catalog = {} as any;
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: ({ props, children }) => {
|
||||||
|
const p = props as Record<string, unknown>;
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ "data-type": "card", "data-title": String(p["title"] ?? "") },
|
||||||
|
[String(p["title"] ?? ""), children],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Button: ({ props, emit }) => {
|
||||||
|
const p = props as Record<string, unknown>;
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{ "data-type": "button", onClick: () => emit("press") },
|
||||||
|
String(p["label"] ?? ""),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mount helper: wraps in the full provider chain required by ElementRenderer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function mountRenderer(
|
||||||
|
spec: Spec | null,
|
||||||
|
reg: ComponentRegistry = registry,
|
||||||
|
extraProps: Record<string, unknown> = {},
|
||||||
|
handlers: Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => Promise<void>
|
||||||
|
> = {},
|
||||||
|
initialState: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return mount(StateProvider as Component, {
|
||||||
|
props: { initialState } as any,
|
||||||
|
slots: {
|
||||||
|
default: () =>
|
||||||
|
h(VisibilityProvider as Component, null, {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider as Component, null, {
|
||||||
|
default: () =>
|
||||||
|
h(ActionProvider as Component, { handlers } as any, {
|
||||||
|
default: () =>
|
||||||
|
h(Renderer, { spec, registry: reg, ...extraProps }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// defineRegistry tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("defineRegistry", () => {
|
||||||
|
it("wraps a render function and returns a Vue component", () => {
|
||||||
|
const result = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
MyComp: () => h("span", null, "hello"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.registry).toBeDefined();
|
||||||
|
expect(result.registry["MyComp"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("component receives resolved props from the spec element", () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "btn1",
|
||||||
|
elements: {
|
||||||
|
btn1: { type: "Button", props: { label: "Press me" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountRenderer(spec);
|
||||||
|
expect(wrapper.find("[data-type='button']").text()).toBe("Press me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("children is passed for container components", () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "card1",
|
||||||
|
elements: {
|
||||||
|
card1: {
|
||||||
|
type: "Card",
|
||||||
|
props: { title: "My Card" },
|
||||||
|
children: ["btn1"],
|
||||||
|
},
|
||||||
|
btn1: { type: "Button", props: { label: "Click" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountRenderer(spec);
|
||||||
|
expect(wrapper.find("[data-type='card']").exists()).toBe(true);
|
||||||
|
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emit('press') fires the corresponding on.press action", async () => {
|
||||||
|
const handler = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "btn1",
|
||||||
|
elements: {
|
||||||
|
btn1: {
|
||||||
|
type: "Button",
|
||||||
|
props: { label: "Click" },
|
||||||
|
on: { press: { action: "myAction" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountRenderer(spec, registry, {}, { myAction: handler });
|
||||||
|
await wrapper.find("[data-type='button']").trigger("click");
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Renderer tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Renderer", () => {
|
||||||
|
it("renders a single-element spec", () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "btn1",
|
||||||
|
elements: {
|
||||||
|
btn1: { type: "Button", props: { label: "Go" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountRenderer(spec);
|
||||||
|
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
|
||||||
|
expect(wrapper.find("[data-type='button']").text()).toBe("Go");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a nested spec (parent contains a child by key reference)", () => {
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "card1",
|
||||||
|
elements: {
|
||||||
|
card1: {
|
||||||
|
type: "Card",
|
||||||
|
props: { title: "Root" },
|
||||||
|
children: ["btn1"],
|
||||||
|
},
|
||||||
|
btn1: { type: "Button", props: { label: "Action" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountRenderer(spec);
|
||||||
|
const card = wrapper.find("[data-type='card']");
|
||||||
|
expect(card.exists()).toBe(true);
|
||||||
|
expect(card.find("[data-type='button']").exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback component for unknown element types", () => {
|
||||||
|
const fallback = defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h("span", { "data-type": "fallback" }, "fallback");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "el1",
|
||||||
|
elements: {
|
||||||
|
el1: { type: "Unknown", props: {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wrapper = mountRenderer(spec, registry, { fallback });
|
||||||
|
expect(wrapper.find("[data-type='fallback']").exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes loading prop through to registered components", () => {
|
||||||
|
let receivedLoading: boolean | undefined;
|
||||||
|
const { registry: testRegistry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Widget: ({ loading }) => {
|
||||||
|
receivedLoading = loading;
|
||||||
|
return h("div", null, "widget");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const spec: Spec = {
|
||||||
|
root: "w1",
|
||||||
|
elements: { w1: { type: "Widget", props: {} } },
|
||||||
|
};
|
||||||
|
mountRenderer(spec, testRegistry, { loading: true });
|
||||||
|
expect(receivedLoading).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,961 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
inject,
|
||||||
|
onErrorCaptured,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
type Component,
|
||||||
|
type ComputedRef,
|
||||||
|
type PropType,
|
||||||
|
type VNode,
|
||||||
|
} from "vue";
|
||||||
|
import type {
|
||||||
|
UIElement,
|
||||||
|
Spec,
|
||||||
|
ActionBinding,
|
||||||
|
Catalog,
|
||||||
|
ComputedFunction,
|
||||||
|
SchemaDefinition,
|
||||||
|
StateStore,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import {
|
||||||
|
resolveElementProps,
|
||||||
|
resolveBindings,
|
||||||
|
resolveActionParam,
|
||||||
|
evaluateVisibility,
|
||||||
|
getByPath,
|
||||||
|
type PropResolutionContext,
|
||||||
|
} from "@json-render/core";
|
||||||
|
import type {
|
||||||
|
Components,
|
||||||
|
Actions,
|
||||||
|
ActionFn,
|
||||||
|
SetState,
|
||||||
|
StateModel,
|
||||||
|
CatalogHasActions,
|
||||||
|
EventHandle,
|
||||||
|
} from "./catalog-types";
|
||||||
|
import { useVisibility } from "./composables/visibility";
|
||||||
|
import { useActions } from "./composables/actions";
|
||||||
|
import { useStateStore } from "./composables/state";
|
||||||
|
import { StateProvider } from "./composables/state";
|
||||||
|
import { VisibilityProvider } from "./composables/visibility";
|
||||||
|
import { ActionProvider } from "./composables/actions";
|
||||||
|
import { ValidationProvider } from "./composables/validation";
|
||||||
|
import { ConfirmDialog } from "./composables/actions";
|
||||||
|
import {
|
||||||
|
RepeatScopeProvider,
|
||||||
|
useRepeatScope,
|
||||||
|
} from "./composables/repeat-scope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to component renderers
|
||||||
|
*/
|
||||||
|
export interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||||
|
/** The element being rendered */
|
||||||
|
element: UIElement<string, P>;
|
||||||
|
/** Emit a named event */
|
||||||
|
emit: (event: string) => void;
|
||||||
|
/** Get an event handle with metadata */
|
||||||
|
on: (event: string) => EventHandle;
|
||||||
|
/**
|
||||||
|
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
|
||||||
|
* Maps prop name → absolute state path for write-back.
|
||||||
|
*/
|
||||||
|
bindings?: Record<string, string>;
|
||||||
|
/** Whether the parent is loading */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of component renderers (Vue component definitions)
|
||||||
|
*/
|
||||||
|
export type ComponentRegistry = Record<string, Component>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the Renderer component
|
||||||
|
*/
|
||||||
|
export interface RendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
registry: ComponentRegistry;
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FunctionsContext — provides $computed functions to the element tree
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
|
||||||
|
const FUNCTIONS_KEY = Symbol("json-render:functions");
|
||||||
|
|
||||||
|
const FunctionsProvider = defineComponent({
|
||||||
|
name: "FunctionsProvider",
|
||||||
|
props: {
|
||||||
|
functions: {
|
||||||
|
type: Object as PropType<Record<string, ComputedFunction>>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const fns = computed(() => props.functions ?? EMPTY_FUNCTIONS);
|
||||||
|
provide(FUNCTIONS_KEY, fns);
|
||||||
|
return () => slots.default?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useFunctions(): ComputedRef<Record<string, ComputedFunction>> {
|
||||||
|
return inject<ComputedRef<Record<string, ComputedFunction>>>(
|
||||||
|
FUNCTIONS_KEY,
|
||||||
|
computed(() => EMPTY_FUNCTIONS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ElementErrorBoundary — catches rendering errors in individual elements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ElementErrorBoundary = defineComponent({
|
||||||
|
name: "ElementErrorBoundary",
|
||||||
|
props: {
|
||||||
|
elementType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
const hasError = ref(false);
|
||||||
|
|
||||||
|
onErrorCaptured((error) => {
|
||||||
|
console.error(
|
||||||
|
`[json-render] Rendering error in <${props.elementType}>:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
hasError.value = true;
|
||||||
|
return false; // prevent propagation
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hasError.value) return null;
|
||||||
|
return slots.default?.();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveAndExecuteBindings — shared helper for emitEvent / watch handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function resolveAndExecuteBindings(
|
||||||
|
actionBindings: ActionBinding[],
|
||||||
|
ctx: PropResolutionContext,
|
||||||
|
getSnapshot: () => Record<string, unknown>,
|
||||||
|
execute: (binding: ActionBinding) => Promise<void>,
|
||||||
|
cancelled?: () => boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const b of actionBindings) {
|
||||||
|
if (cancelled?.()) break;
|
||||||
|
if (!b.params) {
|
||||||
|
await execute(b);
|
||||||
|
if (cancelled?.()) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const liveCtx: PropResolutionContext = {
|
||||||
|
...ctx,
|
||||||
|
stateModel: getSnapshot(),
|
||||||
|
};
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(b.params)) {
|
||||||
|
resolved[key] = resolveActionParam(val, liveCtx);
|
||||||
|
}
|
||||||
|
await execute({ ...b, params: resolved });
|
||||||
|
if (cancelled?.()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ElementRenderer — renders a single element from the spec
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ElementRendererInternalProps {
|
||||||
|
element: UIElement;
|
||||||
|
spec: Spec;
|
||||||
|
registry: ComponentRegistry;
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ElementRenderer = defineComponent({
|
||||||
|
name: "JsonRenderElement",
|
||||||
|
props: {
|
||||||
|
element: {
|
||||||
|
type: Object as PropType<UIElement>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: Object as PropType<Spec>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
registry: {
|
||||||
|
type: Object as PropType<ComponentRegistry>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
type: Object as PropType<Component>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props: ElementRendererInternalProps) {
|
||||||
|
const repeatScope = useRepeatScope();
|
||||||
|
const { ctx: visibilityCtx } = useVisibility();
|
||||||
|
const { execute } = useActions();
|
||||||
|
const { getSnapshot, state: watchState } = useStateStore();
|
||||||
|
const functions = useFunctions();
|
||||||
|
|
||||||
|
// Build context with repeat scope and $computed functions
|
||||||
|
const fullCtx = computed<PropResolutionContext>(() => {
|
||||||
|
const base: PropResolutionContext = repeatScope
|
||||||
|
? {
|
||||||
|
...visibilityCtx.value,
|
||||||
|
repeatItem: repeatScope.item,
|
||||||
|
repeatIndex: repeatScope.index,
|
||||||
|
repeatBasePath: repeatScope.basePath,
|
||||||
|
}
|
||||||
|
: { ...visibilityCtx.value };
|
||||||
|
base.functions = functions.value;
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create emit function
|
||||||
|
const emitEvent = async (eventName: string): Promise<void> => {
|
||||||
|
const binding = props.element.on?.[eventName];
|
||||||
|
if (!binding) return;
|
||||||
|
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
||||||
|
await resolveAndExecuteBindings(
|
||||||
|
actionBindings,
|
||||||
|
fullCtx.value,
|
||||||
|
getSnapshot,
|
||||||
|
execute,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create on() function
|
||||||
|
const onEvent = (eventName: string): EventHandle => {
|
||||||
|
const binding = props.element.on?.[eventName];
|
||||||
|
if (!binding) {
|
||||||
|
return { emit: () => {}, shouldPreventDefault: false, bound: false };
|
||||||
|
}
|
||||||
|
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
||||||
|
const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
|
||||||
|
return {
|
||||||
|
emit: () => {
|
||||||
|
void emitEvent(eventName);
|
||||||
|
},
|
||||||
|
shouldPreventDefault,
|
||||||
|
bound: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch effect: fire actions when watched state paths change.
|
||||||
|
const watchedValues = computed(() => {
|
||||||
|
const cfg = props.element.watch;
|
||||||
|
if (!cfg) return undefined;
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const path of Object.keys(cfg)) {
|
||||||
|
values[path] = getByPath(watchState.value, path);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
watchedValues,
|
||||||
|
(current, prev, onCleanup) => {
|
||||||
|
const cfg = props.element.watch;
|
||||||
|
if (!cfg || !current) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = Object.keys(cfg);
|
||||||
|
void (async () => {
|
||||||
|
for (const path of paths) {
|
||||||
|
if (cancelled) break;
|
||||||
|
if (prev && current[path] === prev[path]) continue;
|
||||||
|
const binding = cfg[path];
|
||||||
|
if (!binding) continue;
|
||||||
|
const bindings = Array.isArray(binding) ? binding : [binding];
|
||||||
|
await resolveAndExecuteBindings(
|
||||||
|
bindings,
|
||||||
|
fullCtx.value,
|
||||||
|
getSnapshot,
|
||||||
|
execute,
|
||||||
|
() => cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const ctx = fullCtx.value;
|
||||||
|
|
||||||
|
// Evaluate visibility
|
||||||
|
const isVisible =
|
||||||
|
props.element.visible === undefined
|
||||||
|
? true
|
||||||
|
: evaluateVisibility(props.element.visible, ctx);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
// Resolve bindings and props
|
||||||
|
const rawProps = props.element.props as Record<string, unknown>;
|
||||||
|
const elementBindings = resolveBindings(rawProps, ctx);
|
||||||
|
const resolvedProps = resolveElementProps(rawProps, ctx);
|
||||||
|
|
||||||
|
const resolvedElement =
|
||||||
|
resolvedProps !== props.element.props
|
||||||
|
? { ...props.element, props: resolvedProps }
|
||||||
|
: props.element;
|
||||||
|
|
||||||
|
// Get component from registry
|
||||||
|
const Component = props.registry[resolvedElement.type] ?? props.fallback;
|
||||||
|
|
||||||
|
if (!Component) {
|
||||||
|
console.warn(
|
||||||
|
`[json-render] No renderer for component type: ${resolvedElement.type}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render children
|
||||||
|
const childrenVNodes: VNode | VNode[] | undefined = resolvedElement.repeat
|
||||||
|
? h(RepeatChildren, {
|
||||||
|
element: resolvedElement,
|
||||||
|
spec: props.spec,
|
||||||
|
registry: props.registry,
|
||||||
|
loading: props.loading,
|
||||||
|
fallback: props.fallback,
|
||||||
|
})
|
||||||
|
: (resolvedElement.children
|
||||||
|
?.map((childKey) => {
|
||||||
|
const childElement = props.spec.elements[childKey];
|
||||||
|
if (!childElement) {
|
||||||
|
if (!props.loading) {
|
||||||
|
console.warn(
|
||||||
|
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return h(ElementRenderer, {
|
||||||
|
key: childKey,
|
||||||
|
element: childElement,
|
||||||
|
spec: props.spec,
|
||||||
|
registry: props.registry,
|
||||||
|
loading: props.loading,
|
||||||
|
fallback: props.fallback,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.filter((n): n is VNode => n !== null) ?? undefined);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
ElementErrorBoundary,
|
||||||
|
{ elementType: resolvedElement.type },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
Component,
|
||||||
|
{
|
||||||
|
element: resolvedElement,
|
||||||
|
emit: emitEvent,
|
||||||
|
on: onEvent,
|
||||||
|
bindings: elementBindings,
|
||||||
|
loading: props.loading,
|
||||||
|
},
|
||||||
|
{ default: () => childrenVNodes },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RepeatChildren — renders child elements once per item in a state array
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RepeatChildren = defineComponent({
|
||||||
|
name: "JsonRenderRepeatChildren",
|
||||||
|
props: {
|
||||||
|
element: {
|
||||||
|
type: Object as PropType<UIElement>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: Object as PropType<Spec>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
registry: {
|
||||||
|
type: Object as PropType<ComponentRegistry>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
type: Object as PropType<Component>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { state } = useStateStore();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const repeat = props.element.repeat;
|
||||||
|
if (!repeat?.statePath) return null;
|
||||||
|
const statePath = repeat.statePath;
|
||||||
|
const raw = getByPath(state.value, statePath);
|
||||||
|
const items = Array.isArray(raw) ? (raw as unknown[]) : [];
|
||||||
|
|
||||||
|
return items.map((itemValue, index) => {
|
||||||
|
const key =
|
||||||
|
repeat.key && typeof itemValue === "object" && itemValue !== null
|
||||||
|
? String(
|
||||||
|
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
|
||||||
|
)
|
||||||
|
: String(index);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
RepeatScopeProvider,
|
||||||
|
{ key, item: itemValue, index, basePath: `${statePath}/${index}` },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
props.element.children
|
||||||
|
?.map((childKey) => {
|
||||||
|
const childElement = props.spec.elements[childKey];
|
||||||
|
if (!childElement) {
|
||||||
|
if (!props.loading) {
|
||||||
|
console.warn(
|
||||||
|
`[json-render] Missing element "${childKey}" referenced as child of "${props.element.type}" (repeat). This element will not render.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return h(ElementRenderer, {
|
||||||
|
key: childKey,
|
||||||
|
element: childElement,
|
||||||
|
spec: props.spec,
|
||||||
|
registry: props.registry,
|
||||||
|
loading: props.loading,
|
||||||
|
fallback: props.fallback,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.filter((n): n is VNode => n !== null) ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Renderer — main exported component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main renderer component
|
||||||
|
*/
|
||||||
|
export const Renderer = defineComponent({
|
||||||
|
name: "JsonRenderer",
|
||||||
|
props: {
|
||||||
|
spec: {
|
||||||
|
type: Object as PropType<Spec | null>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
registry: {
|
||||||
|
type: Object as PropType<ComponentRegistry>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
type: Object as PropType<Component>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
if (!props.spec?.root) return null;
|
||||||
|
|
||||||
|
const rootElement = props.spec.elements[props.spec.root];
|
||||||
|
if (!rootElement) return null;
|
||||||
|
|
||||||
|
return h(ElementRenderer, {
|
||||||
|
element: rootElement,
|
||||||
|
spec: props.spec,
|
||||||
|
registry: props.registry,
|
||||||
|
loading: props.loading,
|
||||||
|
fallback: props.fallback,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfirmationDialogManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ConfirmationDialogManager = defineComponent({
|
||||||
|
name: "ConfirmationDialogManager",
|
||||||
|
setup() {
|
||||||
|
const { pendingConfirmation, confirm, cancel } = useActions();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!pendingConfirmation?.action.confirm) return null;
|
||||||
|
|
||||||
|
return h(ConfirmDialog, {
|
||||||
|
confirm: pendingConfirmation.action.confirm,
|
||||||
|
onConfirm: confirm,
|
||||||
|
onCancel: cancel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSONUIProvider — combined provider for all contexts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for JSONUIProvider
|
||||||
|
*/
|
||||||
|
export interface JSONUIProviderProps {
|
||||||
|
registry: ComponentRegistry;
|
||||||
|
store?: StateStore;
|
||||||
|
initialState?: Record<string, unknown>;
|
||||||
|
handlers?: Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||||
|
>;
|
||||||
|
navigate?: (path: string) => void;
|
||||||
|
validationFunctions?: Record<
|
||||||
|
string,
|
||||||
|
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||||
|
>;
|
||||||
|
/** Named functions for `$computed` expressions in props */
|
||||||
|
functions?: Record<string, ComputedFunction>;
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined provider for all JSONUI contexts
|
||||||
|
*/
|
||||||
|
export const JSONUIProvider = defineComponent({
|
||||||
|
name: "JSONUIProvider",
|
||||||
|
props: {
|
||||||
|
registry: {
|
||||||
|
type: Object as PropType<ComponentRegistry>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
type: Object as PropType<StateStore>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
type: Object as PropType<Record<string, unknown>>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
type: Object as PropType<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
navigate: {
|
||||||
|
type: Function as PropType<(path: string) => void>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
validationFunctions: {
|
||||||
|
type: Object as PropType<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
functions: {
|
||||||
|
type: Object as PropType<Record<string, ComputedFunction>>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
onStateChange: {
|
||||||
|
type: Function as PropType<
|
||||||
|
(changes: Array<{ path: string; value: unknown }>) => void
|
||||||
|
>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
StateProvider,
|
||||||
|
{
|
||||||
|
store: props.store,
|
||||||
|
initialState: props.initialState,
|
||||||
|
onStateChange: props.onStateChange,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(VisibilityProvider, null, {
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
ValidationProvider,
|
||||||
|
{ customFunctions: props.validationFunctions },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
ActionProvider,
|
||||||
|
{ handlers: props.handlers, navigate: props.navigate },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
FunctionsProvider,
|
||||||
|
{ functions: props.functions },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
slots.default?.(),
|
||||||
|
h(ConfirmationDialogManager),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// defineRegistry
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result returned by defineRegistry
|
||||||
|
*/
|
||||||
|
export interface DefineRegistryResult {
|
||||||
|
registry: ComponentRegistry;
|
||||||
|
handlers: (
|
||||||
|
getSetState: () => SetState | undefined,
|
||||||
|
getState: () => StateModel,
|
||||||
|
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
|
||||||
|
executeAction: (
|
||||||
|
actionName: string,
|
||||||
|
params: Record<string, unknown> | undefined,
|
||||||
|
setState: SetState,
|
||||||
|
state?: StateModel,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefineRegistryOptions<C extends Catalog> = {
|
||||||
|
components?: Components<C>;
|
||||||
|
} & (CatalogHasActions<C> extends true
|
||||||
|
? { actions: Actions<C> }
|
||||||
|
: { actions?: Actions<C> });
|
||||||
|
|
||||||
|
type DefineRegistryComponentFn = (ctx: {
|
||||||
|
props: unknown;
|
||||||
|
children?: VNode | VNode[];
|
||||||
|
emit: (event: string) => void;
|
||||||
|
on: (event: string) => EventHandle;
|
||||||
|
bindings?: Record<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
|
}) => VNode | VNode[] | null | string;
|
||||||
|
|
||||||
|
type DefineRegistryActionFn = (
|
||||||
|
params: Record<string, unknown> | undefined,
|
||||||
|
setState: SetState,
|
||||||
|
state: StateModel,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a registry from a catalog with components and/or actions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Components only
|
||||||
|
* const { registry } = defineRegistry(catalog, {
|
||||||
|
* components: {
|
||||||
|
* Card: ({ props, children }) => h('div', { class: 'card' }, [props.title, children]),
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Both
|
||||||
|
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
|
||||||
|
* components: { ... },
|
||||||
|
* actions: { ... },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function defineRegistry<C extends Catalog>(
|
||||||
|
_catalog: C,
|
||||||
|
options: DefineRegistryOptions<C>,
|
||||||
|
): DefineRegistryResult {
|
||||||
|
const registry: ComponentRegistry = {};
|
||||||
|
|
||||||
|
if (options.components) {
|
||||||
|
for (const [name, componentFn] of Object.entries(options.components)) {
|
||||||
|
registry[name] = defineComponent({
|
||||||
|
name: `JsonRenderRegistry_${name}`,
|
||||||
|
props: {
|
||||||
|
element: {
|
||||||
|
type: Object as PropType<UIElement>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
emit: {
|
||||||
|
type: Function as PropType<(event: string) => void>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
type: Function as PropType<(event: string) => EventHandle>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
type: Object as PropType<Record<string, string>>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(registryProps, { slots }) {
|
||||||
|
return () =>
|
||||||
|
(componentFn as DefineRegistryComponentFn)({
|
||||||
|
props: registryProps.element.props,
|
||||||
|
children: slots.default?.(),
|
||||||
|
emit: registryProps.emit,
|
||||||
|
on: registryProps.on,
|
||||||
|
bindings: registryProps.bindings,
|
||||||
|
loading: registryProps.loading,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionMap = options.actions
|
||||||
|
? (Object.entries(options.actions) as Array<
|
||||||
|
[string, DefineRegistryActionFn]
|
||||||
|
>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handlers = (
|
||||||
|
getSetState: () => SetState | undefined,
|
||||||
|
getState: () => StateModel,
|
||||||
|
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
|
||||||
|
const result: Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => Promise<void>
|
||||||
|
> = {};
|
||||||
|
for (const [name, actionFn] of actionMap) {
|
||||||
|
result[name] = async (params) => {
|
||||||
|
const setState = getSetState();
|
||||||
|
const state = getState();
|
||||||
|
if (setState) {
|
||||||
|
await actionFn(params, setState, state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeAction = async (
|
||||||
|
actionName: string,
|
||||||
|
params: Record<string, unknown> | undefined,
|
||||||
|
setState: SetState,
|
||||||
|
state: StateModel = {},
|
||||||
|
): Promise<void> => {
|
||||||
|
const entry = actionMap.find(([name]) => name === actionName);
|
||||||
|
if (entry) {
|
||||||
|
await entry[1](params, setState, state);
|
||||||
|
} else {
|
||||||
|
console.warn(`Unknown action: ${actionName}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { registry, handlers, executeAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// createRenderer
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for renderers created with createRenderer
|
||||||
|
*/
|
||||||
|
export interface CreateRendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
store?: StateStore;
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
|
/** Named functions for `$computed` expressions in props */
|
||||||
|
functions?: Record<string, ComputedFunction>;
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component map type — maps component names to Vue components
|
||||||
|
*/
|
||||||
|
export type ComponentMap<
|
||||||
|
TComponents extends Record<string, { props: unknown }>,
|
||||||
|
> = {
|
||||||
|
[K in keyof TComponents]: Component;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a renderer from a catalog
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const DashboardRenderer = createRenderer(dashboardCatalog, {
|
||||||
|
* Card: ({ props, children }) => h('div', { class: 'card' }, children),
|
||||||
|
* Metric: ({ props }) => h('span', null, props.value),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Usage in template
|
||||||
|
* <DashboardRenderer :spec="aiGeneratedSpec" :state="state" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createRenderer<
|
||||||
|
TDef extends SchemaDefinition,
|
||||||
|
TCatalog extends { components: Record<string, { props: unknown }> },
|
||||||
|
>(
|
||||||
|
catalog: Catalog<TDef, TCatalog>,
|
||||||
|
components: ComponentMap<TCatalog["components"]>,
|
||||||
|
): Component {
|
||||||
|
const registry: ComponentRegistry =
|
||||||
|
components as unknown as ComponentRegistry;
|
||||||
|
|
||||||
|
return defineComponent({
|
||||||
|
name: "CatalogRenderer",
|
||||||
|
props: {
|
||||||
|
spec: {
|
||||||
|
type: Object as PropType<Spec | null>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
type: Object as PropType<StateStore>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: Object as PropType<Record<string, unknown>>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
onAction: {
|
||||||
|
type: Function as PropType<
|
||||||
|
(actionName: string, params?: Record<string, unknown>) => void
|
||||||
|
>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
onStateChange: {
|
||||||
|
type: Function as PropType<
|
||||||
|
(changes: Array<{ path: string; value: unknown }>) => void
|
||||||
|
>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
functions: {
|
||||||
|
type: Object as PropType<Record<string, ComputedFunction>>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
fallback: {
|
||||||
|
type: Object as PropType<Component>,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(rendererProps) {
|
||||||
|
return () => {
|
||||||
|
// Build the action handlers proxy if onAction is provided
|
||||||
|
const actionHandlers = rendererProps.onAction
|
||||||
|
? new Proxy(
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
(params: Record<string, unknown>) => void | Promise<void>
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
get: (_target, prop: string) => {
|
||||||
|
return (params: Record<string, unknown>) =>
|
||||||
|
rendererProps.onAction!(prop, params);
|
||||||
|
},
|
||||||
|
has: () => true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return h(
|
||||||
|
StateProvider,
|
||||||
|
{
|
||||||
|
store: rendererProps.store,
|
||||||
|
initialState: rendererProps.state,
|
||||||
|
onStateChange: rendererProps.onStateChange,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(VisibilityProvider, null, {
|
||||||
|
default: () =>
|
||||||
|
h(ValidationProvider, null, {
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
ActionProvider,
|
||||||
|
{ handlers: actionHandlers },
|
||||||
|
{
|
||||||
|
default: () =>
|
||||||
|
h(
|
||||||
|
FunctionsProvider,
|
||||||
|
{ functions: rendererProps.functions },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(Renderer, {
|
||||||
|
spec: rendererProps.spec,
|
||||||
|
registry,
|
||||||
|
loading: rendererProps.loading,
|
||||||
|
fallback: rendererProps.fallback,
|
||||||
|
}),
|
||||||
|
h(ConfirmationDialogManager),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { defineSchema } from "@json-render/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The schema for @json-render/vue
|
||||||
|
*
|
||||||
|
* Defines:
|
||||||
|
* - Spec: A flat tree of elements with keys, types, props, and children references
|
||||||
|
* - Catalog: Components with props schemas, and optional actions
|
||||||
|
*/
|
||||||
|
export const schema = defineSchema(
|
||||||
|
(s) => ({
|
||||||
|
// What the AI-generated SPEC looks like
|
||||||
|
spec: s.object({
|
||||||
|
/** Root element key */
|
||||||
|
root: s.string(),
|
||||||
|
/** Flat map of elements by key */
|
||||||
|
elements: s.record(
|
||||||
|
s.object({
|
||||||
|
/** Component type from catalog */
|
||||||
|
type: s.ref("catalog.components"),
|
||||||
|
/** Component props */
|
||||||
|
props: s.propsOf("catalog.components"),
|
||||||
|
/** Child element keys (flat reference) */
|
||||||
|
children: s.array(s.string()),
|
||||||
|
/** Visibility condition */
|
||||||
|
visible: s.any(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// What the CATALOG must provide
|
||||||
|
catalog: s.object({
|
||||||
|
/** Component definitions */
|
||||||
|
components: s.map({
|
||||||
|
/** Zod schema for component props */
|
||||||
|
props: s.zod(),
|
||||||
|
/** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
|
||||||
|
slots: s.array(s.string()),
|
||||||
|
/** Description for AI generation hints */
|
||||||
|
description: s.string(),
|
||||||
|
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
|
||||||
|
example: s.any(),
|
||||||
|
}),
|
||||||
|
/** Action definitions (optional) */
|
||||||
|
actions: s.map({
|
||||||
|
/** Zod schema for action params */
|
||||||
|
params: s.zod(),
|
||||||
|
/** Description for AI generation hints */
|
||||||
|
description: s.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
builtInActions: [
|
||||||
|
{
|
||||||
|
name: "setState",
|
||||||
|
description:
|
||||||
|
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pushState",
|
||||||
|
description:
|
||||||
|
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removeState",
|
||||||
|
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
|
||||||
|
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
|
||||||
|
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
|
||||||
|
|
||||||
|
// Field placement
|
||||||
|
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
|
||||||
|
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
|
||||||
|
|
||||||
|
// State and data
|
||||||
|
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||||
|
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||||
|
|
||||||
|
// Design quality
|
||||||
|
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
|
||||||
|
"For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
|
||||||
|
"Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for the Vue schema
|
||||||
|
*/
|
||||||
|
export type VueSchema = typeof schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer the spec type from a catalog
|
||||||
|
*/
|
||||||
|
export type VueSpec<TCatalog> = typeof schema extends {
|
||||||
|
createCatalog: (catalog: TCatalog) => { _specType: infer S };
|
||||||
|
}
|
||||||
|
? S
|
||||||
|
: never;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@internal/typescript-config/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts", "src/schema.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
external: ["vue", "@json-render/core"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# @json-render/xstate-store
|
||||||
|
|
||||||
|
[XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for json-render.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/xstate-store @json-render/core @json-render/react @xstate/store
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This adapter requires `@xstate/store` v3+.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createAtom } from "@xstate/store";
|
||||||
|
import { xstateStoreStateStore } from "@json-render/xstate-store";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
// 1. Create an atom
|
||||||
|
const uiAtom = createAtom({ count: 0 });
|
||||||
|
|
||||||
|
// 2. Create the json-render StateStore adapter
|
||||||
|
const store = xstateStoreStateStore({ atom: uiAtom });
|
||||||
|
|
||||||
|
// 3. Use it
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* json-render reads/writes go through @xstate/store */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `xstateStoreStateStore(options)`
|
||||||
|
|
||||||
|
Creates a `StateStore` backed by an `@xstate/store` atom.
|
||||||
|
|
||||||
|
#### Options
|
||||||
|
|
||||||
|
| Option | Type | Required | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| `atom` | `Atom<StateModel>` | Yes | An `@xstate/store` atom (from `createAtom`) holding the json-render state model |
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "@json-render/xstate-store",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"description": "XState Store adapter for json-render StateStore",
|
||||||
|
"keywords": [
|
||||||
|
"json-render",
|
||||||
|
"xstate",
|
||||||
|
"xstate-store",
|
||||||
|
"state-management",
|
||||||
|
"adapter"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||||
|
"directory": "packages/xstate-store"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@json-render/core": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xstate/store": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/typescript-config": "workspace:*",
|
||||||
|
"tsup": "^8.0.2",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"@xstate/store": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createAtom } from "@xstate/store";
|
||||||
|
import { xstateStoreStateStore } from "./index";
|
||||||
|
|
||||||
|
function createTestStore(initial: Record<string, unknown> = {}) {
|
||||||
|
const atom = createAtom<Record<string, unknown>>(initial);
|
||||||
|
const store = xstateStoreStateStore({ atom });
|
||||||
|
return { atom, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("xstateStoreStateStore", () => {
|
||||||
|
it("get/set round-trip", () => {
|
||||||
|
const { store } = createTestStore({ count: 0 });
|
||||||
|
|
||||||
|
expect(store.get("/count")).toBe(0);
|
||||||
|
|
||||||
|
store.set("/count", 42);
|
||||||
|
|
||||||
|
expect(store.get("/count")).toBe(42);
|
||||||
|
expect(store.getSnapshot().count).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update round-trip with multiple values", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": "hello" });
|
||||||
|
|
||||||
|
expect(store.get("/a")).toBe(1);
|
||||||
|
expect(store.get("/b")).toBe("hello");
|
||||||
|
expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe fires on set", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe fires on update", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": 2 });
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribe stops notifications", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsub = store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
store.set("/x", 2);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSnapshot immutability -- previous snapshot is not mutated", () => {
|
||||||
|
const { store } = createTestStore({
|
||||||
|
user: { name: "Alice", age: 30 },
|
||||||
|
});
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/user/name", "Bob");
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
|
||||||
|
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||||
|
expect(snap1.user).not.toBe(snap2.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("structural sharing -- untouched branches keep references", () => {
|
||||||
|
const { store } = createTestStore({
|
||||||
|
a: { x: 1 },
|
||||||
|
b: { y: 2 },
|
||||||
|
});
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/a/x", 99);
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap2.b).toBe(snap1.b);
|
||||||
|
expect(snap2.a).not.toBe(snap1.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getServerSnapshot returns same as getSnapshot", () => {
|
||||||
|
const { store } = createTestStore({ x: 1 });
|
||||||
|
|
||||||
|
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||||
|
|
||||||
|
store.set("/x", 2);
|
||||||
|
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set skips update when value is unchanged", () => {
|
||||||
|
const { store } = createTestStore({ x: 1 });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot()).toBe(snap1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update skips update when no values changed", () => {
|
||||||
|
const { store } = createTestStore({ a: 1, b: 2 });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": 2 });
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot()).toBe(snap1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads from the shared atom", () => {
|
||||||
|
const atom = createAtom<Record<string, unknown>>({ count: 0 });
|
||||||
|
const store = xstateStoreStateStore({ atom });
|
||||||
|
|
||||||
|
atom.set({ count: 99 });
|
||||||
|
|
||||||
|
expect(store.get("/count")).toBe(99);
|
||||||
|
expect(store.getSnapshot().count).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe fires on external atom.set", () => {
|
||||||
|
const atom = createAtom<Record<string, unknown>>({ count: 0 });
|
||||||
|
const store = xstateStoreStateStore({ atom });
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
atom.set({ count: 99 });
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.get("/count")).toBe(99);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { StateModel, StateStore } from "@json-render/core";
|
||||||
|
import { createStoreAdapter } from "@json-render/core/store-utils";
|
||||||
|
import type { Atom } from "@xstate/store";
|
||||||
|
|
||||||
|
export type { StateStore } from "@json-render/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for {@link xstateStoreStateStore}.
|
||||||
|
*/
|
||||||
|
export interface XstateStoreStateStoreOptions {
|
||||||
|
/** An `@xstate/store` atom (created with `createAtom`). */
|
||||||
|
atom: Atom<StateModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a {@link StateStore} backed by an `@xstate/store` atom.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { createAtom } from "@xstate/store";
|
||||||
|
* import { xstateStoreStateStore } from "@json-render/xstate-store";
|
||||||
|
*
|
||||||
|
* const uiAtom = createAtom<Record<string, unknown>>({ count: 0 });
|
||||||
|
*
|
||||||
|
* const store = xstateStoreStateStore({ atom: uiAtom });
|
||||||
|
*
|
||||||
|
* <StateProvider store={store}>...</StateProvider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function xstateStoreStateStore(
|
||||||
|
options: XstateStoreStateStoreOptions,
|
||||||
|
): StateStore {
|
||||||
|
const { atom } = options;
|
||||||
|
|
||||||
|
return createStoreAdapter({
|
||||||
|
getSnapshot: () => atom.get(),
|
||||||
|
setSnapshot: (next) => atom.set(next),
|
||||||
|
subscribe(listener) {
|
||||||
|
const sub = atom.subscribe(() => {
|
||||||
|
listener();
|
||||||
|
});
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@internal/typescript-config/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
external: [
|
||||||
|
"@json-render/core",
|
||||||
|
"@json-render/core/store-utils",
|
||||||
|
"@xstate/store",
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+686
-20
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -3,11 +3,12 @@ import path from "path";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
// Deduplicate React so tests don't get two copies
|
// Deduplicate React and Vue so tests don't get two copies
|
||||||
// (pnpm strict resolution can cause packages/react to resolve a different copy)
|
// (pnpm strict resolution can cause packages to resolve different copies)
|
||||||
alias: {
|
alias: {
|
||||||
react: path.resolve(__dirname, "node_modules/react"),
|
react: path.resolve(__dirname, "node_modules/react"),
|
||||||
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
|
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
|
||||||
|
vue: path.resolve(__dirname, "packages/vue/node_modules/vue"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user