Compare commits

...
Author SHA1 Message Date
Chris Tate 5296dfb09a fix ci 2026-02-25 09:40:36 -06:00
Chris Tate 4562b4461f fix ci 2026-02-25 09:28:33 -06:00
Chris Tate 77eee851ec fixes 2026-02-25 09:17:11 -06:00
Chris Tate 43c428635b fixes 2026-02-25 09:03:19 -06:00
Chris Tate 4c902d0155 fixes 2026-02-25 08:42:06 -06:00
Chris Tate 63b5078f9d vue improvements 2026-02-25 08:31:23 -06:00
16 changed files with 756 additions and 97 deletions
+2 -2
View File
@@ -241,6 +241,6 @@ const {
| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
+1 -1
View File
@@ -96,7 +96,7 @@ export const demoSpec: Spec = {
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Spec } from "@json-render/vue";
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
@@ -78,7 +78,7 @@ export const demoSpec: Spec = {
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "🎉 Milestone reached: 10!", color: "#10b981" },
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
+7 -1
View File
@@ -251,9 +251,15 @@ export function ActionProvider({
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid });
set(statePath, { valid, errors });
return;
}
+31 -19
View File
@@ -3,6 +3,7 @@
import React, {
createContext,
useContext,
useRef,
useState,
useCallback,
useMemo,
@@ -134,6 +135,9 @@ export function ValidationProvider({
const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState>
>({});
// Mutable mirror of fieldStates for synchronous reads (e.g. reading errors
// immediately after validateAll() before React flushes the batched setState).
const fieldStatesRef = useRef<Record<string, FieldValidationState>>({});
const [fieldConfigs, setFieldConfigs] = useState<
Record<string, ValidationConfig>
>({});
@@ -175,14 +179,16 @@ export function ValidationProvider({
customFunctions,
});
setFieldStates((prev) => ({
...prev,
[path]: {
touched: prev[path]?.touched ?? true,
validated: true,
result,
},
}));
const newFieldState: FieldValidationState = {
touched: fieldStatesRef.current[path]?.touched ?? true,
validated: true,
result,
};
fieldStatesRef.current = {
...fieldStatesRef.current,
[path]: newFieldState,
};
setFieldStates(fieldStatesRef.current);
return result;
},
@@ -190,22 +196,22 @@ export function ValidationProvider({
);
const touch = useCallback((path: string) => {
setFieldStates((prev) => ({
...prev,
fieldStatesRef.current = {
...fieldStatesRef.current,
[path]: {
...prev[path],
...fieldStatesRef.current[path],
touched: true,
validated: prev[path]?.validated ?? false,
result: prev[path]?.result ?? null,
validated: fieldStatesRef.current[path]?.validated ?? false,
result: fieldStatesRef.current[path]?.result ?? null,
},
}));
};
setFieldStates(fieldStatesRef.current);
}, []);
const clear = useCallback((path: string) => {
setFieldStates((prev) => {
const { [path]: _, ...rest } = prev;
return rest;
});
const { [path]: _, ...rest } = fieldStatesRef.current;
fieldStatesRef.current = rest;
setFieldStates(rest);
}, []);
const validateAll = useCallback(() => {
@@ -224,7 +230,11 @@ export function ValidationProvider({
const value = useMemo<ValidationContextValue>(
() => ({
customFunctions,
fieldStates,
// Getter returns the mutable ref so callers that read fieldStates
// synchronously after validateAll() see the latest values.
get fieldStates() {
return fieldStatesRef.current;
},
validate,
touch,
clear,
@@ -233,6 +243,8 @@ export function ValidationProvider({
}),
[
customFunctions,
// fieldStates (React state) stays in deps so the context value object
// is recreated on re-render, triggering downstream consumers.
fieldStates,
validate,
touch,
+6 -3
View File
@@ -546,7 +546,10 @@ describe("validateForm action", () => {
});
const state = getState();
expect(state.result).toEqual({ valid: false });
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
it("writes { valid: true } when all fields pass validation", async () => {
@@ -601,7 +604,7 @@ describe("validateForm action", () => {
});
const state = getState();
expect(state.result).toEqual({ valid: true });
expect(state.result).toEqual({ valid: true, errors: {} });
});
it("defaults to /formValidation when no statePath is provided", async () => {
@@ -651,7 +654,7 @@ describe("validateForm action", () => {
});
const state = getState();
expect(state.formValidation).toEqual({ valid: true });
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
+1 -1
View File
@@ -70,7 +70,7 @@ export const schema = defineSchema(
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
},
],
defaultRules: [
+4 -4
View File
@@ -91,7 +91,7 @@ export const { registry } = defineRegistry(catalog, {
});
```
> **Note:** Vue does not have `useBoundProp`. For two-way binding, use `{ "$bindState": "/path" }` on the value prop and handle the `bindings` object in your component.
> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
### 3. Render Specs
@@ -345,7 +345,7 @@ See [@json-render/core](../core/README.md) for full expression syntax.
## Built-in Actions
The `setState`, `pushState`, and `removeState` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
```json
{
@@ -492,5 +492,5 @@ const spec = {
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | not available | React-specific; use `bindings` directly in Vue |
| Streaming hooks | `useUIStream`, `useChatUI` | not available | Vue package is UI-only for now |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
+25
View File
@@ -18,6 +18,7 @@ import {
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
/**
* Generate a unique ID for use with the "$id" token.
@@ -120,6 +121,7 @@ export const ActionProvider = defineComponent({
},
setup(props, { slots }) {
const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
const loadingActions = ref<Set<string>>(new Set());
@@ -182,6 +184,29 @@ export const ActionProvider = defineComponent({
return;
}
// Built-in: validateForm — triggers validateAll and writes result to state
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
// Built-in: push (navigation)
if (resolved.action === "push" && resolved.params) {
const screen = resolved.params.screen as string;
+482
View File
@@ -0,0 +1,482 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, nextTick, type Component } from "vue";
import { mount } from "@vue/test-utils";
import type { Spec } from "@json-render/core";
import { StateProvider, useStateStore } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { useFieldValidation } from "./composables/validation";
import { useBoundProp } from "./hooks";
import {
Renderer,
JSONUIProvider,
type ComponentRegistry,
type ComponentRenderProps,
} from "./renderer";
// =============================================================================
// Stub components
// =============================================================================
const Button = defineComponent({
name: "Button",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
return () =>
h(
"button",
{ "data-testid": "btn", onClick: () => props.emit("press") },
String((props.element as any).props?.label ?? ""),
);
},
});
const Text = defineComponent({
name: "Text",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
return () => {
const value = (props.element as any).props?.text;
return h(
"span",
{ "data-testid": "text" },
value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value),
);
};
},
});
const Stack = defineComponent({
name: "Stack",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(_props, { slots }) {
return () => h("div", { "data-testid": "stack" }, slots.default?.());
},
});
const InputField = defineComponent({
name: "InputField",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
const elProps = (props.element as any).props ?? {};
const bindingPath = (props.bindings as Record<string, string>)?.value;
const [boundValue, setBoundValue] = useBoundProp<string>(
elProps.value as string | undefined,
bindingPath,
);
const hasValidation = !!(bindingPath && elProps.checks?.length);
const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined;
const { errors } = useFieldValidation(bindingPath ?? "", config);
return () =>
h("div", null, [
elProps.label ? h("label", null, elProps.label) : null,
h("input", {
"data-testid": "input",
value: boundValue ?? "",
onInput: (e: Event) =>
setBoundValue((e.target as HTMLInputElement).value),
}),
errors.value.length > 0
? h("span", { "data-testid": "input-error" }, errors.value[0])
: null,
]);
},
});
const Select = defineComponent({
name: "Select",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
const elProps = (props.element as any).props ?? {};
const bindingPath = (props.bindings as Record<string, string>)?.value;
const [boundValue] = useBoundProp<string>(
elProps.value as string | undefined,
bindingPath,
);
return () => h("span", { "data-testid": "select-value" }, boundValue ?? "");
},
});
const registry: ComponentRegistry = {
Button: Button as unknown as Component,
Text: Text as unknown as Component,
Input: InputField as unknown as Component,
Select: Select as unknown as Component,
Stack: Stack as unknown as Component,
};
// State probe to read state from tests
const StateProbe = defineComponent({
name: "StateProbe",
setup() {
const { state } = useStateStore();
return () =>
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value));
},
});
function getState(wrapper: ReturnType<typeof mount>): Record<string, unknown> {
return JSON.parse(wrapper.find("[data-testid='state-probe']").text());
}
// =============================================================================
// Mount helper
// =============================================================================
function mountWithProviders(
spec: Spec,
opts: {
functions?: Record<string, (args: Record<string, unknown>) => unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
} = {},
) {
return mount(JSONUIProvider as Component, {
props: {
registry,
initialState: opts.initialState ?? spec.state ?? {},
functions: opts.functions,
handlers: opts.handlers,
} as any,
slots: {
default: () => [h(Renderer, { spec, registry }), h(StateProbe)],
},
});
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
const wrapper = mountWithProviders(spec, { functions });
expect(wrapper.find("[data-testid='text']").text()).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
expect(wrapper.find("[data-testid='text']").text()).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
const loadCities = vi.fn();
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec, { handlers: { loadCities } });
expect(loadCities).not.toHaveBeenCalled();
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
await nextTick();
expect(loadCities).toHaveBeenCalledTimes(1);
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
const action1 = vi.fn();
const action2 = vi.fn();
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec, {
handlers: { action1, action2 },
});
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
await nextTick();
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false, errors } when a required field is empty", async () => {
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
it("writes { valid: true, errors: {} } when all fields pass validation", async () => {
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.result).toEqual({ valid: true, errors: {} });
});
it("defaults to /formValidation when no statePath is provided", async () => {
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
+2 -6
View File
@@ -256,17 +256,16 @@ export function useUIStream({
const usage = ref<TokenUsage | null>(null);
const rawLines = ref<string[]>([]);
// Keep latest callbacks (Vue refs are always current — no stale closure issues)
const onCompleteRef = ref(onComplete);
onCompleteRef.value = onComplete;
const onErrorRef = ref(onError);
onErrorRef.value = onError;
let abortController: AbortController | null = null;
const clear = () => {
spec.value = null;
error.value = null;
usage.value = null;
rawLines.value = [];
};
const send = async (
@@ -670,11 +669,8 @@ export function useChatUI({
const isStreaming = ref(false);
const error = ref<Error | null>(null);
// Keep latest callbacks (Vue refs are always current — no stale closure issues)
const onCompleteRef = ref(onComplete);
onCompleteRef.value = onComplete;
const onErrorRef = ref(onError);
onErrorRef.value = onError;
let abortController: AbortController | null = null;
+1 -1
View File
@@ -46,7 +46,7 @@ export {
export { schema, type VueSchema, type VueSpec } from "./schema";
// Core types (re-exported for convenience)
export type { Spec, StateStore } from "@json-render/core";
export type { Spec, StateStore, ComputedFunction } from "@json-render/core";
export { createStateStore } from "@json-render/core";
// Catalog-aware types for Vue
+2 -2
View File
@@ -56,9 +56,9 @@ function mountRenderer(
default: () =>
h(VisibilityProvider as Component, null, {
default: () =>
h(ActionProvider as Component, { handlers } as any, {
h(ValidationProvider as Component, null, {
default: () =>
h(ValidationProvider as Component, null, {
h(ActionProvider as Component, { handlers } as any, {
default: () =>
h(Renderer, { spec, registry: reg, ...extraProps }),
}),
+182 -55
View File
@@ -2,9 +2,13 @@ import {
computed,
defineComponent,
h,
inject,
onErrorCaptured,
provide,
ref,
watch,
type Component,
type ComputedRef,
type PropType,
type VNode,
} from "vue";
@@ -13,6 +17,7 @@ import type {
Spec,
ActionBinding,
Catalog,
ComputedFunction,
SchemaDefinition,
StateStore,
} from "@json-render/core";
@@ -80,6 +85,35 @@ export interface RendererProps {
fallback?: Component;
}
// ---------------------------------------------------------------------------
// FunctionsContext — provides $computed functions to the element tree
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FUNCTIONS_KEY = Symbol("json-render:functions");
const FunctionsProvider = defineComponent({
name: "FunctionsProvider",
props: {
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
},
setup(props, { slots }) {
const fns = computed(() => props.functions ?? EMPTY_FUNCTIONS);
provide(FUNCTIONS_KEY, fns);
return () => slots.default?.();
},
});
function useFunctions(): ComputedRef<Record<string, ComputedFunction>> {
return inject<ComputedRef<Record<string, ComputedFunction>>>(
FUNCTIONS_KEY,
computed(() => EMPTY_FUNCTIONS),
);
}
// ---------------------------------------------------------------------------
// ElementErrorBoundary — catches rendering errors in individual elements
// ---------------------------------------------------------------------------
@@ -111,6 +145,37 @@ const ElementErrorBoundary = defineComponent({
},
});
// ---------------------------------------------------------------------------
// resolveAndExecuteBindings — shared helper for emitEvent / watch handlers
// ---------------------------------------------------------------------------
async function resolveAndExecuteBindings(
actionBindings: ActionBinding[],
ctx: PropResolutionContext,
getSnapshot: () => Record<string, unknown>,
execute: (binding: ActionBinding) => Promise<void>,
cancelled?: () => boolean,
): Promise<void> {
for (const b of actionBindings) {
if (cancelled?.()) break;
if (!b.params) {
await execute(b);
if (cancelled?.()) break;
continue;
}
const liveCtx: PropResolutionContext = {
...ctx,
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
if (cancelled?.()) break;
}
}
// ---------------------------------------------------------------------------
// ElementRenderer — renders a single element from the spec
// ---------------------------------------------------------------------------
@@ -151,19 +216,20 @@ const ElementRenderer = defineComponent({
const repeatScope = useRepeatScope();
const { ctx: visibilityCtx } = useVisibility();
const { execute } = useActions();
const { getSnapshot } = useStateStore();
const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
// Build context with repeat scope
// Build context with repeat scope and $computed functions
const fullCtx = computed<PropResolutionContext>(() => {
const base = visibilityCtx.value;
if (repeatScope) {
return {
...base,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
};
}
const base: PropResolutionContext = repeatScope
? {
...visibilityCtx.value,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: { ...visibilityCtx.value };
base.functions = functions.value;
return base;
});
@@ -172,21 +238,12 @@ const ElementRenderer = defineComponent({
const binding = props.element.on?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
for (const b of actionBindings) {
if (!b.params) {
await execute(b);
continue;
}
const liveCtx: PropResolutionContext = {
...fullCtx.value,
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
}
await resolveAndExecuteBindings(
actionBindings,
fullCtx.value,
getSnapshot,
execute,
);
};
// Create on() function
@@ -206,6 +263,49 @@ const ElementRenderer = defineComponent({
};
};
// Watch effect: fire actions when watched state paths change.
const watchedValues = computed(() => {
const cfg = props.element.watch;
if (!cfg) return undefined;
const values: Record<string, unknown> = {};
for (const path of Object.keys(cfg)) {
values[path] = getByPath(watchState.value, path);
}
return values;
});
watch(
watchedValues,
(current, prev, onCleanup) => {
const cfg = props.element.watch;
if (!cfg || !current) return;
let cancelled = false;
onCleanup(() => {
cancelled = true;
});
const paths = Object.keys(cfg);
void (async () => {
for (const path of paths) {
if (cancelled) break;
if (prev && current[path] === prev[path]) continue;
const binding = cfg[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
await resolveAndExecuteBindings(
bindings,
fullCtx.value,
getSnapshot,
execute,
() => cancelled,
);
}
})().catch(console.error);
},
{ deep: true },
);
return () => {
const ctx = fullCtx.value;
@@ -322,10 +422,11 @@ const RepeatChildren = defineComponent({
const { state } = useStateStore();
return () => {
const repeat = props.element.repeat!;
const repeat = props.element.repeat;
if (!repeat?.statePath) return null;
const statePath = repeat.statePath;
const items =
(getByPath(state.value, statePath) as unknown[] | undefined) ?? [];
const raw = getByPath(state.value, statePath);
const items = Array.isArray(raw) ? (raw as unknown[]) : [];
return items.map((itemValue, index) => {
const key =
@@ -454,6 +555,8 @@ export interface JSONUIProviderProps {
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
@@ -497,6 +600,10 @@ export const JSONUIProvider = defineComponent({
>,
default: undefined,
},
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
onStateChange: {
type: Function as PropType<
(changes: Array<{ path: string; value: unknown }>) => void
@@ -518,18 +625,25 @@ export const JSONUIProvider = defineComponent({
h(VisibilityProvider, null, {
default: () =>
h(
ActionProvider,
{ handlers: props.handlers, navigate: props.navigate },
ValidationProvider,
{ customFunctions: props.validationFunctions },
{
default: () =>
h(
ValidationProvider,
{ customFunctions: props.validationFunctions },
ActionProvider,
{ handlers: props.handlers, navigate: props.navigate },
{
default: () => [
slots.default?.(),
h(ConfirmationDialogManager),
],
default: () =>
h(
FunctionsProvider,
{ functions: props.functions },
{
default: () => [
slots.default?.(),
h(ConfirmationDialogManager),
],
},
),
},
),
},
@@ -704,6 +818,8 @@ export interface CreateRendererProps {
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
loading?: boolean;
fallback?: Component;
}
@@ -768,6 +884,10 @@ export function createRenderer<
>,
default: undefined,
},
functions: {
type: Object as PropType<Record<string, ComputedFunction>>,
default: undefined,
},
loading: {
type: Boolean,
default: undefined,
@@ -807,24 +927,31 @@ export function createRenderer<
default: () =>
h(VisibilityProvider, null, {
default: () =>
h(
ActionProvider,
{ handlers: actionHandlers },
{
default: () =>
h(ValidationProvider, null, {
default: () => [
h(Renderer, {
spec: rendererProps.spec,
registry,
loading: rendererProps.loading,
fallback: rendererProps.fallback,
}),
h(ConfirmationDialogManager),
],
}),
},
),
h(ValidationProvider, null, {
default: () =>
h(
ActionProvider,
{ handlers: actionHandlers },
{
default: () =>
h(
FunctionsProvider,
{ functions: rendererProps.functions },
{
default: () => [
h(Renderer, {
spec: rendererProps.spec,
registry,
loading: rendererProps.loading,
fallback: rendererProps.fallback,
}),
h(ConfirmationDialogManager),
],
},
),
},
),
}),
}),
},
);
+5
View File
@@ -67,6 +67,11 @@ export const schema = defineSchema(
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
},
],
defaultRules: [
// Element integrity
+3
View File
@@ -1212,6 +1212,9 @@ importers:
'@json-render/core':
specifier: workspace:*
version: link:../core
zod:
specifier: ^4.0.0
version: 4.3.6
devDependencies:
'@internal/typescript-config':
specifier: workspace:*