mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc38be8efe | ||
|
|
aa7eef1aea | ||
|
|
ea47b66dfc | ||
|
|
cd82f969c8 | ||
|
|
6bcaaad57d | ||
|
|
0b7d767cdd | ||
|
|
b1036763d2 |
@@ -23,6 +23,8 @@ coverage
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
.nuxt/
|
||||
.output/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
|
||||
@@ -117,6 +117,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
| `@json-render/xstate-store` | XState Store (atom) adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# web
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -5,6 +5,16 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.9.1
|
||||
|
||||
February 2026
|
||||
|
||||
### Fixed: Install failure due to private dependency
|
||||
|
||||
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0
|
||||
|
||||
February 2026
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/computed-values")
|
||||
|
||||
# Computed Values
|
||||
|
||||
Derive dynamic prop values using registered functions or string templates.
|
||||
|
||||
## `$template` — String Interpolation
|
||||
|
||||
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
|
||||
|
||||
Missing paths resolve to an empty string.
|
||||
|
||||
## `$computed` — Registered Functions
|
||||
|
||||
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": {
|
||||
"$computed": "fullName",
|
||||
"args": {
|
||||
"first": { "$state": "/form/firstName" },
|
||||
"last": { "$state": "/form/lastName" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Functions
|
||||
|
||||
Functions are registered in the catalog and provided at runtime.
|
||||
|
||||
**Catalog definition (for AI prompt generation):**
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react/schema';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
functions: {
|
||||
fullName: {
|
||||
description: 'Combines first and last name into a full name',
|
||||
},
|
||||
formatCurrency: {
|
||||
description: 'Formats a number as currency',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Runtime implementation:**
|
||||
|
||||
```tsx
|
||||
import { JSONUIProvider } from '@json-render/react';
|
||||
|
||||
const functions = {
|
||||
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
|
||||
formatCurrency: (args) => {
|
||||
const value = Number(args.value ?? 0);
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: (args.currency as string) ?? 'USD',
|
||||
}).format(value);
|
||||
},
|
||||
};
|
||||
|
||||
<JSONUIProvider registry={registry} functions={functions}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
```
|
||||
|
||||
### Using with `createRenderer`
|
||||
|
||||
```tsx
|
||||
const MyRenderer = createRenderer(catalog, components);
|
||||
|
||||
<MyRenderer
|
||||
spec={spec}
|
||||
functions={functions}
|
||||
/>
|
||||
```
|
||||
|
||||
## Combining Expressions
|
||||
|
||||
`$computed` args can use any expression type. This example computes a total from repeat item fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"$computed": "lineTotal",
|
||||
"args": {
|
||||
"price": { "$item": "price" },
|
||||
"quantity": { "$item": "quantity" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
- [Watchers](/docs/watchers) — react to state changes with cascading actions
|
||||
- [Data Binding](/docs/data-binding) — all expression types
|
||||
- [Validation](/docs/validation) — validate form inputs
|
||||
@@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
|
||||
|
||||
The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
|
||||
## Template Strings
|
||||
|
||||
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"text": { "$template": "Welcome back, ${/user/name}!" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
@@ -255,6 +271,16 @@ The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||
<td>Form components inside repeat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$template"}</code></td>
|
||||
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
|
||||
<td>Anywhere (string props)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$computed"}</code></td>
|
||||
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
|
||||
<td>Anywhere (requires registered function)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,18 @@ json-render includes common validation functions:
|
||||
|
||||
- `required` — Value must be non-empty
|
||||
- `email` — Valid email format
|
||||
- `minLength` — Minimum string length
|
||||
- `maxLength` — Maximum string length
|
||||
- `pattern` — Match a regex pattern
|
||||
- `min` — Minimum numeric value
|
||||
- `max` — Maximum numeric value
|
||||
- `minLength` — Minimum string length (args: `{ "min": N }`)
|
||||
- `maxLength` — Maximum string length (args: `{ "max": N }`)
|
||||
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
|
||||
- `min` — Minimum numeric value (args: `{ "min": N }`)
|
||||
- `max` — Maximum numeric value (args: `{ "max": N }`)
|
||||
- `numeric` — Value must be a number
|
||||
- `url` — Valid URL format
|
||||
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
|
||||
|
||||
## Using Validation in JSON
|
||||
|
||||
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
||||
|
||||
## Cross-Field Validation
|
||||
|
||||
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"label": "Confirm Password",
|
||||
"value": { "$bindState": "/form/confirmPassword" },
|
||||
"checks": [
|
||||
{ "type": "required", "message": "Please confirm your password" },
|
||||
{
|
||||
"type": "matches",
|
||||
"args": { "other": { "$state": "/form/password" } },
|
||||
"message": "Passwords must match"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other cross-field examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"type": "greaterThan",
|
||||
"args": { "other": { "$state": "/form/startDate" } },
|
||||
"message": "End date must be after start date"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"type": "requiredIf",
|
||||
"args": { "field": { "$state": "/form/enableNotifications" } },
|
||||
"message": "Email is required when notifications are enabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Validation
|
||||
|
||||
Use the `enabled` field in the validation config to only run checks when a condition is met:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": {
|
||||
"label": "Company Name",
|
||||
"value": { "$bindState": "/form/company" },
|
||||
"checks": [
|
||||
{ "type": "required", "message": "Company name is required" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the component implementation, you can pass `enabled` to `useFieldValidation`:
|
||||
|
||||
```typescript
|
||||
useFieldValidation(bindings?.value ?? "", {
|
||||
checks: props.checks ?? [],
|
||||
enabled: { "$state": "/form/accountType", eq: "business" },
|
||||
});
|
||||
```
|
||||
|
||||
This only validates the company name when the account type is "business".
|
||||
|
||||
## Validation Timing
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
|
||||
- `change` — Validate on every input change
|
||||
- `blur` — Validate when field loses focus
|
||||
- `blur` — Validate when field loses focus (default for Input, Textarea)
|
||||
- `submit` — Validate only on form submission
|
||||
|
||||
## Form-Level Validation
|
||||
|
||||
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"on": {
|
||||
"press": [
|
||||
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||
{ "action": "submitForm" }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
|
||||
|
||||
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [generation modes](/docs/generation-modes).
|
||||
- [Computed Values](/docs/computed-values) — derive dynamic prop values
|
||||
- [Watchers](/docs/watchers) — react to state changes
|
||||
- [Generation Modes](/docs/generation-modes) — how AI generates specs
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/watchers")
|
||||
|
||||
# Watchers
|
||||
|
||||
React to state changes by triggering actions when watched paths update.
|
||||
|
||||
## The `watch` Field
|
||||
|
||||
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
|
||||
|
||||
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": {
|
||||
"action": "loadCities",
|
||||
"params": { "country": { "$state": "/form/country" } }
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
|
||||
|
||||
## Cascading Selects
|
||||
|
||||
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "form",
|
||||
"elements": {
|
||||
"form": {
|
||||
"type": "Stack",
|
||||
"props": { "direction": "vertical", "gap": "md" },
|
||||
"children": ["country-select", "city-select"]
|
||||
},
|
||||
"country-select": {
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "Country",
|
||||
"value": { "$bindState": "/form/country" },
|
||||
"options": ["US", "Canada", "UK"]
|
||||
},
|
||||
"watch": {
|
||||
"/form/country": [
|
||||
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
|
||||
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
|
||||
]
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
"city-select": {
|
||||
"type": "Select",
|
||||
"props": {
|
||||
"label": "City",
|
||||
"value": { "$bindState": "/form/city" },
|
||||
"options": { "$state": "/availableCities" },
|
||||
"placeholder": "Select a city"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"form": { "country": "", "city": "" },
|
||||
"availableCities": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The watcher on `country-select` fires two actions when the country changes:
|
||||
1. `loadCities` — fetches and writes city options to `/availableCities`
|
||||
2. `setState` — resets the city selection
|
||||
|
||||
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
|
||||
|
||||
### Action Handler
|
||||
|
||||
```typescript
|
||||
const handlers = {
|
||||
loadCities: async (params) => {
|
||||
const cities = await fetchCities(params.country);
|
||||
// setState is called by the runtime to write the result
|
||||
return cities;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Or with `defineRegistry`:
|
||||
|
||||
```typescript
|
||||
const { registry, handlers } = defineRegistry(catalog, {
|
||||
components: { /* ... */ },
|
||||
actions: {
|
||||
loadCities: async (params, setState) => {
|
||||
const response = await fetch(`/api/cities?country=${params.country}`);
|
||||
const cities = await response.json();
|
||||
setState('/availableCities', cities);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Multiple Watchers
|
||||
|
||||
An element can watch multiple state paths. Each path maps to one or more action bindings:
|
||||
|
||||
```json
|
||||
{
|
||||
"watch": {
|
||||
"/form/startDate": { "action": "validateDateRange" },
|
||||
"/form/endDate": { "action": "validateDateRange" },
|
||||
"/form/quantity": [
|
||||
{ "action": "recalculateTotal" },
|
||||
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Watchers only fire on **value changes**, not on the initial render
|
||||
- Comparison is by reference (`===`), not deep equality
|
||||
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
|
||||
- Multiple action bindings on the same path execute sequentially
|
||||
|
||||
## When to Use `watch` vs `on`
|
||||
|
||||
| Mechanism | Trigger | Use Case |
|
||||
|-----------|---------|----------|
|
||||
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
|
||||
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
|
||||
|
||||
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
|
||||
|
||||
## Next
|
||||
|
||||
- [Data Binding](/docs/data-binding) — connect elements to state
|
||||
- [Computed Values](/docs/computed-values) — derive prop values
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Computed Values", href: "/docs/computed-values" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Watchers", href: "/docs/watchers" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-chat
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/shadcn@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-no-ai
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/shadcn@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+59
-41
@@ -2,33 +2,31 @@
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
ValidationProvider,
|
||||
} from "@json-render/react";
|
||||
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
|
||||
import {
|
||||
registry,
|
||||
actionHandlers,
|
||||
computedFunctions,
|
||||
onConfetti,
|
||||
} from "@/lib/render/registry";
|
||||
import { examples } from "@/lib/examples";
|
||||
|
||||
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
||||
return (
|
||||
<StateProvider initialState={spec.state ?? {}}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec.state ?? {}}
|
||||
handlers={actionHandlers}
|
||||
functions={computedFunctions}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [selectedIndex] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = examples[selectedIndex]!;
|
||||
const [confettiKey, setConfettiKey] = useState(0);
|
||||
const [confettiActive, setConfettiActive] = useState(false);
|
||||
@@ -41,30 +39,50 @@ export default function Page() {
|
||||
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-muted/30">
|
||||
<div
|
||||
className="relative bg-background border rounded-lg shadow-sm"
|
||||
style={{ width: 960, height: 1080 }}
|
||||
>
|
||||
{confettiActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
<div className="h-screen flex flex-col bg-muted/30">
|
||||
{/* Example selector */}
|
||||
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
|
||||
{examples.map((ex, i) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => setSelectedIndex(i)}
|
||||
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
|
||||
i === selectedIndex
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{ex.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Render area */}
|
||||
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
|
||||
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
|
||||
{confettiActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 relative z-10">
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{selected.description}
|
||||
</p>
|
||||
<SpecRenderer key={selectedIndex} spec={selected.spec} />
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
|
||||
<SpecRenderer key={selectedIndex} spec={selected.spec} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -508,4 +508,353 @@ export const examples: Example[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Advanced: Registration form with cross-field validation & $template
|
||||
// =========================================================================
|
||||
{
|
||||
name: "Registration Form",
|
||||
description:
|
||||
"Cross-field validation, $template preview, and validateForm action",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
form: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
accountType: "personal",
|
||||
company: "",
|
||||
},
|
||||
result: null,
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Create Account",
|
||||
description: "Fill out the form below to register",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["formStack"],
|
||||
},
|
||||
formStack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"preview",
|
||||
"sep0",
|
||||
"nameInput",
|
||||
"emailInput",
|
||||
"passwordInput",
|
||||
"confirmInput",
|
||||
"sep1",
|
||||
"accountTypeRadio",
|
||||
"companyInput",
|
||||
"sep2",
|
||||
"actions",
|
||||
"statusText",
|
||||
],
|
||||
},
|
||||
|
||||
// $template live preview
|
||||
preview: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
|
||||
},
|
||||
variant: "muted",
|
||||
},
|
||||
visible: { $state: "/form/name", neq: "" },
|
||||
children: [],
|
||||
},
|
||||
sep0: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
nameInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Full Name",
|
||||
name: "name",
|
||||
type: "text",
|
||||
placeholder: "Jane Doe",
|
||||
value: { $bindState: "/form/name" },
|
||||
checks: [
|
||||
{ type: "required", message: "Name is required" },
|
||||
{
|
||||
type: "minLength",
|
||||
args: { min: 2 },
|
||||
message: "Name must be at least 2 characters",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "jane@example.com",
|
||||
value: { $bindState: "/form/email" },
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Enter a valid email address" },
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
passwordInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Password",
|
||||
name: "password",
|
||||
type: "password",
|
||||
placeholder: "At least 8 characters",
|
||||
value: { $bindState: "/form/password" },
|
||||
checks: [
|
||||
{ type: "required", message: "Password is required" },
|
||||
{
|
||||
type: "minLength",
|
||||
args: { min: 8 },
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
confirmInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Confirm Password",
|
||||
name: "confirmPassword",
|
||||
type: "password",
|
||||
placeholder: "Re-enter your password",
|
||||
value: { $bindState: "/form/confirmPassword" },
|
||||
checks: [
|
||||
{ type: "required", message: "Please confirm your password" },
|
||||
{
|
||||
type: "matches",
|
||||
args: { other: { $state: "/form/password" } },
|
||||
message: "Passwords must match",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sep1: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
accountTypeRadio: {
|
||||
type: "Radio",
|
||||
props: {
|
||||
label: "Account Type",
|
||||
name: "accountType",
|
||||
options: ["personal", "business"],
|
||||
value: { $bindState: "/form/accountType" },
|
||||
checks: null,
|
||||
validateOn: null,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
companyInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Company Name",
|
||||
name: "company",
|
||||
type: "text",
|
||||
placeholder: "Acme Inc.",
|
||||
value: { $bindState: "/form/company" },
|
||||
checks: [
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/accountType" } },
|
||||
message: "Company name is required for business accounts",
|
||||
},
|
||||
],
|
||||
validateOn: "blur",
|
||||
},
|
||||
visible: { $state: "/form/accountType", eq: "business" },
|
||||
children: [],
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: "end",
|
||||
},
|
||||
children: ["submitBtn"],
|
||||
},
|
||||
submitBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Register", variant: "primary", disabled: null },
|
||||
on: {
|
||||
press: [
|
||||
{
|
||||
action: "validateForm",
|
||||
params: { statePath: "/result" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
// Validation result
|
||||
statusText: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "Validation Result",
|
||||
message: {
|
||||
$cond: { $state: "/result/valid", eq: true },
|
||||
$then: "All fields are valid -- ready to submit!",
|
||||
$else: "Please fix the errors above before submitting.",
|
||||
},
|
||||
type: {
|
||||
$cond: { $state: "/result/valid", eq: true },
|
||||
$then: "success",
|
||||
$else: "error",
|
||||
},
|
||||
},
|
||||
visible: { $state: "/result", neq: null },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Advanced: Cascading selects with watchers & $computed
|
||||
// =========================================================================
|
||||
{
|
||||
name: "Cascading Selects",
|
||||
description:
|
||||
"Watchers reset dependent fields, $computed derives display values",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
form: { country: "", city: "" },
|
||||
availableCities: [],
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Shipping Address",
|
||||
description: "Select your country to load available cities",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["formStack"],
|
||||
},
|
||||
formStack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"countrySelect",
|
||||
"citySelect",
|
||||
"sep",
|
||||
"addressPreview",
|
||||
"templatePreview",
|
||||
],
|
||||
},
|
||||
|
||||
countrySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
name: "country",
|
||||
options: ["US", "Canada", "UK", "Germany", "Japan"],
|
||||
placeholder: "Choose a country",
|
||||
value: { $bindState: "/form/country" },
|
||||
checks: [{ type: "required", message: "Country is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
watch: {
|
||||
"/form/country": [
|
||||
{
|
||||
action: "setState",
|
||||
params: {
|
||||
statePath: "/availableCities",
|
||||
value: {
|
||||
$computed: "citiesForCountry",
|
||||
args: { country: { $state: "/form/country" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "setState",
|
||||
params: { statePath: "/form/city", value: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
citySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "City",
|
||||
name: "city",
|
||||
options: { $state: "/availableCities" },
|
||||
placeholder: "Select a city",
|
||||
value: { $bindState: "/form/city" },
|
||||
checks: [{ type: "required", message: "City is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null }, children: [] },
|
||||
|
||||
// $computed formatted address
|
||||
addressPreview: {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: {
|
||||
$computed: "formatAddress",
|
||||
args: {
|
||||
city: { $state: "/form/city" },
|
||||
country: { $state: "/form/country" },
|
||||
},
|
||||
},
|
||||
level: "h3",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
// $template string interpolation
|
||||
templatePreview: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: {
|
||||
$template:
|
||||
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
|
||||
},
|
||||
variant: "muted",
|
||||
},
|
||||
visible: { $state: "/form/country", neq: "" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
|
||||
description: "Fire confetti",
|
||||
},
|
||||
},
|
||||
functions: {
|
||||
formatAddress: {
|
||||
description:
|
||||
"Formats country and city into a single address string like 'City, Country'",
|
||||
},
|
||||
citiesForCountry: {
|
||||
description: "Returns an array of city names for the given country code",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
@@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) {
|
||||
};
|
||||
}
|
||||
|
||||
const cityData: Record<string, string[]> = {
|
||||
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
|
||||
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
|
||||
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
|
||||
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
|
||||
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
|
||||
};
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
...shadcnComponents,
|
||||
@@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, {
|
||||
},
|
||||
});
|
||||
|
||||
export const actionHandlers: Record<string, () => void> = {
|
||||
export const actionHandlers: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void
|
||||
> = {
|
||||
confetti: () => confettiListener?.(),
|
||||
};
|
||||
|
||||
export const computedFunctions: Record<string, ComputedFunction> = {
|
||||
formatAddress: (args) => {
|
||||
const city = (args.city as string) ?? "";
|
||||
const country = (args.country as string) ?? "";
|
||||
if (!city && !country) return "No location selected";
|
||||
if (!city) return country;
|
||||
return `${city}, ${country}`;
|
||||
},
|
||||
citiesForCountry: (args) => {
|
||||
const country = (args.country as string) ?? "";
|
||||
return cityData[country] ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export interface Example {
|
||||
name: string;
|
||||
description: string;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
export const examples: Example[] = [
|
||||
{
|
||||
name: "User Profile Card",
|
||||
description: "A card with user info, badges, and a progress bar",
|
||||
spec: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "User Profile",
|
||||
description: null,
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["stack"],
|
||||
},
|
||||
stack: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["heading", "text", "badges", "sep", "progress"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Jane Cooper", level: "h2" },
|
||||
},
|
||||
text: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
|
||||
variant: "muted",
|
||||
},
|
||||
},
|
||||
badges: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["b1", "b2", "b3"],
|
||||
},
|
||||
b1: {
|
||||
type: "Badge",
|
||||
props: { text: "TypeScript", variant: "default" },
|
||||
},
|
||||
b2: { type: "Badge", props: { text: "Vue", variant: "secondary" } },
|
||||
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
|
||||
sep: { type: "Separator", props: { orientation: null } },
|
||||
progress: {
|
||||
type: "Progress",
|
||||
props: { value: 72, max: 100, label: "Profile completion" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Settings Form",
|
||||
description: "A form with inputs, selects, and switches",
|
||||
spec: {
|
||||
root: "card",
|
||||
state: {
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
role: "Engineer",
|
||||
notifications: true,
|
||||
darkMode: false,
|
||||
},
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: {
|
||||
title: "Account Settings",
|
||||
description: "Manage your preferences",
|
||||
maxWidth: "md",
|
||||
centered: null,
|
||||
},
|
||||
children: ["form"],
|
||||
},
|
||||
form: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "md",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: [
|
||||
"nameInput",
|
||||
"emailInput",
|
||||
"roleSelect",
|
||||
"sep",
|
||||
"notifSwitch",
|
||||
"darkSwitch",
|
||||
"sep2",
|
||||
"actions",
|
||||
],
|
||||
},
|
||||
nameInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Full Name",
|
||||
name: "name",
|
||||
type: "text",
|
||||
placeholder: "Your name",
|
||||
value: { $bindState: "/name" },
|
||||
checks: null,
|
||||
},
|
||||
},
|
||||
emailInput: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "you@example.com",
|
||||
value: { $bindState: "/email" },
|
||||
checks: null,
|
||||
},
|
||||
},
|
||||
roleSelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Role",
|
||||
name: "role",
|
||||
options: [
|
||||
"Engineer",
|
||||
"Designer",
|
||||
"Product Manager",
|
||||
"Data Scientist",
|
||||
],
|
||||
placeholder: "Choose a role",
|
||||
value: { $bindState: "/role" },
|
||||
checks: null,
|
||||
},
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null } },
|
||||
notifSwitch: {
|
||||
type: "Switch",
|
||||
props: {
|
||||
label: "Email notifications",
|
||||
name: "notifications",
|
||||
checked: { $bindState: "/notifications" },
|
||||
},
|
||||
},
|
||||
darkSwitch: {
|
||||
type: "Switch",
|
||||
props: {
|
||||
label: "Dark mode",
|
||||
name: "darkMode",
|
||||
checked: { $bindState: "/darkMode" },
|
||||
},
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null } },
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "sm",
|
||||
align: null,
|
||||
justify: "end",
|
||||
},
|
||||
children: ["cancelBtn", "saveBtn"],
|
||||
},
|
||||
cancelBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Cancel", variant: "secondary", disabled: null },
|
||||
},
|
||||
saveBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Save Changes", variant: "primary", disabled: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Pricing Table",
|
||||
description: "A grid of pricing cards with feature lists",
|
||||
spec: {
|
||||
root: "outer",
|
||||
elements: {
|
||||
outer: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "lg",
|
||||
align: null,
|
||||
justify: null,
|
||||
},
|
||||
children: ["header", "grid"],
|
||||
},
|
||||
header: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "sm",
|
||||
align: "center",
|
||||
justify: null,
|
||||
},
|
||||
children: ["title", "subtitle"],
|
||||
},
|
||||
title: {
|
||||
type: "Heading",
|
||||
props: { text: "Simple, transparent pricing", level: "h1" },
|
||||
},
|
||||
subtitle: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
|
||||
variant: "muted",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
type: "Grid",
|
||||
props: { columns: 3, gap: "md" },
|
||||
children: ["free", "pro", "enterprise"],
|
||||
},
|
||||
free: {
|
||||
type: "Card",
|
||||
props: { title: "Free", description: "$0/month" },
|
||||
children: ["freeContent"],
|
||||
},
|
||||
freeContent: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "sm" },
|
||||
children: ["f1", "f2", "f3", "freeBtn"],
|
||||
},
|
||||
f1: {
|
||||
type: "Text",
|
||||
props: { text: "Up to 3 projects", variant: "body" },
|
||||
},
|
||||
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
|
||||
f3: {
|
||||
type: "Text",
|
||||
props: { text: "Community support", variant: "body" },
|
||||
},
|
||||
freeBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Get Started", variant: "secondary" },
|
||||
},
|
||||
pro: {
|
||||
type: "Card",
|
||||
props: { title: "Pro", description: "$19/month" },
|
||||
children: ["proContent"],
|
||||
},
|
||||
proContent: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "sm" },
|
||||
children: ["p1", "p2", "p3", "p4", "proBtn"],
|
||||
},
|
||||
p1: {
|
||||
type: "Text",
|
||||
props: { text: "Unlimited projects", variant: "body" },
|
||||
},
|
||||
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
|
||||
p3: {
|
||||
type: "Text",
|
||||
props: { text: "Priority support", variant: "body" },
|
||||
},
|
||||
p4: {
|
||||
type: "Text",
|
||||
props: { text: "Custom domains", variant: "body" },
|
||||
},
|
||||
proBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Upgrade to Pro", variant: "primary" },
|
||||
},
|
||||
enterprise: {
|
||||
type: "Card",
|
||||
props: { title: "Enterprise", description: "Custom pricing" },
|
||||
children: ["entContent"],
|
||||
},
|
||||
entContent: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "sm" },
|
||||
children: ["e1", "e2", "e3", "e4", "entBtn"],
|
||||
},
|
||||
e1: {
|
||||
type: "Text",
|
||||
props: { text: "Everything in Pro", variant: "body" },
|
||||
},
|
||||
e2: {
|
||||
type: "Text",
|
||||
props: { text: "Unlimited storage", variant: "body" },
|
||||
},
|
||||
e3: {
|
||||
type: "Text",
|
||||
props: { text: "Dedicated support", variant: "body" },
|
||||
},
|
||||
e4: {
|
||||
type: "Text",
|
||||
props: { text: "SLA guarantees", variant: "body" },
|
||||
},
|
||||
entBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Contact Sales", variant: "secondary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Status Dashboard",
|
||||
description: "System status with alerts, a table, and an accordion",
|
||||
spec: {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "lg" },
|
||||
children: [
|
||||
"heading",
|
||||
"alertOk",
|
||||
"alertWarn",
|
||||
"sep",
|
||||
"table",
|
||||
"sep2",
|
||||
"accordion",
|
||||
],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "System Status", level: "h1" },
|
||||
},
|
||||
alertOk: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "API",
|
||||
message: "All systems operational -- 99.98% uptime this month",
|
||||
type: "success",
|
||||
},
|
||||
},
|
||||
alertWarn: {
|
||||
type: "Alert",
|
||||
props: {
|
||||
title: "Database",
|
||||
message: "Elevated latency detected in us-east-1 region",
|
||||
type: "warning",
|
||||
},
|
||||
},
|
||||
sep: { type: "Separator", props: { orientation: null } },
|
||||
table: {
|
||||
type: "Table",
|
||||
props: {
|
||||
columns: ["Service", "Status", "Latency", "Uptime"],
|
||||
rows: [
|
||||
["API Gateway", "Operational", "12ms", "99.99%"],
|
||||
["Auth Service", "Operational", "8ms", "99.98%"],
|
||||
["Database", "Degraded", "145ms", "99.85%"],
|
||||
["CDN", "Operational", "3ms", "100%"],
|
||||
["Workers", "Operational", "22ms", "99.97%"],
|
||||
],
|
||||
caption: "Last updated 2 minutes ago",
|
||||
},
|
||||
},
|
||||
sep2: { type: "Separator", props: { orientation: null } },
|
||||
accordion: {
|
||||
type: "Accordion",
|
||||
props: {
|
||||
items: [
|
||||
{
|
||||
title: "What does 'Degraded' mean?",
|
||||
content:
|
||||
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
|
||||
},
|
||||
{
|
||||
title: "How is uptime calculated?",
|
||||
content:
|
||||
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
|
||||
},
|
||||
{
|
||||
title: "How do I subscribe to updates?",
|
||||
content:
|
||||
"You can subscribe to incident notifications via email or webhook on the Settings page.",
|
||||
},
|
||||
],
|
||||
type: "single",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,173 @@
|
||||
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().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
|
||||
centered: z.boolean().nullable().optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "A card container with optional title and description",
|
||||
},
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).optional(),
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
align: z
|
||||
.enum(["start", "center", "end", "stretch"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Flex container for laying out children horizontally or vertically",
|
||||
},
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.number().optional(),
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Grid layout container",
|
||||
},
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Heading text element",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["body", "muted", "bold"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Text paragraph element",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z
|
||||
.enum(["primary", "secondary", "outline", "danger"])
|
||||
.optional(),
|
||||
disabled: z.boolean().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Clickable button. Emits 'press' event.",
|
||||
},
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string().optional(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
checks: z.any().nullable().optional(),
|
||||
validateOn: z.string().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Text input field with optional label. Emits 'change' event.",
|
||||
},
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string().optional(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
checks: z.any().nullable().optional(),
|
||||
validateOn: z.string().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Dropdown select field. Emits 'change' event.",
|
||||
},
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Toggle switch. Emits 'change' event.",
|
||||
},
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "outline", "destructive"])
|
||||
.optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Small status badge label",
|
||||
},
|
||||
Separator: {
|
||||
props: z.object({
|
||||
orientation: z.enum(["horizontal", "vertical"]).nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Visual separator line",
|
||||
},
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Progress bar",
|
||||
},
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Alert / notification banner",
|
||||
},
|
||||
Table: {
|
||||
props: z.object({
|
||||
columns: z.array(z.string()),
|
||||
rows: z.array(z.array(z.string())),
|
||||
caption: z.string().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Data table with columns and rows",
|
||||
},
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
items: z.array(z.object({ title: z.string(), content: z.string() })),
|
||||
type: z.enum(["single", "multiple"]).optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Collapsible accordion sections",
|
||||
},
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string().optional(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
value: z.string().optional(),
|
||||
checks: z.any().nullable().optional(),
|
||||
validateOn: z.string().nullable().optional(),
|
||||
}),
|
||||
slots: [],
|
||||
description: "Radio button group. Emits 'change' event.",
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
// Cast needed: zod schema types don't align with the inferred catalog input
|
||||
// type from defineCatalog's generics when no actions are defined.
|
||||
} as any);
|
||||
@@ -0,0 +1,593 @@
|
||||
import { h, ref, defineComponent, type VNode, type PropType } from "vue";
|
||||
import {
|
||||
defineRegistry,
|
||||
useBoundProp,
|
||||
useFieldValidation,
|
||||
type BaseComponentProps,
|
||||
} from "@json-render/vue";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
type Ctx<P = Record<string, unknown>> = BaseComponentProps<P>;
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({
|
||||
props,
|
||||
children,
|
||||
}: Ctx<{
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | null;
|
||||
centered?: boolean | null;
|
||||
}>) => {
|
||||
const maxWidthClass =
|
||||
props.maxWidth === "sm"
|
||||
? "max-w-sm"
|
||||
: props.maxWidth === "md"
|
||||
? "max-w-md"
|
||||
: props.maxWidth === "lg"
|
||||
? "max-w-lg"
|
||||
: props.maxWidth === "xl"
|
||||
? "max-w-xl"
|
||||
: "";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"rounded-lg border bg-white shadow-sm p-6",
|
||||
maxWidthClass,
|
||||
props.centered ? "mx-auto" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
[
|
||||
props.title &&
|
||||
h("h3", { class: "text-lg font-semibold" }, props.title),
|
||||
props.description &&
|
||||
h("p", { class: "text-sm text-gray-500 mt-1" }, props.description),
|
||||
(props.title || props.description) &&
|
||||
h("div", { class: "mt-4" }, children),
|
||||
!(props.title || props.description) && children,
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Stack: ({
|
||||
props,
|
||||
children,
|
||||
}: Ctx<{
|
||||
direction?: "horizontal" | "vertical";
|
||||
gap?: "sm" | "md" | "lg";
|
||||
align?: "start" | "center" | "end" | "stretch" | null;
|
||||
justify?: "start" | "center" | "end" | "between" | "around" | null;
|
||||
}>) => {
|
||||
const gapClass =
|
||||
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
|
||||
const dirClass =
|
||||
props.direction === "horizontal" ? "flex-row" : "flex-col";
|
||||
const alignMap: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
between: "justify-between",
|
||||
around: "justify-around",
|
||||
};
|
||||
const alignClass = props.align ? (alignMap[props.align] ?? "") : "";
|
||||
const justifyClass = props.justify
|
||||
? (justifyMap[props.justify] ?? "")
|
||||
: "";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: ["flex", dirClass, gapClass, alignClass, justifyClass]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
children,
|
||||
);
|
||||
},
|
||||
|
||||
Grid: ({
|
||||
props,
|
||||
children,
|
||||
}: Ctx<{ columns?: number; gap?: "sm" | "md" | "lg" }>) => {
|
||||
const cols = props.columns ?? 3;
|
||||
const gapClass =
|
||||
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
|
||||
const colClass =
|
||||
cols === 1
|
||||
? "grid-cols-1"
|
||||
: cols === 2
|
||||
? "grid-cols-2"
|
||||
: cols === 3
|
||||
? "grid-cols-3"
|
||||
: cols === 4
|
||||
? "grid-cols-4"
|
||||
: `grid-cols-${cols}`;
|
||||
return h("div", { class: `grid ${colClass} ${gapClass}` }, children);
|
||||
},
|
||||
|
||||
Heading: ({
|
||||
props,
|
||||
}: Ctx<{ text: string; level?: "h1" | "h2" | "h3" | "h4" }>) => {
|
||||
const tag = props.level ?? "h2";
|
||||
const sizeClass =
|
||||
tag === "h1"
|
||||
? "text-3xl font-bold"
|
||||
: tag === "h2"
|
||||
? "text-2xl font-semibold"
|
||||
: tag === "h3"
|
||||
? "text-xl font-semibold"
|
||||
: "text-lg font-medium";
|
||||
return h(tag, { class: sizeClass }, props.text);
|
||||
},
|
||||
|
||||
Text: ({
|
||||
props,
|
||||
}: Ctx<{ text: string; variant?: "body" | "muted" | "bold" }>) => {
|
||||
const variantClass =
|
||||
props.variant === "muted"
|
||||
? "text-gray-500"
|
||||
: props.variant === "bold"
|
||||
? "font-semibold"
|
||||
: "";
|
||||
return h("p", { class: variantClass }, props.text);
|
||||
},
|
||||
|
||||
Button: ({
|
||||
props,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label: string;
|
||||
variant?: "primary" | "secondary" | "outline" | "danger";
|
||||
disabled?: boolean | null;
|
||||
}>) => {
|
||||
const baseClass =
|
||||
"inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
|
||||
const variantClass =
|
||||
props.variant === "primary"
|
||||
? "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500"
|
||||
: props.variant === "danger"
|
||||
? "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
|
||||
: props.variant === "outline"
|
||||
? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500";
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: `${baseClass} ${variantClass}`,
|
||||
disabled: props.disabled ?? false,
|
||||
onClick: () => emit("press"),
|
||||
},
|
||||
props.label,
|
||||
);
|
||||
},
|
||||
|
||||
Input: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label?: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
checks?: unknown;
|
||||
validateOn?: string | null;
|
||||
}>) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value,
|
||||
);
|
||||
const validation = props.checks
|
||||
? useFieldValidation(`/${props.name}`, {
|
||||
checks: props.checks as any[],
|
||||
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h(
|
||||
"label",
|
||||
{ class: "text-sm font-medium text-gray-700" },
|
||||
props.label,
|
||||
),
|
||||
h("input", {
|
||||
type: props.type ?? "text",
|
||||
placeholder: props.placeholder,
|
||||
value: value ?? "",
|
||||
class:
|
||||
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
|
||||
onInput: (e: Event) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
setValue(val);
|
||||
emit("change");
|
||||
if (validation && props.validateOn === "change") {
|
||||
validation.validate();
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
if (validation && props.validateOn === "blur") {
|
||||
validation.touch();
|
||||
validation.validate();
|
||||
}
|
||||
},
|
||||
}),
|
||||
validation &&
|
||||
validation.errors.value.length > 0 &&
|
||||
h(
|
||||
"p",
|
||||
{ class: "text-xs text-red-500" },
|
||||
validation.errors.value[0],
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label?: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
checks?: unknown;
|
||||
validateOn?: string | null;
|
||||
}>) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value,
|
||||
);
|
||||
const options = props.options ?? [];
|
||||
const validation = props.checks
|
||||
? useFieldValidation(`/${props.name}`, {
|
||||
checks: props.checks as any[],
|
||||
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h(
|
||||
"label",
|
||||
{ class: "text-sm font-medium text-gray-700" },
|
||||
props.label,
|
||||
),
|
||||
h(
|
||||
"select",
|
||||
{
|
||||
value: value ?? "",
|
||||
class:
|
||||
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
|
||||
onChange: (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
setValue(val);
|
||||
emit("change");
|
||||
if (validation && props.validateOn === "change") {
|
||||
validation.validate();
|
||||
}
|
||||
},
|
||||
},
|
||||
[
|
||||
props.placeholder &&
|
||||
h("option", { value: "", disabled: true }, props.placeholder),
|
||||
...options.map((opt: string) =>
|
||||
h("option", { value: opt, key: opt }, opt),
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
),
|
||||
validation &&
|
||||
validation.errors.value.length > 0 &&
|
||||
h(
|
||||
"p",
|
||||
{ class: "text-xs text-red-500" },
|
||||
validation.errors.value[0],
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{ label: string; name: string; checked?: boolean }>) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isOn = checked ?? false;
|
||||
return h("label", { class: "flex items-center gap-3 cursor-pointer" }, [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
role: "switch",
|
||||
"aria-checked": isOn,
|
||||
class: [
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isOn ? "bg-blue-600" : "bg-gray-200",
|
||||
].join(" "),
|
||||
onClick: () => {
|
||||
setChecked(!isOn);
|
||||
emit("change");
|
||||
},
|
||||
},
|
||||
h("span", {
|
||||
class: [
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
isOn ? "translate-x-6" : "translate-x-1",
|
||||
].join(" "),
|
||||
}),
|
||||
),
|
||||
h("span", { class: "text-sm" }, props.label),
|
||||
]);
|
||||
},
|
||||
|
||||
Badge: ({
|
||||
props,
|
||||
}: Ctx<{
|
||||
text: string;
|
||||
variant?: "default" | "secondary" | "outline" | "destructive";
|
||||
}>) => {
|
||||
const variantClass =
|
||||
props.variant === "secondary"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: props.variant === "outline"
|
||||
? "border border-gray-300 text-gray-700"
|
||||
: props.variant === "destructive"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-blue-100 text-blue-800";
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
class: `inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantClass}`,
|
||||
},
|
||||
props.text,
|
||||
);
|
||||
},
|
||||
|
||||
Separator: () => {
|
||||
return h("hr", { class: "border-gray-200" });
|
||||
},
|
||||
|
||||
Progress: ({
|
||||
props,
|
||||
}: Ctx<{ value: number; max?: number; label?: string }>) => {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round((props.value / (props.max ?? 100)) * 100),
|
||||
);
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h("div", { class: "flex justify-between text-sm" }, [
|
||||
h("span", { class: "text-gray-600" }, props.label),
|
||||
h("span", { class: "font-medium" }, `${pct}%`),
|
||||
]),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: "h-2 w-full rounded-full bg-gray-200 overflow-hidden",
|
||||
},
|
||||
h("div", {
|
||||
class: "h-full rounded-full bg-blue-600 transition-all",
|
||||
style: { width: `${pct}%` },
|
||||
}),
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
Alert: ({
|
||||
props,
|
||||
}: Ctx<{
|
||||
title: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
}>) => {
|
||||
const typeClasses: Record<string, string> = {
|
||||
success: "bg-green-50 border-green-200 text-green-800",
|
||||
warning: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
info: "bg-blue-50 border-blue-200 text-blue-800",
|
||||
};
|
||||
const cls = typeClasses[props.type ?? "info"] ?? typeClasses.info;
|
||||
return h("div", { class: `rounded-md border p-4 ${cls}` }, [
|
||||
h("p", { class: "text-sm font-medium" }, props.title),
|
||||
h("p", { class: "text-sm mt-1 opacity-80" }, props.message),
|
||||
]);
|
||||
},
|
||||
|
||||
Table: ({
|
||||
props,
|
||||
}: Ctx<{
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
caption?: string;
|
||||
}>) => {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "overflow-x-auto" },
|
||||
[
|
||||
h("table", { class: "min-w-full text-sm" }, [
|
||||
h(
|
||||
"thead",
|
||||
null,
|
||||
h(
|
||||
"tr",
|
||||
{ class: "border-b" },
|
||||
props.columns.map((col: string) =>
|
||||
h(
|
||||
"th",
|
||||
{
|
||||
key: col,
|
||||
class: "px-4 py-2 text-left font-medium text-gray-500",
|
||||
},
|
||||
col,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"tbody",
|
||||
null,
|
||||
props.rows.map((row: string[], i: number) =>
|
||||
h(
|
||||
"tr",
|
||||
{ key: i, class: "border-b last:border-0" },
|
||||
row.map((cell: string, j: number) =>
|
||||
h("td", { key: j, class: "px-4 py-2 text-gray-700" }, cell),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
props.caption &&
|
||||
h("p", { class: "text-xs text-gray-400 mt-2 px-4" }, props.caption),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
|
||||
// Uses defineComponent instead of a plain render function because it
|
||||
// needs local reactive state (openItems ref) to track expanded items.
|
||||
Accordion: defineComponent({
|
||||
props: {
|
||||
props: {
|
||||
type: Object as PropType<{
|
||||
items: Array<{ title: string; content: string }>;
|
||||
type?: "single" | "multiple";
|
||||
}>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(compProps) {
|
||||
const openItems = ref<Set<number>>(new Set());
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
const next = new Set(openItems.value);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
if (compProps.props.type === "single") next.clear();
|
||||
next.add(idx);
|
||||
}
|
||||
openItems.value = next;
|
||||
};
|
||||
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "divide-y border rounded-md" },
|
||||
compProps.props.items.map(
|
||||
(item: { title: string; content: string }, i: number) =>
|
||||
h("div", { key: i }, [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class:
|
||||
"flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-left hover:bg-gray-50 transition-colors",
|
||||
onClick: () => toggle(i),
|
||||
},
|
||||
[
|
||||
h("span", null, item.title),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: [
|
||||
"transform transition-transform text-gray-400",
|
||||
openItems.value.has(i) ? "rotate-180" : "",
|
||||
].join(" "),
|
||||
},
|
||||
"\u25BE",
|
||||
),
|
||||
],
|
||||
),
|
||||
openItems.value.has(i) &&
|
||||
h(
|
||||
"div",
|
||||
{ class: "px-4 pb-3 text-sm text-gray-600" },
|
||||
item.content,
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
}) as any,
|
||||
|
||||
Radio: ({
|
||||
props,
|
||||
bindings,
|
||||
emit,
|
||||
}: Ctx<{
|
||||
label?: string;
|
||||
name: string;
|
||||
options: string[];
|
||||
value?: string;
|
||||
}>) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value,
|
||||
);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "flex flex-col gap-1.5" },
|
||||
[
|
||||
props.label &&
|
||||
h(
|
||||
"label",
|
||||
{ class: "text-sm font-medium text-gray-700" },
|
||||
props.label,
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex gap-4" },
|
||||
(props.options ?? []).map((opt: string) =>
|
||||
h(
|
||||
"label",
|
||||
{
|
||||
key: opt,
|
||||
class: "flex items-center gap-2 cursor-pointer",
|
||||
},
|
||||
[
|
||||
h("input", {
|
||||
type: "radio",
|
||||
name: props.name,
|
||||
value: opt,
|
||||
checked: value === opt,
|
||||
class: "accent-blue-600",
|
||||
onChange: () => {
|
||||
setValue(opt);
|
||||
emit("change");
|
||||
},
|
||||
}),
|
||||
h("span", { class: "text-sm" }, opt),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
].filter(Boolean) as VNode[],
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2025-11-01",
|
||||
css: ["~/assets/css/main.css"],
|
||||
vite: {
|
||||
// Tailwind's Vite plugin types don't align with Nuxt's plugin types
|
||||
plugins: [tailwindcss() as any],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "example-nuxt",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless nuxt-demo.json-render nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"check-types": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/vue": "workspace:*",
|
||||
"nuxt": "^4.3.1",
|
||||
"vue": "^3.5.29",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { JSONUIProvider, Renderer } from "@json-render/vue";
|
||||
import { registry } from "~/lib/render/registry";
|
||||
import { examples } from "~/lib/examples";
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const selected = computed(() => examples[selectedIndex.value]!);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-gray-50">
|
||||
<!-- Example selector -->
|
||||
<nav class="flex gap-1 p-3 overflow-x-auto border-b bg-white shrink-0">
|
||||
<button
|
||||
v-for="(ex, i) in examples"
|
||||
:key="ex.name"
|
||||
class="px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors"
|
||||
:class="
|
||||
i === selectedIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'hover:bg-gray-100 text-gray-700'
|
||||
"
|
||||
@click="selectedIndex = i"
|
||||
>
|
||||
{{ ex.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Render area -->
|
||||
<div class="flex-1 flex items-start justify-center overflow-auto p-6">
|
||||
<div
|
||||
class="bg-white border rounded-lg shadow-sm w-full max-w-[960px] p-6"
|
||||
>
|
||||
<p class="text-xs text-gray-400 mb-4">
|
||||
{{ selected.description }}
|
||||
</p>
|
||||
<JSONUIProvider
|
||||
:key="selectedIndex"
|
||||
:registry="registry"
|
||||
:initial-state="selected.spec.state ?? {}"
|
||||
>
|
||||
<Renderer :spec="selected.spec" :registry="registry" />
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-native
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react-native@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-pdf
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react-pdf@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# example-remotion
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/remotion@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-demo
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-fullpage-demo
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
|
||||
+3
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --concurrency 15",
|
||||
"dev": "turbo run dev --concurrency 20",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"type-check": "turbo run check-types",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@changesets/cli": "2.29.8",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/react": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"turbo": "^2.7.4",
|
||||
"typescript": "5.9.2",
|
||||
"vue": "^3.5.29",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @json-render/core
|
||||
|
||||
## 0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -72,7 +72,11 @@ export {
|
||||
} from "./visibility";
|
||||
|
||||
// Prop Expressions
|
||||
export type { PropExpression, PropResolutionContext } from "./props";
|
||||
export type {
|
||||
PropExpression,
|
||||
PropResolutionContext,
|
||||
ComputedFunction,
|
||||
} from "./props";
|
||||
|
||||
export {
|
||||
resolvePropValue,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
resolvePropValue,
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
_resetWarnedComputedFns,
|
||||
_resetWarnedTemplatePaths,
|
||||
} from "./props";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
|
||||
expect(resolveActionParam(null, ctx)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// $computed expressions
|
||||
// =============================================================================
|
||||
|
||||
describe("$computed expressions", () => {
|
||||
it("calls a registered function with resolved args", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
|
||||
functions: {
|
||||
fullName: (args) => `${args.first} ${args.last}`,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolvePropValue(
|
||||
{
|
||||
$computed: "fullName",
|
||||
args: {
|
||||
first: { $state: "/form/firstName" },
|
||||
last: { $state: "/form/lastName" },
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("calls function with no args", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: {},
|
||||
functions: {
|
||||
timestamp: () => 1234567890,
|
||||
},
|
||||
};
|
||||
expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown function", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: {},
|
||||
functions: {},
|
||||
};
|
||||
expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no functions in context", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {} };
|
||||
expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deduplicates warnings for the same unknown function", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
|
||||
resolvePropValue({ $computed: "dedupTest" }, ctx);
|
||||
resolvePropValue({ $computed: "dedupTest" }, ctx);
|
||||
const calls = warnSpy.mock.calls.filter((c) =>
|
||||
String(c[0]).includes("dedupTest"),
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resolves nested expressions in args", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { active: true, values: { a: 10, b: 20 } },
|
||||
functions: {
|
||||
conditionalSum: (args) => {
|
||||
if (args.enabled) return (args.x as number) + (args.y as number);
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
resolvePropValue(
|
||||
{
|
||||
$computed: "conditionalSum",
|
||||
args: {
|
||||
enabled: { $state: "/active" },
|
||||
x: { $state: "/values/a" },
|
||||
y: { $state: "/values/b" },
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// $template expressions
|
||||
// =============================================================================
|
||||
|
||||
describe("$template expressions", () => {
|
||||
it("interpolates state values into a string", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { user: { name: "Alice" }, count: 3 },
|
||||
};
|
||||
expect(
|
||||
resolvePropValue(
|
||||
{ $template: "Hello, ${/user/name}! You have ${/count} messages." },
|
||||
ctx,
|
||||
),
|
||||
).toBe("Hello, Alice! You have 3 messages.");
|
||||
});
|
||||
|
||||
it("replaces missing paths with empty string", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {} };
|
||||
expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
|
||||
});
|
||||
|
||||
it("handles template with no interpolations", () => {
|
||||
const ctx: PropResolutionContext = { stateModel: {} };
|
||||
expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
|
||||
"No variables here",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles multiple references to the same path", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { x: "A" },
|
||||
};
|
||||
expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
|
||||
"A and A",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts non-string values to strings", () => {
|
||||
const ctx: PropResolutionContext = {
|
||||
stateModel: { num: 42, bool: true },
|
||||
};
|
||||
expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
|
||||
"42 is true",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when path does not start with /", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
expect(result).toBe("Hi Bob!");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('$template path "name"'),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
});
|
||||
|
||||
it("deduplicates warnings for the same $template path", () => {
|
||||
_resetWarnedTemplatePaths();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
|
||||
const calls = warnSpy.mock.calls.filter((c) =>
|
||||
String(c[0]).includes('$template path "name"'),
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
warnSpy.mockRestore();
|
||||
_resetWarnedTemplatePaths();
|
||||
});
|
||||
});
|
||||
|
||||
+100
-1
@@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
|
||||
* repeat item — resolves via `repeatBasePath + path` and exposes the
|
||||
* absolute state path for write-back.
|
||||
* - `{ $cond, $then, $else }` conditionally picks a value
|
||||
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
|
||||
* registered function with resolved args and returns the result
|
||||
* - `{ $template: string }` interpolates `${/path}` references in the
|
||||
* string with values from the state model
|
||||
* - Any other value is a literal (passthrough)
|
||||
*/
|
||||
export type PropExpression<T = unknown> =
|
||||
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
|
||||
$cond: VisibilityCondition;
|
||||
$then: PropExpression<T>;
|
||||
$else: PropExpression<T>;
|
||||
};
|
||||
}
|
||||
| { $computed: string; args?: Record<string, unknown> }
|
||||
| { $template: string };
|
||||
|
||||
/**
|
||||
* Function signature for `$computed` expressions.
|
||||
* Receives a record of resolved argument values and returns a computed result.
|
||||
*/
|
||||
export type ComputedFunction = (args: Record<string, unknown>) => unknown;
|
||||
|
||||
/**
|
||||
* Context for resolving prop expressions.
|
||||
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
|
||||
export interface PropResolutionContext extends VisibilityContext {
|
||||
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
|
||||
repeatBasePath?: string;
|
||||
/** Named functions available for `$computed` expressions. */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -110,6 +124,47 @@ function isCondExpression(
|
||||
);
|
||||
}
|
||||
|
||||
function isComputedExpression(
|
||||
value: unknown,
|
||||
): value is { $computed: string; args?: Record<string, unknown> } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"$computed" in value &&
|
||||
typeof (value as Record<string, unknown>).$computed === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isTemplateExpression(value: unknown): value is { $template: string } {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"$template" in value &&
|
||||
typeof (value as Record<string, unknown>).$template === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// Module-level set to avoid spamming console.warn on every render for the same
|
||||
// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
|
||||
// new names are no longer deduplicated (warnings still fire) but the set stops
|
||||
// growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
|
||||
const WARNED_COMPUTED_MAX = 100;
|
||||
const warnedComputedFns = new Set<string>();
|
||||
|
||||
/** @internal Test-only: clear the deduplication set for $computed warnings. */
|
||||
export function _resetWarnedComputedFns(): void {
|
||||
warnedComputedFns.clear();
|
||||
}
|
||||
|
||||
// Same deduplication pattern for $template paths that don't start with "/".
|
||||
const WARNED_TEMPLATE_MAX = 100;
|
||||
const warnedTemplatePaths = new Set<string>();
|
||||
|
||||
/** @internal Test-only: clear the deduplication set for $template warnings. */
|
||||
export function _resetWarnedTemplatePaths(): void {
|
||||
warnedTemplatePaths.clear();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Prop Expression Resolution
|
||||
// =============================================================================
|
||||
@@ -193,6 +248,50 @@ export function resolvePropValue(
|
||||
return resolvePropValue(result ? value.$then : value.$else, ctx);
|
||||
}
|
||||
|
||||
// $computed: call a registered function with resolved args
|
||||
if (isComputedExpression(value)) {
|
||||
const fn = ctx.functions?.[value.$computed];
|
||||
if (!fn) {
|
||||
if (!warnedComputedFns.has(value.$computed)) {
|
||||
if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
|
||||
warnedComputedFns.add(value.$computed);
|
||||
}
|
||||
console.warn(`Unknown $computed function: "${value.$computed}"`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const resolvedArgs: Record<string, unknown> = {};
|
||||
if (value.args) {
|
||||
for (const [key, arg] of Object.entries(value.args)) {
|
||||
resolvedArgs[key] = resolvePropValue(arg, ctx);
|
||||
}
|
||||
}
|
||||
return fn(resolvedArgs);
|
||||
}
|
||||
|
||||
// $template: interpolate ${/path} references with state values
|
||||
if (isTemplateExpression(value)) {
|
||||
return value.$template.replace(
|
||||
/\$\{([^}]+)\}/g,
|
||||
(_match, rawPath: string) => {
|
||||
let path = rawPath;
|
||||
if (!path.startsWith("/")) {
|
||||
if (!warnedTemplatePaths.has(path)) {
|
||||
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
|
||||
warnedTemplatePaths.add(path);
|
||||
}
|
||||
console.warn(
|
||||
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
|
||||
);
|
||||
}
|
||||
path = "/" + path;
|
||||
}
|
||||
const resolved = getByPath(ctx.stateModel, path);
|
||||
return resolved != null ? String(resolved) : "";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Arrays: resolve each element
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolvePropValue(item, ctx));
|
||||
|
||||
@@ -900,6 +900,31 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
"Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
|
||||
);
|
||||
lines.push(
|
||||
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// $computed section — only emit when catalog defines functions
|
||||
const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
|
||||
if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
|
||||
lines.push(
|
||||
'5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
|
||||
);
|
||||
lines.push(
|
||||
' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
|
||||
);
|
||||
lines.push(" Available functions:");
|
||||
for (const name of Object.keys(
|
||||
catalogFunctions as Record<string, unknown>,
|
||||
)) {
|
||||
lines.push(` - ${name}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Validation section — only emit when at least one component has a `checks` prop
|
||||
const hasChecksComponents = allComponents
|
||||
@@ -930,7 +955,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
lines.push(" - numeric — value must be a number");
|
||||
lines.push(" - url — valid URL format");
|
||||
lines.push(
|
||||
' - matches — must equal another field (args: { "other": "value" })',
|
||||
' - matches — must equal another field (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push(
|
||||
' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Example:");
|
||||
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// State watchers section — only emit when actions are available (watchers
|
||||
// trigger actions, so the section is irrelevant without them).
|
||||
if (hasCustomActions || hasBuiltInActions) {
|
||||
lines.push("STATE WATCHERS:");
|
||||
lines.push(
|
||||
"Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
|
||||
);
|
||||
lines.push(
|
||||
"Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Example (cascading select — country changes trigger city loading):",
|
||||
);
|
||||
lines.push(
|
||||
` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
|
||||
);
|
||||
lines.push(
|
||||
"IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Rules
|
||||
lines.push("RULES:");
|
||||
const baseRules =
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Spec } from "./types";
|
||||
import { validateSpec, autoFixSpec } from "./spec-validator";
|
||||
|
||||
// =============================================================================
|
||||
// validateSpec
|
||||
// =============================================================================
|
||||
|
||||
describe("validateSpec", () => {
|
||||
it("returns valid for a correct spec", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["child1"] },
|
||||
child1: { type: "Text", props: { text: "hello" }, children: [] },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects missing root", () => {
|
||||
const spec = {
|
||||
root: "",
|
||||
elements: { a: { type: "T", props: {}, children: [] } },
|
||||
} as Spec;
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects root_not_found", () => {
|
||||
const spec: Spec = {
|
||||
root: "missing",
|
||||
elements: { a: { type: "T", props: {}, children: [] } },
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects empty spec", () => {
|
||||
const spec: Spec = { root: "r", elements: {} };
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects missing_child", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: ["nonexistent"] },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects visible_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { visible: { $state: "/show" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects on_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Button",
|
||||
props: { on: { press: { action: "doSomething" } } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects repeat_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { repeat: { statePath: "/items" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects watch_in_props", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Select",
|
||||
props: {
|
||||
watch: {
|
||||
"/form/country": { action: "loadCities" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec);
|
||||
expect(result.valid).toBe(false);
|
||||
const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
|
||||
expect(watchIssue).toBeDefined();
|
||||
expect(watchIssue!.elementKey).toBe("root");
|
||||
});
|
||||
|
||||
it("detects orphaned elements when checkOrphans is true", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Stack", props: {}, children: [] },
|
||||
orphan: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
};
|
||||
const result = validateSpec(spec, { checkOrphans: true });
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// autoFixSpec
|
||||
// =============================================================================
|
||||
|
||||
describe("autoFixSpec", () => {
|
||||
it("moves visible from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { text: "hi", visible: { $state: "/show" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).visible,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
|
||||
expect(fixes.some((f) => f.includes("visible"))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves on from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Button",
|
||||
props: { label: "OK", on: { press: { action: "submit" } } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).on,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
|
||||
expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves repeat from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { repeat: { statePath: "/items" } },
|
||||
children: ["child"],
|
||||
},
|
||||
child: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).repeat,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
|
||||
expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("moves watch from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
watch: {
|
||||
"/form/country": { action: "loadCities" },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(
|
||||
(fixed.elements.root.props as Record<string, unknown>).watch,
|
||||
).toBeUndefined();
|
||||
expect(fixed.elements.root.watch).toEqual({
|
||||
"/form/country": { action: "loadCities" },
|
||||
});
|
||||
expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no fixes for a correct spec", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical" },
|
||||
children: [],
|
||||
watch: { "/x": { action: "y" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
const { fixes } = autoFixSpec(spec);
|
||||
expect(fixes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ export interface SpecIssue {
|
||||
| "orphaned_element"
|
||||
| "empty_spec"
|
||||
| "on_in_props"
|
||||
| "repeat_in_props";
|
||||
| "repeat_in_props"
|
||||
| "watch_in_props";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +152,16 @@ export function validateSpec(
|
||||
code: "repeat_in_props",
|
||||
});
|
||||
}
|
||||
|
||||
// 3e. `watch` inside props (should be a top-level field)
|
||||
if (props && "watch" in props && props.watch !== undefined) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
|
||||
elementKey: key,
|
||||
code: "watch_in_props",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Orphaned elements (optional)
|
||||
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
|
||||
fixes.push(`Moved "repeat" from props to element level on "${key}".`);
|
||||
}
|
||||
|
||||
currentProps = fixed.props as Record<string, unknown> | undefined;
|
||||
if (
|
||||
currentProps &&
|
||||
"watch" in currentProps &&
|
||||
currentProps.watch !== undefined
|
||||
) {
|
||||
const { watch, ...restProps } = currentProps;
|
||||
fixed = {
|
||||
...fixed,
|
||||
props: restProps,
|
||||
watch: watch as UIElement["watch"],
|
||||
};
|
||||
fixes.push(`Moved "watch" from props to element level on "${key}".`);
|
||||
}
|
||||
|
||||
fixedElements[key] = fixed;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface UIElement<
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>;
|
||||
/** Repeat children once per item in a state array */
|
||||
repeat?: { statePath: string; key?: string };
|
||||
/**
|
||||
* State watchers — maps JSON Pointer state paths to action bindings.
|
||||
* When the value at a watched path changes, the bound actions fire.
|
||||
* Useful for cascading dependencies (e.g. country → city option loading).
|
||||
*/
|
||||
watch?: Record<string, ActionBinding | ActionBinding[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("equalTo", () => {
|
||||
it("passes when values are equal", () => {
|
||||
expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when values differ", () => {
|
||||
expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lessThan", () => {
|
||||
it("passes when value is less than other", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when value equals other", () => {
|
||||
expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when value is greater than other", () => {
|
||||
expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces numeric string vs number", () => {
|
||||
expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it("fails coercion when non-numeric string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes for string comparison (ISO dates)", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.lessThan("2026-01-01", {
|
||||
other: "2026-06-15",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails for equal strings", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.lessThan("2026-01-01", {
|
||||
other: "2026-01-01",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is null", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when value is null", () => {
|
||||
expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is undefined", () => {
|
||||
expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThan", () => {
|
||||
it("passes when value is greater than other", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when value equals other", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when value is less than other", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces numeric string vs number", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails coercion when non-numeric string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes for string comparison (ISO dates)", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.greaterThan("2026-06-15", {
|
||||
other: "2026-01-01",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails for lesser strings", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.greaterThan("2026-01-01", {
|
||||
other: "2026-06-15",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when value is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is empty string vs non-empty string", () => {
|
||||
expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when other is null", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when value is undefined", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.greaterThan(undefined, { other: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is null", () => {
|
||||
expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredIf", () => {
|
||||
it("passes when condition is falsy (field not required)", () => {
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf("", { field: undefined }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when condition is truthy and value is empty", () => {
|
||||
expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf(null, { field: "yes" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf(undefined, { field: 1 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes when condition is truthy and value is present", () => {
|
||||
expect(
|
||||
builtInValidationFunctions.requiredIf("hello", { field: true }),
|
||||
).toBe(true);
|
||||
expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runValidationCheck", () => {
|
||||
@@ -391,6 +611,22 @@ describe("check helper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric", () => {
|
||||
it("creates numeric check with default message", () => {
|
||||
const c = check.numeric();
|
||||
|
||||
expect(c.type).toBe("numeric");
|
||||
expect(c.message).toBe("Must be a number");
|
||||
});
|
||||
|
||||
it("creates numeric check with custom message", () => {
|
||||
const c = check.numeric("Numbers only");
|
||||
|
||||
expect(c.type).toBe("numeric");
|
||||
expect(c.message).toBe("Numbers only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matches", () => {
|
||||
it("creates matches check with path reference", () => {
|
||||
const c = check.matches("/password", "Passwords must match");
|
||||
@@ -400,4 +636,109 @@ describe("check helper", () => {
|
||||
expect(c.message).toBe("Passwords must match");
|
||||
});
|
||||
});
|
||||
|
||||
describe("equalTo", () => {
|
||||
it("creates equalTo check with path reference", () => {
|
||||
const c = check.equalTo("/email", "Emails must match");
|
||||
|
||||
expect(c.type).toBe("equalTo");
|
||||
expect(c.args).toEqual({ other: { $state: "/email" } });
|
||||
expect(c.message).toBe("Emails must match");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lessThan", () => {
|
||||
it("creates lessThan check with path reference", () => {
|
||||
const c = check.lessThan("/maxValue", "Must be less");
|
||||
|
||||
expect(c.type).toBe("lessThan");
|
||||
expect(c.args).toEqual({ other: { $state: "/maxValue" } });
|
||||
expect(c.message).toBe("Must be less");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greaterThan", () => {
|
||||
it("creates greaterThan check with path reference", () => {
|
||||
const c = check.greaterThan("/minValue");
|
||||
|
||||
expect(c.type).toBe("greaterThan");
|
||||
expect(c.args).toEqual({ other: { $state: "/minValue" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiredIf", () => {
|
||||
it("creates requiredIf check with path reference", () => {
|
||||
const c = check.requiredIf("/toggle", "Required when toggle is on");
|
||||
|
||||
expect(c.type).toBe("requiredIf");
|
||||
expect(c.args).toEqual({ field: { $state: "/toggle" } });
|
||||
expect(c.message).toBe("Required when toggle is on");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Deep arg resolution in runValidationCheck
|
||||
// =============================================================================
|
||||
|
||||
describe("deep arg resolution", () => {
|
||||
it("resolves nested $state refs in validation args", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "matches",
|
||||
args: { other: { $state: "/form/password" } },
|
||||
message: "Passwords must match",
|
||||
},
|
||||
{
|
||||
value: "secret123",
|
||||
stateModel: { form: { password: "secret123" } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves $state in cross-field lessThan check", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "lessThan",
|
||||
args: { other: { $state: "/form/maxPrice" } },
|
||||
message: "Must be less than max price",
|
||||
},
|
||||
{
|
||||
value: 50,
|
||||
stateModel: { form: { maxPrice: 100 } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves $state in requiredIf check", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/enableEmail" } },
|
||||
message: "Email is required",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
stateModel: { form: { enableEmail: true } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("passes requiredIf when condition is false", () => {
|
||||
const result = runValidationCheck(
|
||||
{
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: "/form/enableEmail" } },
|
||||
message: "Email is required",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
stateModel: { form: { enableEmail: false } },
|
||||
},
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import type { DynamicValue, StateModel, VisibilityCondition } from "./types";
|
||||
import { DynamicValueSchema, resolveDynamicValue } from "./types";
|
||||
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
|
||||
import { resolvePropValue } from "./props";
|
||||
|
||||
/**
|
||||
* Validation check definition
|
||||
@@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const matchesImpl: ValidationFunction = (
|
||||
value: unknown,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
const other = args?.other;
|
||||
return value === other;
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in validation functions
|
||||
*/
|
||||
@@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
|
||||
/**
|
||||
* Check if value matches another field
|
||||
*/
|
||||
matches: (value: unknown, args?: Record<string, unknown>) => {
|
||||
matches: matchesImpl,
|
||||
|
||||
/**
|
||||
* Alias for matches with a more descriptive name for cross-field equality
|
||||
*/
|
||||
equalTo: matchesImpl,
|
||||
|
||||
/**
|
||||
* Check if value is less than another field's value.
|
||||
* Supports numbers, strings (useful for ISO date comparison), and
|
||||
* cross-type numeric coercion (e.g. string "3" vs number 5).
|
||||
*/
|
||||
lessThan: (value: unknown, args?: Record<string, unknown>) => {
|
||||
const other = args?.other;
|
||||
return value === other;
|
||||
if (value == null || other == null || value === "" || other === "")
|
||||
return false;
|
||||
if (typeof value === "number" && typeof other === "number")
|
||||
return value < other;
|
||||
if (typeof value === "string" && typeof other === "string")
|
||||
return value < other;
|
||||
const numVal = Number(value);
|
||||
const numOther = Number(other);
|
||||
if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if value is greater than another field's value.
|
||||
* Supports numbers, strings (useful for ISO date comparison), and
|
||||
* cross-type numeric coercion (e.g. string "7" vs number 5).
|
||||
*/
|
||||
greaterThan: (value: unknown, args?: Record<string, unknown>) => {
|
||||
const other = args?.other;
|
||||
if (value == null || other == null || value === "" || other === "")
|
||||
return false;
|
||||
if (typeof value === "number" && typeof other === "number")
|
||||
return value > other;
|
||||
if (typeof value === "string" && typeof other === "string")
|
||||
return value > other;
|
||||
const numVal = Number(value);
|
||||
const numOther = Number(other);
|
||||
if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Required only when a condition is met.
|
||||
* Uses JS truthiness: 0, false, "", null, and undefined are all
|
||||
* treated as "condition not met" (field not required), matching
|
||||
* the visibility system's bare-condition semantics.
|
||||
*/
|
||||
requiredIf: (value: unknown, args?: Record<string, unknown>) => {
|
||||
const condition = args?.field;
|
||||
if (!condition) return true;
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,11 +273,12 @@ export function runValidationCheck(
|
||||
): ValidationCheckResult {
|
||||
const { value, stateModel, customFunctions } = ctx;
|
||||
|
||||
// Resolve args
|
||||
// Resolve args using resolvePropValue so nested $state refs (and any other
|
||||
// prop expressions) are handled consistently with the rest of the system.
|
||||
const resolvedArgs: Record<string, unknown> = {};
|
||||
if (check.args) {
|
||||
for (const [key, argValue] of Object.entries(check.args)) {
|
||||
resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
|
||||
resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +391,11 @@ export const check = {
|
||||
message,
|
||||
}),
|
||||
|
||||
numeric: (message = "Must be a number"): ValidationCheck => ({
|
||||
type: "numeric",
|
||||
message,
|
||||
}),
|
||||
|
||||
matches: (
|
||||
otherPath: string,
|
||||
message = "Fields must match",
|
||||
@@ -334,4 +404,34 @@ export const check = {
|
||||
args: { other: { $state: otherPath } },
|
||||
message,
|
||||
}),
|
||||
|
||||
equalTo: (
|
||||
otherPath: string,
|
||||
message = "Fields must match",
|
||||
): ValidationCheck => ({
|
||||
type: "equalTo",
|
||||
args: { other: { $state: otherPath } },
|
||||
message,
|
||||
}),
|
||||
|
||||
lessThan: (otherPath: string, message?: string): ValidationCheck => ({
|
||||
type: "lessThan",
|
||||
args: { other: { $state: otherPath } },
|
||||
message: message ?? "Must be less than the compared field",
|
||||
}),
|
||||
|
||||
greaterThan: (otherPath: string, message?: string): ValidationCheck => ({
|
||||
type: "greaterThan",
|
||||
args: { other: { $state: otherPath } },
|
||||
message: message ?? "Must be greater than the compared field",
|
||||
}),
|
||||
|
||||
requiredIf: (
|
||||
fieldPath: string,
|
||||
message = "This field is required",
|
||||
): ValidationCheck => ({
|
||||
type: "requiredIf",
|
||||
args: { field: { $state: fieldPath } },
|
||||
message,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @json-render/jotai
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-native
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
@@ -56,10 +56,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*"
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"react-native": "0.83.1",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/schema.ts", "src/catalog.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "react-native", "@json-render/core", "zod"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-pdf
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
@@ -61,10 +61,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@react-pdf/renderer": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @internal/react-state
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@internal/react-state",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Shared React state context for json-render renderer packages",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
@@ -50,10 +50,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*"
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ResolvedAction,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state";
|
||||
import { useOptionalValidation } from "./validation";
|
||||
|
||||
/**
|
||||
* Generate a unique ID for use with the "$id" token.
|
||||
@@ -137,6 +138,7 @@ export function ActionProvider({
|
||||
children,
|
||||
}: ActionProviderProps) {
|
||||
const { get, set, getSnapshot } = useStateStore();
|
||||
const validation = useOptionalValidation();
|
||||
|
||||
const [handlers, setHandlers] =
|
||||
useState<Record<string, ActionHandler>>(initialHandlers);
|
||||
@@ -227,13 +229,34 @@ export function ActionProvider({
|
||||
if (previousScreen) {
|
||||
set("/currentScreen", previousScreen);
|
||||
} else {
|
||||
// Sentinel empty string = clear currentScreen (return to default)
|
||||
set("/currentScreen", undefined);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in: validateForm triggers validateAll from the ValidationProvider
|
||||
// and writes the result to a state path (default: /formValidation).
|
||||
// IMPORTANT: validateAll() is synchronous — it runs all registered field
|
||||
// validations and returns immediately. This guarantees that the next action
|
||||
// in a sequential list (e.g. [validateForm, submitForm]) can read the
|
||||
// validation result from state without awaiting an extra tick.
|
||||
if (resolved.action === "validateForm") {
|
||||
const validateAll = validation?.validateAll;
|
||||
if (!validateAll) {
|
||||
console.warn(
|
||||
"validateForm action was dispatched but no ValidationProvider is connected. " +
|
||||
"Ensure ValidationProvider is rendered inside the provider tree.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const valid = validateAll();
|
||||
const statePath =
|
||||
(resolved.params?.statePath as string) || "/formValidation";
|
||||
set(statePath, { valid });
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[resolved.action];
|
||||
|
||||
if (!handler) {
|
||||
@@ -300,7 +323,7 @@ export function ActionProvider({
|
||||
});
|
||||
}
|
||||
},
|
||||
[handlers, get, set, getSnapshot, navigate],
|
||||
[handlers, get, set, getSnapshot, navigate, validation],
|
||||
);
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -130,11 +129,7 @@ export function ValidationProvider({
|
||||
customFunctions = {},
|
||||
children,
|
||||
}: ValidationProviderProps) {
|
||||
const { state } = useStateStore();
|
||||
// Keep a ref to the latest state so `validate` doesn't change on every
|
||||
// state update — preventing the entire validation context from churning.
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const { state, getSnapshot } = useStateStore();
|
||||
|
||||
const [fieldStates, setFieldStates] = useState<
|
||||
Record<string, FieldValidationState>
|
||||
@@ -160,8 +155,10 @@ export function ValidationProvider({
|
||||
|
||||
const validate = useCallback(
|
||||
(path: string, config: ValidationConfig): ValidationResult => {
|
||||
// Walk the nested state object using JSON Pointer segments
|
||||
const currentState = stateRef.current;
|
||||
// Read from the store directly so validation sees values written in the
|
||||
// same synchronous handler (e.g. setValue then validate in onChange).
|
||||
// Using React state would return the stale pre-render snapshot.
|
||||
const currentState = getSnapshot();
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
let value: unknown = currentState;
|
||||
for (const seg of segments) {
|
||||
@@ -189,7 +186,7 @@ export function ValidationProvider({
|
||||
|
||||
return result;
|
||||
},
|
||||
[customFunctions],
|
||||
[customFunctions, getSnapshot],
|
||||
);
|
||||
|
||||
const touch = useCallback((path: string) => {
|
||||
@@ -263,6 +260,14 @@ export function useValidation(): ValidationContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing variant of useValidation.
|
||||
* Returns null when no ValidationProvider is present.
|
||||
*/
|
||||
export function useOptionalValidation(): ValidationContextValue | null {
|
||||
return useContext(ValidationContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get validation state for a field
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { render, act, fireEvent, screen } from "@testing-library/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
type ComponentRenderProps,
|
||||
} from "./renderer";
|
||||
import { useStateStore } from "./contexts/state";
|
||||
import { useFieldValidation } from "./contexts/validation";
|
||||
import { useBoundProp } from "./hooks";
|
||||
|
||||
// =============================================================================
|
||||
// Stub components
|
||||
// =============================================================================
|
||||
|
||||
function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
|
||||
return (
|
||||
<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 });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Select validate-on-change timing (#151)
|
||||
// =============================================================================
|
||||
|
||||
describe("Select validate-on-change sees the new value, not the stale value", () => {
|
||||
const Stack = ({
|
||||
children,
|
||||
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
|
||||
|
||||
const regWithSelect = {
|
||||
...registry,
|
||||
Stack,
|
||||
Select: ValidatedSelect,
|
||||
};
|
||||
|
||||
it("does not show 'required' error when selecting the first value", async () => {
|
||||
const spec: Spec = {
|
||||
state: { form: { country: "" } },
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["countrySelect"],
|
||||
},
|
||||
countrySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "Country",
|
||||
name: "country",
|
||||
options: ["US", "Canada", "UK"],
|
||||
placeholder: "Choose a country",
|
||||
value: { $bindState: "/form/country" },
|
||||
checks: [{ type: "required", message: "Country is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={regWithSelect} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
// Select "US" for the first time (from empty)
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId("select-country"), {
|
||||
target: { value: "US" },
|
||||
});
|
||||
});
|
||||
|
||||
// The value should be set in state
|
||||
const state = getState();
|
||||
expect((state.form as Record<string, unknown>).country).toBe("US");
|
||||
|
||||
// No validation error should appear -- "US" is non-empty
|
||||
expect(screen.queryByTestId("select-error-country")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
|
||||
const spec: Spec = {
|
||||
state: {
|
||||
form: { country: "US", city: "" },
|
||||
availableCities: ["New York", "Chicago"],
|
||||
},
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["citySelect"],
|
||||
},
|
||||
citySelect: {
|
||||
type: "Select",
|
||||
props: {
|
||||
label: "City",
|
||||
name: "city",
|
||||
options: ["New York", "Chicago"],
|
||||
placeholder: "Select a city",
|
||||
value: { $bindState: "/form/city" },
|
||||
checks: [{ type: "required", message: "City is required" }],
|
||||
validateOn: "change",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={regWithSelect} />
|
||||
<StateProbe />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
// Select "New York" for the first time (from empty)
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId("select-city"), {
|
||||
target: { value: "New York" },
|
||||
});
|
||||
});
|
||||
|
||||
const state = getState();
|
||||
expect((state.form as Record<string, unknown>).city).toBe("New York");
|
||||
|
||||
// No validation error should appear
|
||||
expect(screen.queryByTestId("select-error-city")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
export {
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
useOptionalValidation,
|
||||
useFieldValidation,
|
||||
type ValidationContextValue,
|
||||
type ValidationProviderProps,
|
||||
|
||||
+137
-31
@@ -5,7 +5,9 @@ import React, {
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type {
|
||||
UIElement,
|
||||
@@ -14,6 +16,7 @@ import type {
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
ComputedFunction,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -136,6 +139,19 @@ class ElementErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FunctionsContext – provides $computed functions to the element tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
|
||||
|
||||
const FunctionsContext =
|
||||
React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
|
||||
|
||||
function useFunctions(): Record<string, ComputedFunction> {
|
||||
return React.useContext(FunctionsContext);
|
||||
}
|
||||
|
||||
interface ElementRendererProps {
|
||||
element: UIElement;
|
||||
spec: Spec;
|
||||
@@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
const repeatScope = useRepeatScope();
|
||||
const { ctx } = useVisibility();
|
||||
const { execute } = useActions();
|
||||
const { getSnapshot } = useStateStore();
|
||||
const { getSnapshot, state: watchState } = useStateStore();
|
||||
const functions = useFunctions();
|
||||
|
||||
// Build context with repeat scope (used for both visibility and props)
|
||||
const fullCtx: PropResolutionContext = useMemo(
|
||||
() =>
|
||||
repeatScope
|
||||
? {
|
||||
...ctx,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: ctx,
|
||||
[ctx, repeatScope],
|
||||
);
|
||||
// Build context with repeat scope and $computed functions
|
||||
const fullCtx: PropResolutionContext = useMemo(() => {
|
||||
const base: PropResolutionContext = repeatScope
|
||||
? {
|
||||
...ctx,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: { ...ctx };
|
||||
base.functions = functions;
|
||||
return base;
|
||||
}, [ctx, repeatScope, functions]);
|
||||
|
||||
// Evaluate visibility (now supports $item/$index inside repeat scopes)
|
||||
const isVisible =
|
||||
@@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
[onBindings, emit],
|
||||
);
|
||||
|
||||
// Watch effect: fire actions when watched state paths change.
|
||||
// Must be called before any early return to satisfy Rules of Hooks.
|
||||
//
|
||||
// Two refs serve distinct roles:
|
||||
// - `stableWatchRef` (useMemo): holds the last emitted values object so we
|
||||
// can return the same reference when watched values haven't changed,
|
||||
// preventing the downstream useEffect from firing on unrelated state updates.
|
||||
// - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
|
||||
// for change detection. Starts as `null` to skip the initial mount.
|
||||
const watchConfig = element.watch;
|
||||
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
|
||||
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
|
||||
|
||||
const watchedValues = useMemo(() => {
|
||||
if (!watchConfig) return undefined;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const path of Object.keys(watchConfig)) {
|
||||
values[path] = getByPath(watchState, path);
|
||||
}
|
||||
const prev = stableWatchRef.current;
|
||||
if (prev) {
|
||||
const keys = Object.keys(values);
|
||||
if (
|
||||
keys.length === Object.keys(prev).length &&
|
||||
keys.every((k) => values[k] === prev[k])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
stableWatchRef.current = values;
|
||||
return values;
|
||||
}, [watchConfig, watchState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!watchConfig || !watchedValues) return;
|
||||
const paths = Object.keys(watchConfig);
|
||||
if (paths.length === 0) return;
|
||||
|
||||
const prev = prevWatchValues.current;
|
||||
prevWatchValues.current = watchedValues;
|
||||
|
||||
// Skip the initial mount — only fire on changes
|
||||
if (prev === null) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
for (const path of paths) {
|
||||
if (cancelled) break;
|
||||
if (watchedValues[path] !== prev[path]) {
|
||||
const binding = watchConfig[path];
|
||||
if (!binding) continue;
|
||||
const bindings = Array.isArray(binding) ? binding : [binding];
|
||||
for (const b of bindings) {
|
||||
if (cancelled) break;
|
||||
if (!b.params) {
|
||||
await execute(b);
|
||||
if (cancelled) break;
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...fullCtx,
|
||||
stateModel: getSnapshot(),
|
||||
};
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(b.params)) {
|
||||
resolved[key] = resolveActionParam(val, liveCtx);
|
||||
}
|
||||
await execute({ ...b, params: resolved });
|
||||
if (cancelled) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
@@ -419,6 +515,8 @@ export interface JSONUIProviderProps {
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
/** Named functions for `$computed` expressions in props */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
@@ -434,6 +532,7 @@ export function JSONUIProvider({
|
||||
handlers,
|
||||
navigate,
|
||||
validationFunctions,
|
||||
functions,
|
||||
onStateChange,
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
@@ -444,12 +543,14 @@ export function JSONUIProvider({
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||
{children}
|
||||
<ConfirmationDialogManager />
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
@@ -650,6 +751,8 @@ export interface CreateRendererProps {
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Named functions for `$computed` expressions in props */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
/** Fallback component for unknown types */
|
||||
@@ -703,6 +806,7 @@ export function createRenderer<
|
||||
state,
|
||||
onAction,
|
||||
onStateChange,
|
||||
functions,
|
||||
loading,
|
||||
fallback,
|
||||
}: CreateRendererProps) {
|
||||
@@ -730,17 +834,19 @@ export function createRenderer<
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
<ValidationProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
/>
|
||||
<ConfirmationDialogManager />
|
||||
</FunctionsContext.Provider>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
@@ -67,6 +67,11 @@ export const schema = defineSchema(
|
||||
description:
|
||||
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
|
||||
},
|
||||
{
|
||||
name: "validateForm",
|
||||
description:
|
||||
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
|
||||
},
|
||||
],
|
||||
defaultRules: [
|
||||
// Element integrity
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/schema.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
dts: { resolve: ["@internal/react-state"] },
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
noExternal: ["@internal/react-state"],
|
||||
external: ["react", "react-dom", "@json-render/core"],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @json-render/redux
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @json-render/remotion
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @json-render/shadcn
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Shared validation schemas used across form components
|
||||
// =============================================================================
|
||||
|
||||
const validationCheckSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable();
|
||||
|
||||
const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable();
|
||||
|
||||
// =============================================================================
|
||||
// shadcn/ui Component Definitions
|
||||
// =============================================================================
|
||||
@@ -272,19 +288,12 @@ export const shadcnComponentDefinitions = {
|
||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["submit", "focus", "blur"],
|
||||
description:
|
||||
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
|
||||
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).",
|
||||
example: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
@@ -300,18 +309,11 @@ export const shadcnComponentDefinitions = {
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
description:
|
||||
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
|
||||
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).",
|
||||
},
|
||||
|
||||
Select: {
|
||||
@@ -321,19 +323,12 @@ export const shadcnComponentDefinitions = {
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
|
||||
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
@@ -341,9 +336,12 @@ export const shadcnComponentDefinitions = {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Checkbox input. Use { $bindState } on checked for binding.",
|
||||
description:
|
||||
"Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
@@ -352,9 +350,12 @@ export const shadcnComponentDefinitions = {
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
value: z.string().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Radio button group. Use { $bindState } on value for binding.",
|
||||
description:
|
||||
"Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
@@ -362,9 +363,12 @@ export const shadcnComponentDefinitions = {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle switch. Use { $bindState } on checked for binding.",
|
||||
description:
|
||||
"Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Slider: {
|
||||
|
||||
@@ -664,11 +664,12 @@ export const shadcnComponents = {
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const validateOn = props.validateOn ?? "blur";
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -682,13 +683,16 @@ export const shadcnComponents = {
|
||||
type={props.type ?? "text"}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") emit("submit");
|
||||
}}
|
||||
onFocus={() => emit("focus")}
|
||||
onBlur={() => {
|
||||
if (hasValidation) validate();
|
||||
if (hasValidation && validateOn === "blur") validate();
|
||||
emit("blur");
|
||||
}}
|
||||
/>
|
||||
@@ -711,11 +715,12 @@ export const shadcnComponents = {
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const validateOn = props.validateOn ?? "blur";
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -729,9 +734,12 @@ export const shadcnComponents = {
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (hasValidation) validate();
|
||||
if (hasValidation && validateOn === "blur") validate();
|
||||
}}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
@@ -758,11 +766,12 @@ export const shadcnComponents = {
|
||||
const options = rawOptions.map((opt) =>
|
||||
typeof opt === "string" ? opt : String(opt ?? ""),
|
||||
);
|
||||
const validateOn = props.validateOn ?? "change";
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -772,7 +781,8 @@ export const shadcnComponents = {
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
if (hasValidation) validate();
|
||||
// Select has no native blur event, so only validate on "change"
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
@@ -808,19 +818,32 @@ export const shadcnComponents = {
|
||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
const validateOn = props.validateOn ?? "change";
|
||||
const hasValidation = !!(bindings?.checked && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.checked ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={props.name ?? undefined}
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c === true);
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={props.name ?? undefined}
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c === true);
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
@@ -843,6 +866,13 @@ export const shadcnComponents = {
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
const validateOn = props.validateOn ?? "change";
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.label && <Label>{props.label}</Label>}
|
||||
@@ -850,6 +880,7 @@ export const shadcnComponents = {
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
@@ -868,6 +899,9 @@ export const shadcnComponents = {
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -886,19 +920,32 @@ export const shadcnComponents = {
|
||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
const validateOn = props.validateOn ?? "change";
|
||||
const hasValidation = !!(bindings?.checked && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.checked ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={props.name ?? undefined}
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c);
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={props.name ?? undefined}
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c);
|
||||
if (hasValidation && validateOn === "change") validate();
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
# @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
|
||||
```
|
||||
|
||||
## 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(),
|
||||
}),
|
||||
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" },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Define Component Implementations
|
||||
|
||||
```typescript
|
||||
import { h } from "vue";
|
||||
import { defineRegistry, useBoundProp } 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),
|
||||
children,
|
||||
]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp(props.value, bindings?.value);
|
||||
return h("label", null, [
|
||||
props.label,
|
||||
h("input", {
|
||||
placeholder: props.placeholder ?? "",
|
||||
value: value ?? "",
|
||||
onInput: (e: Event) =>
|
||||
setValue((e.target as HTMLInputElement).value),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
submit: async (_params, _setState) => {
|
||||
console.log("Form submitted");
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Render a Spec
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { JSONUIProvider, Renderer } from "@json-render/vue";
|
||||
import { registry } from "./registry";
|
||||
|
||||
const spec = {
|
||||
root: "card",
|
||||
state: { name: "" },
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Hello", description: null },
|
||||
children: ["input", "btn"],
|
||||
},
|
||||
input: {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
placeholder: "Enter your name",
|
||||
value: { $bindState: "/name" },
|
||||
},
|
||||
},
|
||||
btn: {
|
||||
type: "Button",
|
||||
props: { label: "Submit" },
|
||||
on: { press: { action: "submit" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<JSONUIProvider :registry="registry" :initial-state="spec.state ?? {}">
|
||||
<Renderer :spec="spec" :registry="registry" />
|
||||
</JSONUIProvider>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Spec Format
|
||||
|
||||
The spec is a flat map of elements. Each element references its children by key:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "card",
|
||||
"state": { "count": 0 },
|
||||
"elements": {
|
||||
"card": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Counter" },
|
||||
"children": ["display", "increment"]
|
||||
},
|
||||
"display": {
|
||||
"type": "Text",
|
||||
"props": { "text": { "$state": "/count" } }
|
||||
},
|
||||
"increment": {
|
||||
"type": "Button",
|
||||
"props": { "label": "+1" },
|
||||
"on": {
|
||||
"press": {
|
||||
"action": "setState",
|
||||
"params": { "statePath": "/count", "value": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Composables
|
||||
|
||||
### `useStateStore()`
|
||||
|
||||
Access the state context inside any component rendered within a `JSONUIProvider`.
|
||||
|
||||
```typescript
|
||||
import { useStateStore } from "@json-render/vue";
|
||||
|
||||
const { state, get, set, getSnapshot } = useStateStore();
|
||||
```
|
||||
|
||||
### `useBoundProp(propValue, bindingPath)`
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions.
|
||||
|
||||
```typescript
|
||||
import { useBoundProp } from "@json-render/vue";
|
||||
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
```
|
||||
|
||||
### `useVisibility()`
|
||||
|
||||
Evaluate visibility conditions.
|
||||
|
||||
```typescript
|
||||
import { useVisibility } from "@json-render/vue";
|
||||
|
||||
const { isVisible, ctx } = useVisibility();
|
||||
```
|
||||
|
||||
### `useActions()`
|
||||
|
||||
Access action execution.
|
||||
|
||||
```typescript
|
||||
import { useActions } from "@json-render/vue";
|
||||
|
||||
const { execute, loadingActions } = useActions();
|
||||
```
|
||||
|
||||
### `useFieldValidation(path, config)`
|
||||
|
||||
Field-level validation.
|
||||
|
||||
```typescript
|
||||
import { useFieldValidation } from "@json-render/vue";
|
||||
|
||||
const { errors, isValid, validate, touch } = useFieldValidation("/email", {
|
||||
checks: [{ type: "required", message: "Email is required" }],
|
||||
});
|
||||
```
|
||||
|
||||
## Provider Components
|
||||
|
||||
All providers are renderless components that use Vue's `provide`/`inject`:
|
||||
|
||||
- **`StateProvider`** -- Manages state model with `createStateStore` from core
|
||||
- **`VisibilityProvider`** -- Evaluates visibility conditions against state
|
||||
- **`ValidationProvider`** -- Handles form validation
|
||||
- **`ActionProvider`** -- Executes action bindings (setState, pushState, custom actions)
|
||||
- **`JSONUIProvider`** -- Composes all of the above into a single provider
|
||||
|
||||
## Dynamic Prop Expressions
|
||||
|
||||
Props support dynamic expressions that resolve against state:
|
||||
|
||||
| Expression | Description |
|
||||
|---|---|
|
||||
| `{ "$state": "/path" }` | Read a value from state |
|
||||
| `{ "$bindState": "/path" }` | Two-way binding to state |
|
||||
| `{ "$item": "field" }` | Read from current repeat item |
|
||||
| `{ "$bindItem": "field" }` | Two-way bind to repeat item field |
|
||||
| `{ "$index": true }` | Current repeat index |
|
||||
| `{ "$cond": ..., "$then": ..., "$else": ... }` | Conditional value |
|
||||
| `{ "$computed": "fnName", "args": {...} }` | Computed function |
|
||||
| `{ "$template": "Hello ${/name}" }` | String interpolation |
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `setState` | Set a value at a state path |
|
||||
| `pushState` | Append to an array in state |
|
||||
| `removeState` | Remove an item from an array by index |
|
||||
| `validateForm` | Validate all registered fields |
|
||||
|
||||
## Key Exports
|
||||
|
||||
```typescript
|
||||
// Renderer
|
||||
export { Renderer, JSONUIProvider, defineRegistry, createRenderer, useBoundProp };
|
||||
|
||||
// Composables
|
||||
export { useStateStore, useStateValue };
|
||||
export { useVisibility, useIsVisible };
|
||||
export { useActions, useAction };
|
||||
export { useValidation, useFieldValidation };
|
||||
export { useRepeatScope };
|
||||
|
||||
// Provider components
|
||||
export { StateProvider, VisibilityProvider, ActionProvider, ValidationProvider };
|
||||
export { RepeatScopeProvider };
|
||||
|
||||
// Schema
|
||||
export { schema };
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ComponentRenderProps, ComponentRenderer, ComponentRegistry,
|
||||
RendererProps, JSONUIProviderProps, CreateRendererProps,
|
||||
BaseComponentProps, ComponentContext, ComponentFn,
|
||||
EventHandle, SetState, StateModel,
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@json-render/vue",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema.d.ts",
|
||||
"import": "./dist/schema.js",
|
||||
"require": "./dist/schema.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vue": "^3.5.29"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { VNode } from "vue";
|
||||
import type {
|
||||
Catalog,
|
||||
InferCatalogComponents,
|
||||
InferCatalogActions,
|
||||
InferComponentProps,
|
||||
InferActionParams,
|
||||
StateModel,
|
||||
} from "@json-render/core";
|
||||
|
||||
export type { StateModel };
|
||||
|
||||
export type SetState = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export interface EventHandle {
|
||||
emit: () => void;
|
||||
shouldPreventDefault: boolean;
|
||||
bound: boolean;
|
||||
}
|
||||
|
||||
export interface BaseComponentProps<P = Record<string, unknown>> {
|
||||
props: P;
|
||||
children?: VNode[];
|
||||
emit: (event: string) => void;
|
||||
on: (event: string) => EventHandle;
|
||||
bindings?: Record<string, string>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentContext<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogComponents<C>,
|
||||
> extends BaseComponentProps<InferComponentProps<C, K>> {}
|
||||
|
||||
export type ComponentFn<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogComponents<C>,
|
||||
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null;
|
||||
|
||||
export type Components<C extends Catalog> = {
|
||||
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
|
||||
};
|
||||
|
||||
export type ActionFn<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogActions<C>,
|
||||
> = (
|
||||
params: InferActionParams<C, K> | undefined,
|
||||
setState: SetState,
|
||||
state: StateModel,
|
||||
) => Promise<void>;
|
||||
|
||||
export type Actions<C extends Catalog> = {
|
||||
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
|
||||
};
|
||||
|
||||
export type CatalogHasActions<C extends Catalog> = [
|
||||
InferCatalogActions<C>,
|
||||
] extends [never]
|
||||
? false
|
||||
: [keyof InferCatalogActions<C>] extends [never]
|
||||
? false
|
||||
: true;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { defineComponent, h, createApp, nextTick } from "vue";
|
||||
import { ActionProvider, useActions } from "./actions.js";
|
||||
import { StateProvider, useStateStore } from "./state.js";
|
||||
import { VisibilityProvider } from "./visibility.js";
|
||||
import { ValidationProvider } from "./validation.js";
|
||||
|
||||
function runInProviders<T>(
|
||||
initialState: Record<string, unknown>,
|
||||
fn: () => T,
|
||||
handlers?: Record<string, (...args: unknown[]) => void>,
|
||||
): T {
|
||||
let result: T | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
result = fn();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: handlers ?? {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("useActions", () => {
|
||||
it("provides execute function", () => {
|
||||
const ctx = runInProviders({}, () => useActions());
|
||||
expect(typeof ctx.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("provides confirm and cancel functions", () => {
|
||||
const ctx = runInProviders({}, () => useActions());
|
||||
expect(typeof ctx.confirm).toBe("function");
|
||||
expect(typeof ctx.cancel).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("action execution", () => {
|
||||
it("executes setState action", async () => {
|
||||
let storeCtx: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsCtx: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
storeCtx = useStateStore();
|
||||
actionsCtx = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: { count: 0 } }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actionsCtx!.execute({
|
||||
action: "setState",
|
||||
params: { statePath: "/count", value: 42 },
|
||||
});
|
||||
|
||||
expect(storeCtx!.get("/count")).toBe(42);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("executes pushState action", async () => {
|
||||
let storeCtx: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsCtx: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
storeCtx = useStateStore();
|
||||
actionsCtx = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: { items: ["a"] } }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actionsCtx!.execute({
|
||||
action: "pushState",
|
||||
params: { statePath: "/items", value: "b" },
|
||||
});
|
||||
|
||||
expect(storeCtx!.get("/items")).toEqual(["a", "b"]);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("executes removeState action", async () => {
|
||||
let storeCtx: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsCtx: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
storeCtx = useStateStore();
|
||||
actionsCtx = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: { items: ["a", "b", "c"] } }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actionsCtx!.execute({
|
||||
action: "removeState",
|
||||
params: { statePath: "/items", index: 1 },
|
||||
});
|
||||
|
||||
expect(storeCtx!.get("/items")).toEqual(["a", "c"]);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("warns when no handler is registered for custom action", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const actionsCtx = runInProviders({}, () => useActions());
|
||||
|
||||
// The provider is unmounted, but execute still runs synchronously for the warn path
|
||||
// We need a mounted tree for this test
|
||||
let actions: ReturnType<typeof useActions> | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
actions = useActions();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState: {} }, () =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: {} }, () => h(Inner)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
await actions!.execute({ action: "unknownAction" });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"No handler registered for action: unknownAction",
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
provide,
|
||||
inject,
|
||||
ref,
|
||||
type InjectionKey,
|
||||
type PropType,
|
||||
} from "vue";
|
||||
import {
|
||||
resolveAction,
|
||||
executeAction,
|
||||
type ActionBinding,
|
||||
type ActionHandler,
|
||||
type ActionConfirm,
|
||||
type ResolvedAction,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state.js";
|
||||
import { useOptionalValidation } from "./validation.js";
|
||||
|
||||
function generateUniqueId(): string {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
const MAX_RESOLVE_DEPTH = 20;
|
||||
|
||||
function deepResolveValue(
|
||||
value: unknown,
|
||||
get: (path: string) => unknown,
|
||||
depth: number = 0,
|
||||
): unknown {
|
||||
if (depth > MAX_RESOLVE_DEPTH) return value;
|
||||
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, depth + 1));
|
||||
}
|
||||
|
||||
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, depth + 1);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface PendingConfirmation {
|
||||
action: ResolvedAction;
|
||||
handler: ActionHandler;
|
||||
resolve: () => void;
|
||||
reject: () => void;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const ActionKey: InjectionKey<ActionContextValue> = Symbol(
|
||||
"json-render-actions",
|
||||
);
|
||||
|
||||
export interface ActionProviderProps {
|
||||
handlers?: Record<string, ActionHandler>;
|
||||
navigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
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 storeCtx = useStateStore();
|
||||
const validation = useOptionalValidation();
|
||||
|
||||
const handlersRef = ref<Record<string, ActionHandler>>({
|
||||
...props.handlers,
|
||||
});
|
||||
const loadingActionsRef = ref<Set<string>>(new Set());
|
||||
const pendingConfirmationRef = ref<PendingConfirmation | null>(null);
|
||||
|
||||
const registerHandler = (name: string, handler: ActionHandler) => {
|
||||
handlersRef.value = { ...handlersRef.value, [name]: handler };
|
||||
};
|
||||
|
||||
const execute = async (binding: ActionBinding) => {
|
||||
const resolved = resolveAction(binding, storeCtx.getSnapshot());
|
||||
|
||||
if (resolved.action === "setState" && resolved.params) {
|
||||
const statePath = resolved.params.statePath as string;
|
||||
const value = resolved.params.value;
|
||||
if (statePath) {
|
||||
storeCtx.set(statePath, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved.action === "pushState" && resolved.params) {
|
||||
const statePath = resolved.params.statePath as string;
|
||||
const rawValue = resolved.params.value;
|
||||
if (statePath) {
|
||||
const resolvedValue = deepResolveValue(rawValue, storeCtx.get);
|
||||
const arr = (storeCtx.get(statePath) as unknown[] | undefined) ?? [];
|
||||
storeCtx.set(statePath, [...arr, resolvedValue]);
|
||||
const clearStatePath = resolved.params.clearStatePath as
|
||||
| string
|
||||
| undefined;
|
||||
if (clearStatePath) {
|
||||
storeCtx.set(clearStatePath, "");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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 = (storeCtx.get(statePath) as unknown[] | undefined) ?? [];
|
||||
storeCtx.set(
|
||||
statePath,
|
||||
arr.filter((_, i) => i !== index),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved.action === "push" && resolved.params) {
|
||||
const screen = resolved.params.screen as string;
|
||||
if (screen) {
|
||||
const currentScreen = storeCtx.get("/currentScreen") as
|
||||
| string
|
||||
| undefined;
|
||||
const navStack =
|
||||
(storeCtx.get("/navStack") as string[] | undefined) ?? [];
|
||||
if (currentScreen) {
|
||||
storeCtx.set("/navStack", [...navStack, currentScreen]);
|
||||
} else {
|
||||
storeCtx.set("/navStack", [...navStack, ""]);
|
||||
}
|
||||
storeCtx.set("/currentScreen", screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved.action === "pop") {
|
||||
const navStack =
|
||||
(storeCtx.get("/navStack") as string[] | undefined) ?? [];
|
||||
if (navStack.length > 0) {
|
||||
const previousScreen = navStack[navStack.length - 1];
|
||||
storeCtx.set("/navStack", navStack.slice(0, -1));
|
||||
if (previousScreen) {
|
||||
storeCtx.set("/currentScreen", previousScreen);
|
||||
} else {
|
||||
storeCtx.set("/currentScreen", undefined);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved.action === "validateForm") {
|
||||
const validateAll = validation?.validateAll;
|
||||
if (!validateAll) {
|
||||
console.warn(
|
||||
"validateForm action was dispatched but no ValidationProvider is connected.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const valid = validateAll();
|
||||
const statePath =
|
||||
(resolved.params?.statePath as string) || "/formValidation";
|
||||
storeCtx.set(statePath, { valid });
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlersRef.value[resolved.action];
|
||||
|
||||
if (!handler) {
|
||||
console.warn(`No handler registered for action: ${resolved.action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved.confirm) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingConfirmationRef.value = {
|
||||
action: resolved,
|
||||
handler,
|
||||
resolve: () => {
|
||||
pendingConfirmationRef.value = null;
|
||||
resolve();
|
||||
},
|
||||
reject: () => {
|
||||
pendingConfirmationRef.value = null;
|
||||
reject(new Error("Action cancelled"));
|
||||
},
|
||||
};
|
||||
}).then(async () => {
|
||||
const next = new Set(loadingActionsRef.value);
|
||||
next.add(resolved.action);
|
||||
loadingActionsRef.value = next;
|
||||
try {
|
||||
await executeAction({
|
||||
action: resolved,
|
||||
handler,
|
||||
setState: storeCtx.set,
|
||||
navigate: props.navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
const fin = new Set(loadingActionsRef.value);
|
||||
fin.delete(resolved.action);
|
||||
loadingActionsRef.value = fin;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const next = new Set(loadingActionsRef.value);
|
||||
next.add(resolved.action);
|
||||
loadingActionsRef.value = next;
|
||||
try {
|
||||
await executeAction({
|
||||
action: resolved,
|
||||
handler,
|
||||
setState: storeCtx.set,
|
||||
navigate: props.navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
const fin = new Set(loadingActionsRef.value);
|
||||
fin.delete(resolved.action);
|
||||
loadingActionsRef.value = fin;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
pendingConfirmationRef.value?.resolve();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
pendingConfirmationRef.value?.reject();
|
||||
};
|
||||
|
||||
const ctx: ActionContextValue = {
|
||||
get handlers() {
|
||||
return handlersRef.value;
|
||||
},
|
||||
get loadingActions() {
|
||||
return loadingActionsRef.value;
|
||||
},
|
||||
get pendingConfirmation() {
|
||||
return pendingConfirmationRef.value;
|
||||
},
|
||||
execute,
|
||||
confirm,
|
||||
cancel,
|
||||
registerHandler,
|
||||
};
|
||||
|
||||
provide(ActionKey, ctx);
|
||||
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
export function useActions(): ActionContextValue {
|
||||
const ctx = inject(ActionKey);
|
||||
if (!ctx) {
|
||||
throw new Error("useActions must be used within an ActionProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useAction(binding: ActionBinding): {
|
||||
execute: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { execute, loadingActions } = useActions();
|
||||
|
||||
return {
|
||||
execute: () => execute(binding),
|
||||
get isLoading() {
|
||||
return loadingActions.has(binding.action);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfirmDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
confirm: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: string;
|
||||
};
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmDialog = defineComponent({
|
||||
name: "ConfirmDialog",
|
||||
props: {
|
||||
confirm: {
|
||||
type: Object as PropType<ConfirmDialogProps["confirm"]>,
|
||||
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: Event) => 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",
|
||||
{
|
||||
onClick: props.onCancel,
|
||||
style: {
|
||||
padding: "8px 16px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "white",
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
props.confirm.cancelLabel ?? "Cancel",
|
||||
),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
onClick: props.onConfirm,
|
||||
style: {
|
||||
padding: "8px 16px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
backgroundColor: isDanger ? "#dc2626" : "#3b82f6",
|
||||
color: "white",
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
props.confirm.confirmLabel ?? "Confirm",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
defineComponent,
|
||||
provide,
|
||||
inject,
|
||||
type InjectionKey,
|
||||
type PropType,
|
||||
} from "vue";
|
||||
|
||||
export interface RepeatScopeValue {
|
||||
item: unknown;
|
||||
index: number;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export const RepeatScopeKey: InjectionKey<RepeatScopeValue | null> = Symbol(
|
||||
"json-render-repeat-scope",
|
||||
);
|
||||
|
||||
export const RepeatScopeProvider = defineComponent({
|
||||
name: "RepeatScopeProvider",
|
||||
props: {
|
||||
item: {
|
||||
type: null as unknown as PropType<unknown>,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const value: RepeatScopeValue = {
|
||||
get item() {
|
||||
return props.item;
|
||||
},
|
||||
get index() {
|
||||
return props.index;
|
||||
},
|
||||
get basePath() {
|
||||
return props.basePath;
|
||||
},
|
||||
};
|
||||
|
||||
provide(RepeatScopeKey, value);
|
||||
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
export function useRepeatScope(): RepeatScopeValue | null {
|
||||
return inject(RepeatScopeKey, null);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { defineComponent, h, createApp, nextTick } from "vue";
|
||||
import {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
} from "./state.js";
|
||||
|
||||
function runInStateProvider<T>(
|
||||
initialState: Record<string, unknown>,
|
||||
fn: () => T,
|
||||
): T {
|
||||
let result: T | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
result = fn();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () => h(StateProvider, { initialState }, () => h(Inner));
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("StateProvider + useStateStore", () => {
|
||||
it("provides initial state and get/set round-trip", () => {
|
||||
const ctx = runInStateProvider({ count: 0 }, () => useStateStore());
|
||||
|
||||
expect(ctx.get("/count")).toBe(0);
|
||||
ctx.set("/count", 42);
|
||||
expect(ctx.get("/count")).toBe(42);
|
||||
});
|
||||
|
||||
it("state reflects initial values", () => {
|
||||
const ctx = runInStateProvider({ name: "Alice" }, () => useStateStore());
|
||||
expect(ctx.state.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("getSnapshot returns current state", () => {
|
||||
const ctx = runInStateProvider({ x: 1 }, () => useStateStore());
|
||||
expect(ctx.getSnapshot().x).toBe(1);
|
||||
|
||||
ctx.set("/x", 99);
|
||||
expect(ctx.getSnapshot().x).toBe(99);
|
||||
});
|
||||
|
||||
it("update applies multiple changes", () => {
|
||||
const ctx = runInStateProvider({ a: 1, b: 2 }, () => useStateStore());
|
||||
|
||||
ctx.update({ "/a": 10, "/b": 20 });
|
||||
expect(ctx.get("/a")).toBe(10);
|
||||
expect(ctx.get("/b")).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateValue", () => {
|
||||
it("reads a value from state", () => {
|
||||
const value = runInStateProvider({ name: "Alice" }, () =>
|
||||
useStateValue<string>("/name"),
|
||||
);
|
||||
expect(value).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns undefined for missing path", () => {
|
||||
const value = runInStateProvider({}, () =>
|
||||
useStateValue<string>("/missing"),
|
||||
);
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateBinding", () => {
|
||||
it("returns value from state and a setter", () => {
|
||||
const [value, setValue] = runInStateProvider({ x: 42 }, () =>
|
||||
useStateBinding<number>("/x"),
|
||||
);
|
||||
expect(value).toBe(42);
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
|
||||
it("returns undefined for missing path", () => {
|
||||
const [value] = runInStateProvider({}, () =>
|
||||
useStateBinding<string>("/missing"),
|
||||
);
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
defineComponent,
|
||||
provide,
|
||||
inject,
|
||||
shallowRef,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
type InjectionKey,
|
||||
type PropType,
|
||||
} from "vue";
|
||||
import {
|
||||
getByPath,
|
||||
createStateStore,
|
||||
type StateModel,
|
||||
type StateStore,
|
||||
} from "@json-render/core";
|
||||
import { flattenToPointers } from "@json-render/core/store-utils";
|
||||
|
||||
export interface StateContextValue {
|
||||
state: StateModel;
|
||||
get: (path: string) => unknown;
|
||||
set: (path: string, value: unknown) => void;
|
||||
update: (updates: Record<string, unknown>) => void;
|
||||
getSnapshot: () => StateModel;
|
||||
}
|
||||
|
||||
export const StateKey: InjectionKey<StateContextValue> =
|
||||
Symbol("json-render-state");
|
||||
|
||||
export interface StateProviderProps {
|
||||
store?: StateStore;
|
||||
initialState?: StateModel;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
}
|
||||
|
||||
export const StateProvider = defineComponent({
|
||||
name: "StateProvider",
|
||||
props: {
|
||||
store: {
|
||||
type: Object as PropType<StateStore>,
|
||||
default: undefined,
|
||||
},
|
||||
initialState: {
|
||||
type: Object as PropType<StateModel>,
|
||||
default: () => ({}),
|
||||
},
|
||||
onStateChange: {
|
||||
type: Function as PropType<
|
||||
(changes: Array<{ path: string; value: unknown }>) => void
|
||||
>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const isControlled = !!props.store;
|
||||
let internalStore: StateStore | undefined;
|
||||
if (!isControlled) {
|
||||
internalStore = createStateStore(props.initialState);
|
||||
}
|
||||
|
||||
const activeStore = props.store ?? internalStore!;
|
||||
|
||||
const stateRef = shallowRef<StateModel>(activeStore.getSnapshot());
|
||||
|
||||
const unsub = activeStore.subscribe(() => {
|
||||
stateRef.value = activeStore.getSnapshot();
|
||||
});
|
||||
onBeforeUnmount(unsub);
|
||||
|
||||
// Sync initialState changes for uncontrolled mode
|
||||
if (!isControlled) {
|
||||
let prevFlat: Record<string, unknown> =
|
||||
props.initialState && Object.keys(props.initialState).length > 0
|
||||
? flattenToPointers(props.initialState)
|
||||
: {};
|
||||
|
||||
watch(
|
||||
() => props.initialState,
|
||||
(newState) => {
|
||||
if (isControlled || !newState) return;
|
||||
const nextFlat =
|
||||
Object.keys(newState).length > 0 ? flattenToPointers(newState) : {};
|
||||
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) {
|
||||
activeStore.update(updates);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
}
|
||||
|
||||
const get = (path: string) => activeStore.get(path);
|
||||
|
||||
const set = (path: string, value: unknown) => {
|
||||
const prev = activeStore.getSnapshot();
|
||||
activeStore.set(path, value);
|
||||
if (!isControlled && activeStore.getSnapshot() !== prev) {
|
||||
props.onStateChange?.([{ path, value }]);
|
||||
}
|
||||
};
|
||||
|
||||
const update = (updates: Record<string, unknown>) => {
|
||||
const prev = activeStore.getSnapshot();
|
||||
activeStore.update(updates);
|
||||
if (!isControlled && activeStore.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) {
|
||||
props.onStateChange?.(changes);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSnapshot = () => activeStore.getSnapshot();
|
||||
|
||||
const ctx: StateContextValue = {
|
||||
get state() {
|
||||
return stateRef.value;
|
||||
},
|
||||
get,
|
||||
set,
|
||||
update,
|
||||
getSnapshot,
|
||||
};
|
||||
|
||||
provide(StateKey, ctx);
|
||||
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
export function useStateStore(): StateContextValue {
|
||||
const ctx = inject(StateKey);
|
||||
if (!ctx) {
|
||||
throw new Error("useStateStore must be used within a StateProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useStateValue<T>(path: string): T | undefined {
|
||||
const { state } = useStateStore();
|
||||
return getByPath(state, path) as T | undefined;
|
||||
}
|
||||
|
||||
export function useStateBinding<T>(
|
||||
path: string,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { state, set } = useStateStore();
|
||||
const value = getByPath(state, path) as T | undefined;
|
||||
const setValue = (newValue: T) => set(path, newValue);
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
defineComponent,
|
||||
provide,
|
||||
inject,
|
||||
ref,
|
||||
computed,
|
||||
watchEffect,
|
||||
type InjectionKey,
|
||||
type PropType,
|
||||
type ComputedRef,
|
||||
} from "vue";
|
||||
import {
|
||||
runValidation,
|
||||
type ValidationConfig,
|
||||
type ValidationFunction,
|
||||
type ValidationResult,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state.js";
|
||||
|
||||
export interface FieldValidationState {
|
||||
touched: boolean;
|
||||
validated: boolean;
|
||||
result: ValidationResult | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const ValidationKey: InjectionKey<ValidationContextValue> = Symbol(
|
||||
"json-render-validation",
|
||||
);
|
||||
|
||||
export interface ValidationProviderProps {
|
||||
customFunctions?: Record<string, ValidationFunction>;
|
||||
}
|
||||
|
||||
export const ValidationProvider = defineComponent({
|
||||
name: "ValidationProvider",
|
||||
props: {
|
||||
customFunctions: {
|
||||
type: Object as PropType<Record<string, ValidationFunction>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const storeCtx = useStateStore();
|
||||
const fieldStates = ref<Record<string, FieldValidationState>>({});
|
||||
const fieldConfigs = ref<Record<string, ValidationConfig>>({});
|
||||
|
||||
const registerField = (path: string, config: ValidationConfig) => {
|
||||
fieldConfigs.value = { ...fieldConfigs.value, [path]: config };
|
||||
};
|
||||
|
||||
const validate = (
|
||||
path: string,
|
||||
config: ValidationConfig,
|
||||
): ValidationResult => {
|
||||
const currentState = storeCtx.getSnapshot();
|
||||
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) => {
|
||||
const existing = fieldStates.value[path];
|
||||
fieldStates.value = {
|
||||
...fieldStates.value,
|
||||
[path]: {
|
||||
touched: true,
|
||||
validated: existing?.validated ?? false,
|
||||
result: existing?.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;
|
||||
};
|
||||
|
||||
const ctx: ValidationContextValue = {
|
||||
get customFunctions() {
|
||||
return props.customFunctions;
|
||||
},
|
||||
get fieldStates() {
|
||||
return fieldStates.value;
|
||||
},
|
||||
validate,
|
||||
touch,
|
||||
clear,
|
||||
validateAll,
|
||||
registerField,
|
||||
};
|
||||
|
||||
provide(ValidationKey, ctx);
|
||||
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
export function useValidation(): ValidationContextValue {
|
||||
const ctx = inject(ValidationKey);
|
||||
if (!ctx) {
|
||||
throw new Error("useValidation must be used within a ValidationProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalValidation(): ValidationContextValue | null {
|
||||
return inject(ValidationKey, null);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (path && config) {
|
||||
watchEffect(() => {
|
||||
ctx.registerField(path, config);
|
||||
});
|
||||
}
|
||||
|
||||
const defaultState: FieldValidationState = {
|
||||
touched: false,
|
||||
validated: false,
|
||||
result: null,
|
||||
};
|
||||
|
||||
const state = computed(() => ctx.fieldStates[path] ?? defaultState);
|
||||
const errors = computed(() => state.value.result?.errors ?? []);
|
||||
const isValid = computed(() => state.value.result?.valid ?? true);
|
||||
|
||||
return {
|
||||
state,
|
||||
validate: () => ctx.validate(path, config ?? { checks: [] }),
|
||||
touch: () => ctx.touch(path),
|
||||
clear: () => ctx.clear(path),
|
||||
errors,
|
||||
isValid,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { defineComponent, h, createApp } from "vue";
|
||||
import {
|
||||
VisibilityProvider,
|
||||
useVisibility,
|
||||
useIsVisible,
|
||||
} from "./visibility.js";
|
||||
import { StateProvider } from "./state.js";
|
||||
|
||||
function runInProviders<T>(
|
||||
initialState: Record<string, unknown>,
|
||||
fn: () => T,
|
||||
): T {
|
||||
let result: T | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
result = fn();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(StateProvider, { initialState }, () =>
|
||||
h(VisibilityProvider, null, () => h(Inner)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("useVisibility", () => {
|
||||
it("provides isVisible function", () => {
|
||||
const ctx = runInProviders({}, () => useVisibility());
|
||||
expect(typeof ctx.isVisible).toBe("function");
|
||||
});
|
||||
|
||||
it("provides visibility context with stateModel", () => {
|
||||
const ctx = runInProviders({ test: true }, () => useVisibility());
|
||||
expect(ctx.ctx.stateModel).toEqual({ test: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useIsVisible", () => {
|
||||
it("returns true for undefined condition", () => {
|
||||
const result = runInProviders({}, () => useIsVisible(undefined));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for true condition", () => {
|
||||
const result = runInProviders({}, () => useIsVisible(true));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for false condition", () => {
|
||||
const result = runInProviders({}, () => useIsVisible(false));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("evaluates $state conditions against state", () => {
|
||||
const trueResult = runInProviders({ isVisible: true }, () =>
|
||||
useIsVisible({ $state: "/isVisible" }),
|
||||
);
|
||||
expect(trueResult).toBe(true);
|
||||
|
||||
const falseResult = runInProviders({ isVisible: false }, () =>
|
||||
useIsVisible({ $state: "/isVisible" }),
|
||||
);
|
||||
expect(falseResult).toBe(false);
|
||||
});
|
||||
|
||||
it("evaluates equality conditions", () => {
|
||||
const result = runInProviders({ count: 1 }, () =>
|
||||
useIsVisible({ $state: "/count", eq: 1 }),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluates array conditions (implicit AND)", () => {
|
||||
const result = runInProviders({ user: { isAdmin: true }, count: 5 }, () =>
|
||||
useIsVisible([{ $state: "/user/isAdmin" }, { $state: "/count", eq: 5 }]),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
defineComponent,
|
||||
provide,
|
||||
inject,
|
||||
computed,
|
||||
type InjectionKey,
|
||||
} from "vue";
|
||||
import {
|
||||
evaluateVisibility,
|
||||
type VisibilityCondition,
|
||||
type VisibilityContext as CoreVisibilityContext,
|
||||
} from "@json-render/core";
|
||||
import { useStateStore } from "./state.js";
|
||||
|
||||
export interface VisibilityContextValue {
|
||||
isVisible: (condition: VisibilityCondition | undefined) => boolean;
|
||||
ctx: CoreVisibilityContext;
|
||||
}
|
||||
|
||||
export const VisibilityKey: InjectionKey<VisibilityContextValue> = Symbol(
|
||||
"json-render-visibility",
|
||||
);
|
||||
|
||||
export const VisibilityProvider = defineComponent({
|
||||
name: "VisibilityProvider",
|
||||
setup(_, { slots }) {
|
||||
const storeCtx = useStateStore();
|
||||
|
||||
const ctxRef = computed<CoreVisibilityContext>(() => ({
|
||||
stateModel: storeCtx.state,
|
||||
}));
|
||||
|
||||
const value: VisibilityContextValue = {
|
||||
isVisible: (condition: VisibilityCondition | undefined) =>
|
||||
evaluateVisibility(condition, ctxRef.value),
|
||||
get ctx() {
|
||||
return ctxRef.value;
|
||||
},
|
||||
};
|
||||
|
||||
provide(VisibilityKey, value);
|
||||
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
export function useVisibility(): VisibilityContextValue {
|
||||
const ctx = inject(VisibilityKey);
|
||||
if (!ctx) {
|
||||
throw new Error("useVisibility must be used within a VisibilityProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useIsVisible(
|
||||
condition: VisibilityCondition | undefined,
|
||||
): boolean {
|
||||
const { isVisible } = useVisibility();
|
||||
return isVisible(condition);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { defineComponent, h, createApp } from "vue";
|
||||
import {
|
||||
flatToTree,
|
||||
buildSpecFromParts,
|
||||
getTextFromParts,
|
||||
useBoundProp,
|
||||
} from "./hooks.js";
|
||||
import { StateProvider, useStateBinding } from "./composables/state.js";
|
||||
|
||||
describe("flatToTree", () => {
|
||||
it("converts array of elements to tree structure", () => {
|
||||
const elements = [
|
||||
{ key: "container", type: "stack", props: {}, parentKey: null },
|
||||
{
|
||||
key: "text1",
|
||||
type: "text",
|
||||
props: { content: "Hello" },
|
||||
parentKey: "container",
|
||||
},
|
||||
{
|
||||
key: "text2",
|
||||
type: "text",
|
||||
props: { content: "World" },
|
||||
parentKey: "container",
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.root).toBe("container");
|
||||
expect(Object.keys(tree.elements)).toHaveLength(3);
|
||||
expect(tree.elements["container"]).toBeDefined();
|
||||
expect(tree.elements["text1"]).toBeDefined();
|
||||
expect(tree.elements["text2"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("builds parent-child relationships", () => {
|
||||
const elements = [
|
||||
{ key: "root", type: "stack", props: {}, parentKey: null },
|
||||
{ key: "child1", type: "text", props: {}, parentKey: "root" },
|
||||
{ key: "child2", type: "text", props: {}, parentKey: "root" },
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["root"]!.children).toHaveLength(2);
|
||||
expect(tree.elements["root"]!.children).toContain("child1");
|
||||
expect(tree.elements["root"]!.children).toContain("child2");
|
||||
});
|
||||
|
||||
it("handles single root element", () => {
|
||||
const elements = [
|
||||
{
|
||||
key: "only",
|
||||
type: "text",
|
||||
props: { content: "Single" },
|
||||
parentKey: null,
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.root).toBe("only");
|
||||
expect(Object.keys(tree.elements)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles deeply nested elements", () => {
|
||||
const elements = [
|
||||
{ key: "level0", type: "stack", props: {}, parentKey: null },
|
||||
{ key: "level1", type: "stack", props: {}, parentKey: "level0" },
|
||||
{ key: "level2", type: "stack", props: {}, parentKey: "level1" },
|
||||
{ key: "level3", type: "text", props: {}, parentKey: "level2" },
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.root).toBe("level0");
|
||||
expect(tree.elements["level0"]!.children).toContain("level1");
|
||||
expect(tree.elements["level1"]!.children).toContain("level2");
|
||||
expect(tree.elements["level2"]!.children).toContain("level3");
|
||||
});
|
||||
|
||||
it("preserves element props", () => {
|
||||
const elements = [
|
||||
{
|
||||
key: "btn",
|
||||
type: "button",
|
||||
props: { label: "Click me", variant: "primary" },
|
||||
parentKey: null,
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["btn"]!.props).toEqual({
|
||||
label: "Click me",
|
||||
variant: "primary",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves visibility conditions", () => {
|
||||
const elements = [
|
||||
{
|
||||
key: "conditional",
|
||||
type: "text",
|
||||
props: {},
|
||||
parentKey: null,
|
||||
visible: { $state: "/isVisible" },
|
||||
},
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["conditional"]!.visible).toEqual({
|
||||
$state: "/isVisible",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty elements array", () => {
|
||||
const tree = flatToTree([]);
|
||||
|
||||
expect(tree.root).toBe("");
|
||||
expect(Object.keys(tree.elements)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles multiple children correctly", () => {
|
||||
const elements = [
|
||||
{ key: "parent", type: "grid", props: {}, parentKey: null },
|
||||
{ key: "a", type: "card", props: {}, parentKey: "parent" },
|
||||
{ key: "b", type: "card", props: {}, parentKey: "parent" },
|
||||
{ key: "c", type: "card", props: {}, parentKey: "parent" },
|
||||
{ key: "d", type: "card", props: {}, parentKey: "parent" },
|
||||
];
|
||||
|
||||
const tree = flatToTree(elements);
|
||||
|
||||
expect(tree.elements["parent"]!.children).toHaveLength(4);
|
||||
expect(tree.elements["parent"]!.children).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSpecFromParts", () => {
|
||||
it("returns null when no data-spec parts are present", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "Hello there" },
|
||||
{ type: "text", text: "How can I help?" },
|
||||
];
|
||||
expect(buildSpecFromParts(parts)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a spec from patch parts", () => {
|
||||
const parts = [
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: { op: "add", path: "/root", value: "main" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: {
|
||||
op: "add",
|
||||
path: "/elements/main",
|
||||
value: { type: "Card", props: { title: "Hello" }, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const spec = buildSpecFromParts(parts);
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec!.root).toBe("main");
|
||||
expect(spec!.elements["main"]).toEqual({
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles flat spec parts", () => {
|
||||
const parts = [
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "flat",
|
||||
spec: {
|
||||
root: "card-1",
|
||||
elements: {
|
||||
"card-1": { type: "Card", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const spec = buildSpecFromParts(parts);
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec!.root).toBe("card-1");
|
||||
expect(spec!.elements["card-1"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("ignores non-spec parts", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "Some text" },
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: { op: "add", path: "/root", value: "main" },
|
||||
},
|
||||
},
|
||||
{ type: "tool-invocation", data: { toolName: "search" } },
|
||||
];
|
||||
|
||||
const spec = buildSpecFromParts(parts);
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec!.root).toBe("main");
|
||||
});
|
||||
|
||||
it("applies patches incrementally", () => {
|
||||
const parts = [
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: { op: "add", path: "/root", value: "main" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: {
|
||||
op: "add",
|
||||
path: "/elements/main",
|
||||
value: { type: "Stack", props: {}, children: ["child"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: {
|
||||
op: "add",
|
||||
path: "/elements/child",
|
||||
value: { type: "Text", props: { content: "Hi" }, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const spec = buildSpecFromParts(parts);
|
||||
expect(spec).not.toBeNull();
|
||||
expect(Object.keys(spec!.elements)).toHaveLength(2);
|
||||
expect(spec!.elements["child"]!.props.content).toBe("Hi");
|
||||
});
|
||||
|
||||
it("returns null from empty parts list", () => {
|
||||
const spec = buildSpecFromParts([]);
|
||||
expect(spec).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTextFromParts", () => {
|
||||
it("extracts text from text parts", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: "World" },
|
||||
];
|
||||
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
|
||||
});
|
||||
|
||||
it("returns empty string when no text parts", () => {
|
||||
const parts = [
|
||||
{
|
||||
type: "data-spec",
|
||||
data: {
|
||||
type: "patch",
|
||||
patch: { op: "add", path: "/root", value: "x" },
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(getTextFromParts(parts)).toBe("");
|
||||
});
|
||||
|
||||
it("ignores non-text parts", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "Before" },
|
||||
{ type: "data-spec", data: {} },
|
||||
{ type: "tool-invocation", data: {} },
|
||||
{ type: "text", text: "After" },
|
||||
];
|
||||
expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
|
||||
});
|
||||
|
||||
it("trims whitespace from text parts", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: " Hello " },
|
||||
{ type: "text", text: " World " },
|
||||
];
|
||||
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
|
||||
});
|
||||
|
||||
it("skips empty text parts", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "World" },
|
||||
];
|
||||
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
|
||||
});
|
||||
|
||||
it("ignores text parts with non-string text field", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "Valid" },
|
||||
{ type: "text", text: undefined as unknown as string },
|
||||
{ type: "text", text: 42 as unknown as string },
|
||||
{ type: "text", text: "Also valid" },
|
||||
];
|
||||
expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
|
||||
});
|
||||
});
|
||||
|
||||
function runInStateProvider<T>(
|
||||
initialState: Record<string, unknown>,
|
||||
fn: () => T,
|
||||
): T {
|
||||
let result: T | undefined;
|
||||
|
||||
const Inner = defineComponent({
|
||||
setup() {
|
||||
result = fn();
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () => h(StateProvider, { initialState }, () => h(Inner));
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("useBoundProp", () => {
|
||||
it("returns the prop value as-is", () => {
|
||||
const [value] = runInStateProvider({ name: "test" }, () =>
|
||||
useBoundProp<string>("hello", undefined),
|
||||
);
|
||||
expect(value).toBe("hello");
|
||||
});
|
||||
|
||||
it("returns undefined when prop value is undefined", () => {
|
||||
const [value] = runInStateProvider({}, () =>
|
||||
useBoundProp<string>(undefined, undefined),
|
||||
);
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("provides a setter function", () => {
|
||||
const [, setValue] = runInStateProvider({ name: "test" }, () =>
|
||||
useBoundProp<string>("hello", "/name"),
|
||||
);
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateBinding", () => {
|
||||
it("returns value from state and a setter", () => {
|
||||
const [value, setValue] = runInStateProvider({ x: 42 }, () =>
|
||||
useStateBinding<number>("/x"),
|
||||
);
|
||||
expect(value).toBe(42);
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
|
||||
it("returns undefined for missing path", () => {
|
||||
const [value] = runInStateProvider({}, () =>
|
||||
useStateBinding<string>("/missing"),
|
||||
);
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,688 @@
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
onBeforeUnmount,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
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";
|
||||
import { useStateStore } from "./composables/state.js";
|
||||
|
||||
/**
|
||||
* Token usage metadata from AI generation
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
type ParsedLine =
|
||||
| { type: "patch"; patch: JsonPatch }
|
||||
| { type: "usage"; usage: TokenUsage }
|
||||
| null;
|
||||
|
||||
function parseLine(line: string): ParsedLine {
|
||||
try {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("//")) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(trimmed);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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": {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return newSpec;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useUIStream
|
||||
// =============================================================================
|
||||
|
||||
export interface UseUIStreamOptions {
|
||||
api: string;
|
||||
onComplete?: (spec: Spec) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface UseUIStreamReturn {
|
||||
spec: Ref<Spec | null>;
|
||||
isStreaming: Ref<boolean>;
|
||||
error: Ref<Error | null>;
|
||||
usage: Ref<TokenUsage | null>;
|
||||
rawLines: Ref<string[]>;
|
||||
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function useUIStream({
|
||||
api,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseUIStreamOptions): UseUIStreamReturn {
|
||||
const spec = ref<Spec | null>(null);
|
||||
const isStreaming = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
const usage = ref<TokenUsage | null>(null);
|
||||
const rawLines = ref<string[]>([]);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const clear = () => {
|
||||
spec.value = null;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const send = async (
|
||||
prompt: string,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
|
||||
isStreaming.value = true;
|
||||
error.value = null;
|
||||
usage.value = null;
|
||||
rawLines.value = [];
|
||||
|
||||
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) {
|
||||
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();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(currentSpec);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
||||
error.value = resolvedError;
|
||||
onError?.(resolvedError);
|
||||
} finally {
|
||||
isStreaming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
return {
|
||||
spec,
|
||||
isStreaming,
|
||||
error,
|
||||
usage,
|
||||
rawLines,
|
||||
send,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// flatToTree
|
||||
// =============================================================================
|
||||
|
||||
export function flatToTree(elements: FlatElement[]): Spec {
|
||||
const elementMap: Record<string, UIElement> = {};
|
||||
let root = "";
|
||||
|
||||
for (const element of elements) {
|
||||
elementMap[element.key] = {
|
||||
type: element.type,
|
||||
props: element.props,
|
||||
children: [],
|
||||
visible: element.visible,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// buildSpecFromParts / getTextFromParts
|
||||
// =============================================================================
|
||||
|
||||
export interface DataPart {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
export function useJsonRenderMessage(parts: Ref<DataPart[]>) {
|
||||
const result = ref<{ spec: Spec | null; text: string }>({
|
||||
spec: null,
|
||||
text: "",
|
||||
});
|
||||
|
||||
let prevParts: DataPart[] = [];
|
||||
|
||||
watch(
|
||||
parts,
|
||||
(newParts) => {
|
||||
const partsChanged =
|
||||
newParts !== prevParts &&
|
||||
(newParts.length !== prevParts.length ||
|
||||
newParts[newParts.length - 1] !== prevParts[prevParts.length - 1]);
|
||||
|
||||
if (partsChanged || prevParts.length === 0) {
|
||||
prevParts = newParts;
|
||||
result.value = {
|
||||
spec: buildSpecFromParts(newParts),
|
||||
text: getTextFromParts(newParts),
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
return {
|
||||
spec: computed(() => result.value.spec),
|
||||
text: computed(() => result.value.text),
|
||||
hasSpec: computed(() => {
|
||||
const s = result.value.spec;
|
||||
return s !== null && Object.keys(s.elements || {}).length > 0;
|
||||
}),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useChatUI
|
||||
// =============================================================================
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
spec: Spec | null;
|
||||
}
|
||||
|
||||
export interface UseChatUIOptions {
|
||||
api: string;
|
||||
onComplete?: (message: ChatMessage) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export interface UseChatUIReturn {
|
||||
messages: Ref<ChatMessage[]>;
|
||||
isStreaming: Ref<boolean>;
|
||||
error: Ref<Error | null>;
|
||||
send: (text: string) => Promise<void>;
|
||||
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}`;
|
||||
}
|
||||
|
||||
export function useChatUI({
|
||||
api,
|
||||
onComplete,
|
||||
onError,
|
||||
}: UseChatUIOptions): UseChatUIReturn {
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const isStreaming = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const clear = () => {
|
||||
messages.value = [];
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const send = async (text: string): Promise<void> => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
messages.value = [...messages.value, userMessage, assistantMessage];
|
||||
isStreaming.value = true;
|
||||
error.value = null;
|
||||
|
||||
const historyForApi = [
|
||||
...messages.value
|
||||
.filter((m) => m.id !== assistantId)
|
||||
.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.text,
|
||||
})),
|
||||
];
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
const finalMessage: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
text: accumulatedText,
|
||||
spec: hasSpec
|
||||
? {
|
||||
root: currentSpec.root,
|
||||
elements: { ...currentSpec.elements },
|
||||
...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
onComplete?.(finalMessage);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
||||
error.value = resolvedError;
|
||||
messages.value = messages.value.filter(
|
||||
(m) => m.id !== assistantId || m.text.length > 0,
|
||||
);
|
||||
onError?.(resolvedError);
|
||||
} finally {
|
||||
isStreaming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
isStreaming,
|
||||
error,
|
||||
send,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// useBoundProp
|
||||
// =============================================================================
|
||||
|
||||
export function useBoundProp<T>(
|
||||
propValue: T | undefined,
|
||||
bindingPath: string | undefined,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { set } = useStateStore();
|
||||
const setValue = (value: T) => {
|
||||
if (bindingPath) set(bindingPath, value);
|
||||
};
|
||||
return [propValue, setValue];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Composables – State
|
||||
export {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
StateKey,
|
||||
type StateContextValue,
|
||||
type StateProviderProps,
|
||||
} from "./composables/state.js";
|
||||
|
||||
// Composables – Visibility
|
||||
export {
|
||||
VisibilityProvider,
|
||||
useVisibility,
|
||||
useIsVisible,
|
||||
VisibilityKey,
|
||||
type VisibilityContextValue,
|
||||
} from "./composables/visibility.js";
|
||||
|
||||
// Composables – Actions
|
||||
export {
|
||||
ActionProvider,
|
||||
useActions,
|
||||
useAction,
|
||||
ActionKey,
|
||||
ConfirmDialog,
|
||||
type ActionContextValue,
|
||||
type ActionProviderProps,
|
||||
type PendingConfirmation,
|
||||
type ConfirmDialogProps,
|
||||
} from "./composables/actions.js";
|
||||
|
||||
// Composables – Validation
|
||||
export {
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
useOptionalValidation,
|
||||
useFieldValidation,
|
||||
ValidationKey,
|
||||
type ValidationContextValue,
|
||||
type ValidationProviderProps,
|
||||
type FieldValidationState,
|
||||
} from "./composables/validation.js";
|
||||
|
||||
// Composables – Repeat Scope
|
||||
export {
|
||||
RepeatScopeProvider,
|
||||
useRepeatScope,
|
||||
RepeatScopeKey,
|
||||
type RepeatScopeValue,
|
||||
} from "./composables/repeat-scope.js";
|
||||
|
||||
// Schema (Vue's spec format)
|
||||
export { schema, type VueSchema, type VueSpec } from "./schema.js";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec, StateStore } 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.js";
|
||||
|
||||
// Renderer
|
||||
export {
|
||||
defineRegistry,
|
||||
type DefineRegistryResult,
|
||||
createRenderer,
|
||||
type CreateRendererProps,
|
||||
type ComponentMap,
|
||||
Renderer,
|
||||
JSONUIProvider,
|
||||
type ComponentRenderProps,
|
||||
type ComponentRenderer,
|
||||
type ComponentRegistry,
|
||||
type RendererProps,
|
||||
type JSONUIProviderProps,
|
||||
} from "./renderer.js";
|
||||
|
||||
// Hooks / composables (streaming, chat, spec utilities)
|
||||
export {
|
||||
useUIStream,
|
||||
useChatUI,
|
||||
useJsonRenderMessage,
|
||||
useBoundProp,
|
||||
flatToTree,
|
||||
buildSpecFromParts,
|
||||
getTextFromParts,
|
||||
type TokenUsage,
|
||||
type UseUIStreamOptions,
|
||||
type UseUIStreamReturn,
|
||||
type UseChatUIOptions,
|
||||
type UseChatUIReturn,
|
||||
type ChatMessage,
|
||||
type DataPart,
|
||||
} from "./hooks.js";
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { defineComponent, h, createApp, nextTick, type VNode } from "vue";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
type ComponentRenderProps,
|
||||
} from "./renderer.js";
|
||||
import { useStateStore } from "./composables/state.js";
|
||||
import { useActions } from "./composables/actions.js";
|
||||
import { useBoundProp } from "./hooks.js";
|
||||
import type { EventHandle } from "./catalog-types.js";
|
||||
|
||||
// =============================================================================
|
||||
// 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: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
"data-testid": "btn",
|
||||
onClick: () => (props.emit as (e: string) => void)("press"),
|
||||
},
|
||||
(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: false },
|
||||
},
|
||||
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: false },
|
||||
},
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", { "data-testid": "stack" }, slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
const Input = defineComponent({
|
||||
name: "Input",
|
||||
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: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
const elProps = (props.element as any).props;
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
elProps.value as string | undefined,
|
||||
(props.bindings as Record<string, string> | undefined)?.value,
|
||||
);
|
||||
return h("input", {
|
||||
"data-testid": "input",
|
||||
value: value ?? "",
|
||||
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const StateProbe = defineComponent({
|
||||
name: "StateProbe",
|
||||
setup() {
|
||||
const { state } = useStateStore();
|
||||
return () =>
|
||||
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state));
|
||||
},
|
||||
});
|
||||
|
||||
import type { ComponentRegistry } from "./renderer.js";
|
||||
|
||||
const registry: ComponentRegistry = { Button, Text, Stack, Input } as any;
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function mountApp(spec: Spec, initialState?: Record<string, unknown>) {
|
||||
const container = document.createElement("div");
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
JSONUIProvider,
|
||||
{ registry, initialState: initialState ?? spec.state ?? {} },
|
||||
() => [h(Renderer, { spec, registry }), h(StateProbe)],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
|
||||
const getState = () => {
|
||||
const probe = container.querySelector("[data-testid='state-probe']");
|
||||
return JSON.parse(probe!.textContent!);
|
||||
};
|
||||
|
||||
return { container, app, getState };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("integration: basic rendering", () => {
|
||||
it("renders a simple spec with text", async () => {
|
||||
const spec: Spec = {
|
||||
root: "main",
|
||||
elements: {
|
||||
main: { type: "Text", props: { text: "Hello World" } },
|
||||
},
|
||||
};
|
||||
|
||||
const { container, app } = mountApp(spec);
|
||||
await nextTick();
|
||||
|
||||
const text = container.querySelector("[data-testid='text']");
|
||||
expect(text?.textContent).toBe("Hello World");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("renders nested elements", async () => {
|
||||
const spec: Spec = {
|
||||
root: "stack",
|
||||
elements: {
|
||||
stack: { type: "Stack", props: {}, children: ["t1", "t2"] },
|
||||
t1: { type: "Text", props: { text: "First" } },
|
||||
t2: { type: "Text", props: { text: "Second" } },
|
||||
},
|
||||
};
|
||||
|
||||
const { container, app } = mountApp(spec);
|
||||
await nextTick();
|
||||
|
||||
const texts = container.querySelectorAll("[data-testid='text']");
|
||||
expect(texts.length).toBe(2);
|
||||
expect(texts[0]?.textContent).toBe("First");
|
||||
expect(texts[1]?.textContent).toBe("Second");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: state binding", () => {
|
||||
it("resolves $state expressions in props", async () => {
|
||||
const spec: Spec = {
|
||||
root: "main",
|
||||
state: { greeting: "Hello from state" },
|
||||
elements: {
|
||||
main: { type: "Text", props: { text: { $state: "/greeting" } } },
|
||||
},
|
||||
};
|
||||
|
||||
const { container, app } = mountApp(spec);
|
||||
await nextTick();
|
||||
|
||||
const text = container.querySelector("[data-testid='text']");
|
||||
expect(text?.textContent).toBe("Hello from state");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: actions", () => {
|
||||
it("executes setState via action context", async () => {
|
||||
let storeRef: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsRef: { execute: (b: any) => Promise<void> } | undefined;
|
||||
|
||||
const Probe = defineComponent({
|
||||
name: "ActionProbe",
|
||||
setup() {
|
||||
storeRef = useStateStore();
|
||||
const { execute } = useActions();
|
||||
actionsRef = { execute };
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
JSONUIProvider,
|
||||
{ registry, initialState: { clicked: false } },
|
||||
() => h(Probe),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
expect(storeRef!.get("/clicked")).toBe(false);
|
||||
|
||||
await actionsRef!.execute({
|
||||
action: "setState",
|
||||
params: { statePath: "/clicked", value: true },
|
||||
});
|
||||
|
||||
expect(storeRef!.get("/clicked")).toBe(true);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("chains pushState and setState with $state resolution", async () => {
|
||||
let storeRef: ReturnType<typeof useStateStore> | undefined;
|
||||
let actionsRef: { execute: (b: any) => Promise<void> } | undefined;
|
||||
|
||||
const Probe = defineComponent({
|
||||
name: "ActionProbe",
|
||||
setup() {
|
||||
storeRef = useStateStore();
|
||||
const { execute } = useActions();
|
||||
actionsRef = { execute };
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
JSONUIProvider,
|
||||
{
|
||||
registry,
|
||||
initialState: { items: ["initial"], observed: "not yet set" },
|
||||
},
|
||||
() => h(Probe),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
await actionsRef!.execute({
|
||||
action: "pushState",
|
||||
params: { statePath: "/items", value: "new-item" },
|
||||
});
|
||||
|
||||
await actionsRef!.execute({
|
||||
action: "setState",
|
||||
params: {
|
||||
statePath: "/observed",
|
||||
value: { $state: "/items" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(storeRef!.get("/items")).toEqual(["initial", "new-item"]);
|
||||
expect(storeRef!.get("/observed")).toEqual(["initial", "new-item"]);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: visibility", () => {
|
||||
it("hides elements when visible is false", async () => {
|
||||
const spec: Spec = {
|
||||
root: "stack",
|
||||
state: { show: false },
|
||||
elements: {
|
||||
stack: { type: "Stack", props: {}, children: ["hidden", "visible"] },
|
||||
hidden: {
|
||||
type: "Text",
|
||||
props: { text: "Hidden" },
|
||||
visible: { $state: "/show" },
|
||||
},
|
||||
visible: { type: "Text", props: { text: "Visible" } },
|
||||
},
|
||||
};
|
||||
|
||||
const { container, app } = mountApp(spec);
|
||||
await nextTick();
|
||||
|
||||
const texts = container.querySelectorAll("[data-testid='text']");
|
||||
expect(texts.length).toBe(1);
|
||||
expect(texts[0]?.textContent).toBe("Visible");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("shows elements when visible condition is true", async () => {
|
||||
const spec: Spec = {
|
||||
root: "stack",
|
||||
state: { show: true },
|
||||
elements: {
|
||||
stack: { type: "Stack", props: {}, children: ["conditional"] },
|
||||
conditional: {
|
||||
type: "Text",
|
||||
props: { text: "Shown" },
|
||||
visible: { $state: "/show" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container, app } = mountApp(spec);
|
||||
await nextTick();
|
||||
|
||||
const texts = container.querySelectorAll("[data-testid='text']");
|
||||
expect(texts.length).toBe(1);
|
||||
expect(texts[0]?.textContent).toBe("Shown");
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: repeat", () => {
|
||||
it("renders repeated elements from state array", async () => {
|
||||
const spec: Spec = {
|
||||
root: "list",
|
||||
state: { items: ["apple", "banana", "cherry"] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["item"],
|
||||
},
|
||||
item: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "." } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container, app } = mountApp(spec);
|
||||
await nextTick();
|
||||
|
||||
const texts = container.querySelectorAll("[data-testid='text']");
|
||||
expect(texts.length).toBe(3);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { h, createApp, defineComponent } from "vue";
|
||||
import { Renderer, type ComponentRenderer } from "./renderer.js";
|
||||
|
||||
describe("Renderer", () => {
|
||||
it("renders nothing for null spec", () => {
|
||||
let rendered = false;
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () => {
|
||||
rendered = true;
|
||||
return h(Renderer, { spec: null, registry: {} });
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
expect(rendered).toBe(true);
|
||||
expect(container.innerHTML).toBe("<!---->"); // Vue comment node for null
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("renders nothing for spec without root", () => {
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Renderer, { spec: { root: "", elements: {} }, registry: {} });
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
expect(container.innerHTML).toBe("<!---->");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("accepts loading prop", () => {
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () => h(Renderer, { spec: null, registry: {}, loading: true });
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("accepts fallback prop", () => {
|
||||
const Fallback = defineComponent({
|
||||
setup() {
|
||||
return () => h("div", null, "Unknown component");
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(Renderer, {
|
||||
spec: null,
|
||||
registry: {},
|
||||
fallback: Fallback as unknown as ComponentRenderer,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
const app = createApp(App);
|
||||
app.mount(container);
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,844 @@
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
provide,
|
||||
inject,
|
||||
computed,
|
||||
unref,
|
||||
watch,
|
||||
onErrorCaptured,
|
||||
type Component,
|
||||
type ComputedRef,
|
||||
type DefineComponent,
|
||||
type PropType,
|
||||
type Ref,
|
||||
type VNode,
|
||||
type InjectionKey,
|
||||
} from "vue";
|
||||
import type {
|
||||
UIElement,
|
||||
Spec,
|
||||
ActionBinding,
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
ComputedFunction,
|
||||
} 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.js";
|
||||
import { useVisibility } from "./composables/visibility.js";
|
||||
import { useActions, ConfirmDialog } from "./composables/actions.js";
|
||||
import { useStateStore, StateProvider } from "./composables/state.js";
|
||||
import { VisibilityProvider } from "./composables/visibility.js";
|
||||
import { ActionProvider } from "./composables/actions.js";
|
||||
import { ValidationProvider } from "./composables/validation.js";
|
||||
import {
|
||||
RepeatScopeProvider,
|
||||
useRepeatScope,
|
||||
} from "./composables/repeat-scope.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComponentRenderProps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||
element: UIElement<string, P>;
|
||||
children?: VNode[];
|
||||
emit: (event: string) => void;
|
||||
on: (event: string) => EventHandle;
|
||||
bindings?: Record<string, string>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export type ComponentRenderer<P = Record<string, unknown>> = Component<
|
||||
ComponentRenderProps<P>
|
||||
>;
|
||||
|
||||
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FunctionsContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
|
||||
|
||||
const FunctionsKey: InjectionKey<
|
||||
| Ref<Record<string, ComputedFunction>>
|
||||
| ComputedRef<Record<string, ComputedFunction>>
|
||||
> = Symbol("json-render-functions");
|
||||
|
||||
function useFunctions():
|
||||
| Ref<Record<string, ComputedFunction>>
|
||||
| ComputedRef<Record<string, ComputedFunction>> {
|
||||
return inject(
|
||||
FunctionsKey,
|
||||
computed(() => EMPTY_FUNCTIONS),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementErrorBoundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ElementErrorBoundary = defineComponent({
|
||||
name: "ElementErrorBoundary",
|
||||
props: {
|
||||
elementType: { type: String, required: true },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
onErrorCaptured((error, _instance, info) => {
|
||||
console.error(
|
||||
`[json-render] Rendering error in <${props.elementType}>:`,
|
||||
error,
|
||||
info,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementRenderer & RepeatChildren
|
||||
// ---------------------------------------------------------------------------
|
||||
// These two components reference each other recursively, so we declare them
|
||||
// with explicit type annotations to satisfy DTS generation.
|
||||
|
||||
interface ElementRendererExposed {}
|
||||
interface RepeatChildrenExposed {}
|
||||
|
||||
const ElementRenderer: DefineComponent<
|
||||
{
|
||||
element: { type: PropType<UIElement>; required: true };
|
||||
spec: { type: PropType<Spec>; required: true };
|
||||
registry: { type: PropType<ComponentRegistry>; required: true };
|
||||
loading: { type: BooleanConstructor; default: false };
|
||||
fallback: { type: PropType<ComponentRenderer>; default: undefined };
|
||||
},
|
||||
ElementRendererExposed
|
||||
> = defineComponent({
|
||||
name: "ElementRenderer",
|
||||
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: false },
|
||||
fallback: {
|
||||
type: [Object, Function] as PropType<ComponentRenderer>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props): () => VNode | null {
|
||||
const repeatScope = useRepeatScope();
|
||||
const { ctx } = useVisibility();
|
||||
const { execute } = useActions();
|
||||
const storeCtx = useStateStore();
|
||||
const functionsRef = useFunctions();
|
||||
|
||||
const fullCtx = computed<PropResolutionContext>(() => {
|
||||
const base: PropResolutionContext = repeatScope
|
||||
? {
|
||||
...ctx,
|
||||
repeatItem: repeatScope.item,
|
||||
repeatIndex: repeatScope.index,
|
||||
repeatBasePath: repeatScope.basePath,
|
||||
}
|
||||
: { ...ctx };
|
||||
base.functions = unref(functionsRef);
|
||||
return base;
|
||||
});
|
||||
|
||||
// Watch effect for element.watch
|
||||
let prevWatchValues: Record<string, unknown> | null = null;
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const watchConfig = props.element.watch;
|
||||
if (!watchConfig) return undefined;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const path of Object.keys(watchConfig)) {
|
||||
values[path] = getByPath(storeCtx.state, path);
|
||||
}
|
||||
return values;
|
||||
},
|
||||
(watchedValues, _old, onCleanup) => {
|
||||
const watchConfig = props.element.watch;
|
||||
if (!watchConfig || !watchedValues) return;
|
||||
|
||||
const prev = prevWatchValues;
|
||||
prevWatchValues = watchedValues;
|
||||
|
||||
// Skip initial
|
||||
if (prev === null) return;
|
||||
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
const paths = Object.keys(watchConfig);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...fullCtx.value,
|
||||
stateModel: storeCtx.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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(console.error);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
return (): VNode | null => {
|
||||
const element = props.element;
|
||||
const currentCtx = fullCtx.value;
|
||||
|
||||
// Visibility
|
||||
const isVisible =
|
||||
element.visible === undefined
|
||||
? true
|
||||
: evaluateVisibility(element.visible, currentCtx);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
// Bindings & props
|
||||
const rawProps = element.props as Record<string, unknown>;
|
||||
const elementBindings = resolveBindings(rawProps, currentCtx);
|
||||
const resolvedProps = resolveElementProps(rawProps, currentCtx);
|
||||
|
||||
const resolvedElement =
|
||||
resolvedProps !== element.props
|
||||
? { ...element, props: resolvedProps }
|
||||
: element;
|
||||
|
||||
// Lookup component
|
||||
const Comp = props.registry[resolvedElement.type] ?? props.fallback;
|
||||
if (!Comp) {
|
||||
console.warn(`No renderer for component type: ${resolvedElement.type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build emit / on
|
||||
const onBindings = element.on;
|
||||
|
||||
const emit = async (eventName: string): Promise<void> => {
|
||||
const binding = onBindings?.[eventName];
|
||||
if (!binding) return;
|
||||
const actionBindings = Array.isArray(binding) ? binding : [binding];
|
||||
for (const b of actionBindings) {
|
||||
if (!b.params) {
|
||||
await execute(b);
|
||||
continue;
|
||||
}
|
||||
const liveCtx: PropResolutionContext = {
|
||||
...currentCtx,
|
||||
stateModel: storeCtx.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 });
|
||||
}
|
||||
};
|
||||
|
||||
const on = (eventName: string): EventHandle => {
|
||||
const binding = onBindings?.[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: () => emit(eventName),
|
||||
shouldPreventDefault,
|
||||
bound: true,
|
||||
};
|
||||
};
|
||||
|
||||
// Render children
|
||||
let children: VNode[] | undefined;
|
||||
|
||||
if (resolvedElement.repeat) {
|
||||
children = [
|
||||
h(RepeatChildren, {
|
||||
element: resolvedElement,
|
||||
spec: props.spec,
|
||||
registry: props.registry,
|
||||
loading: props.loading,
|
||||
fallback: props.fallback,
|
||||
}),
|
||||
];
|
||||
} else if (resolvedElement.children) {
|
||||
children = resolvedElement.children
|
||||
.map((childKey): VNode | null => {
|
||||
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((v): v is VNode => v !== null);
|
||||
}
|
||||
|
||||
return h(
|
||||
ElementErrorBoundary,
|
||||
{ elementType: resolvedElement.type },
|
||||
() =>
|
||||
h(
|
||||
Comp as Component,
|
||||
{
|
||||
element: resolvedElement,
|
||||
emit,
|
||||
on,
|
||||
bindings: elementBindings,
|
||||
loading: props.loading,
|
||||
},
|
||||
children ? () => children : undefined,
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
// Cast required: ElementRenderer and RepeatChildren reference each other
|
||||
// recursively, which prevents TypeScript from inferring the component type.
|
||||
// The explicit DefineComponent annotation above provides the correct type.
|
||||
}) as any;
|
||||
|
||||
const RepeatChildren: DefineComponent<
|
||||
{
|
||||
element: { type: PropType<UIElement>; required: true };
|
||||
spec: { type: PropType<Spec>; required: true };
|
||||
registry: { type: PropType<ComponentRegistry>; required: true };
|
||||
loading: { type: BooleanConstructor; default: false };
|
||||
fallback: { type: PropType<ComponentRenderer>; default: undefined };
|
||||
},
|
||||
RepeatChildrenExposed
|
||||
> = defineComponent({
|
||||
name: "RepeatChildren",
|
||||
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: false },
|
||||
fallback: {
|
||||
type: [Object, Function] as PropType<ComponentRenderer>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props): () => VNode[] {
|
||||
const storeCtx = useStateStore();
|
||||
|
||||
return (): VNode[] => {
|
||||
const repeat = props.element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
const items =
|
||||
(getByPath(storeCtx.state, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
return items.map((itemValue, index): VNode => {
|
||||
const key =
|
||||
repeat.key && typeof itemValue === "object" && itemValue !== null
|
||||
? String(
|
||||
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
|
||||
)
|
||||
: String(index);
|
||||
|
||||
const childNodes: VNode[] = (props.element.children ?? [])
|
||||
.map((childKey): VNode | null => {
|
||||
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((v): v is VNode => v !== null);
|
||||
|
||||
return h(
|
||||
RepeatScopeProvider,
|
||||
{
|
||||
key,
|
||||
item: itemValue,
|
||||
index,
|
||||
basePath: `${statePath}/${index}`,
|
||||
},
|
||||
() => childNodes,
|
||||
);
|
||||
});
|
||||
};
|
||||
},
|
||||
// Cast required: see ElementRenderer comment above (mutual recursion).
|
||||
}) as any;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RendererProps {
|
||||
spec: Spec | null;
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
|
||||
export const Renderer = defineComponent({
|
||||
name: "Renderer",
|
||||
props: {
|
||||
spec: {
|
||||
type: Object as PropType<Spec | null>,
|
||||
default: null,
|
||||
},
|
||||
registry: {
|
||||
type: Object as PropType<ComponentRegistry>,
|
||||
required: true,
|
||||
},
|
||||
loading: { type: Boolean, default: false },
|
||||
fallback: {
|
||||
type: [Object, Function] as PropType<ComponentRenderer>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (!props.spec || !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 actions = useActions();
|
||||
|
||||
return () => {
|
||||
const pending = actions.pendingConfirmation;
|
||||
if (!pending?.action.confirm) return null;
|
||||
|
||||
return h(ConfirmDialog, {
|
||||
confirm: pending.action.confirm,
|
||||
onConfirm: actions.confirm,
|
||||
onCancel: actions.cancel,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
>;
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
}
|
||||
|
||||
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: () => ({}),
|
||||
},
|
||||
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 }) {
|
||||
provide(
|
||||
FunctionsKey,
|
||||
computed(() => props.functions ?? EMPTY_FUNCTIONS),
|
||||
);
|
||||
|
||||
return () =>
|
||||
h(
|
||||
StateProvider,
|
||||
{
|
||||
store: props.store,
|
||||
initialState: props.initialState,
|
||||
onStateChange: props.onStateChange,
|
||||
},
|
||||
() =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(
|
||||
ValidationProvider,
|
||||
{ customFunctions: props.validationFunctions },
|
||||
() =>
|
||||
h(
|
||||
ActionProvider,
|
||||
{ handlers: props.handlers, navigate: props.navigate },
|
||||
() => [slots.default?.(), h(ConfirmationDialogManager)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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[];
|
||||
emit: (event: string) => void;
|
||||
on: (event: string) => EventHandle;
|
||||
bindings?: Record<string, string>;
|
||||
loading?: boolean;
|
||||
}) => VNode | VNode[] | null;
|
||||
|
||||
type DefineRegistryActionFn = (
|
||||
params: Record<string, unknown> | undefined,
|
||||
setState: SetState,
|
||||
state: StateModel,
|
||||
) => Promise<void>;
|
||||
|
||||
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: `JR_${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: false },
|
||||
},
|
||||
setup(compProps, { slots }) {
|
||||
return () => {
|
||||
const children = slots.default?.();
|
||||
return (componentFn as DefineRegistryComponentFn)({
|
||||
props: compProps.element.props,
|
||||
children,
|
||||
emit: compProps.emit as (event: string) => void,
|
||||
on: compProps.on as (event: string) => EventHandle,
|
||||
bindings: compProps.bindings,
|
||||
loading: compProps.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 executeActionFn = 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: executeActionFn };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
|
||||
export type ComponentMap<
|
||||
TComponents extends Record<string, { props: unknown }>,
|
||||
> = {
|
||||
[K in keyof TComponents]: Component<
|
||||
ComponentRenderProps<
|
||||
TComponents[K]["props"] extends { _output: infer O }
|
||||
? O
|
||||
: Record<string, unknown>
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
export function createRenderer<
|
||||
TDef extends SchemaDefinition,
|
||||
TCatalog extends { components: Record<string, { props: unknown }> },
|
||||
>(
|
||||
_catalog: Catalog<TDef, TCatalog>,
|
||||
components: ComponentMap<TCatalog["components"]>,
|
||||
): Component<CreateRendererProps> {
|
||||
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: false },
|
||||
fallback: {
|
||||
type: [Object, Function] as PropType<ComponentRenderer>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
provide(
|
||||
FunctionsKey,
|
||||
computed(() => props.functions ?? EMPTY_FUNCTIONS),
|
||||
);
|
||||
|
||||
return () => {
|
||||
const actionHandlers = props.onAction
|
||||
? new Proxy(
|
||||
{} as Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void | Promise<void>
|
||||
>,
|
||||
{
|
||||
get: (_target, prop: string) => {
|
||||
return (params: Record<string, unknown>) =>
|
||||
props.onAction!(prop, params);
|
||||
},
|
||||
has: () => true,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return h(
|
||||
StateProvider,
|
||||
{
|
||||
store: props.store,
|
||||
initialState: props.state,
|
||||
onStateChange: props.onStateChange,
|
||||
},
|
||||
() =>
|
||||
h(VisibilityProvider, null, () =>
|
||||
h(ValidationProvider, null, () =>
|
||||
h(ActionProvider, { handlers: actionHandlers }, () => [
|
||||
h(Renderer, {
|
||||
spec: props.spec,
|
||||
registry,
|
||||
loading: props.loading,
|
||||
fallback: props.fallback,
|
||||
}),
|
||||
h(ConfirmationDialogManager),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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) => ({
|
||||
spec: s.object({
|
||||
root: s.string(),
|
||||
elements: s.record(
|
||||
s.object({
|
||||
type: s.ref("catalog.components"),
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
visible: s.any(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
catalog: s.object({
|
||||
components: s.map({
|
||||
props: s.zod(),
|
||||
slots: s.array(s.string()),
|
||||
description: s.string(),
|
||||
example: s.any(),
|
||||
}),
|
||||
actions: s.map({
|
||||
params: s.zod(),
|
||||
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 }.",
|
||||
},
|
||||
],
|
||||
defaultRules: [
|
||||
"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.",
|
||||
'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.',
|
||||
"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 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.",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export type VueSchema = typeof schema;
|
||||
|
||||
export type VueSpec<TCatalog> = typeof schema extends {
|
||||
createCatalog: (catalog: TCatalog) => { _specType: infer S };
|
||||
}
|
||||
? S
|
||||
: never;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/vue-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user