diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cbc28082..21f8c4f4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -68,7 +68,7 @@ export interface UIElement< /** Event bindings — maps event names to action bindings */ on?: Record; /** Repeat children once per item in a state array */ - repeat?: { statePath: string; key?: string }; + repeat?: { statePath: string | { $item: 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. diff --git a/packages/image/src/render.tsx b/packages/image/src/render.tsx index 71fd5b3b..31e50716 100644 --- a/packages/image/src/render.tsx +++ b/packages/image/src/render.tsx @@ -60,21 +60,39 @@ function renderElement( if (!Component) return null; if (resolvedElement.repeat) { + const repeat = resolvedElement.repeat; + let statePath: string; + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (repeatBasePath) { + statePath = + field === "" ? repeatBasePath : `${repeatBasePath}/${field}`; + } else { + console.warn( + "[json-render/image] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } + const items = - (getByPath(stateModel, resolvedElement.repeat.statePath) as - | unknown[] - | undefined) ?? []; + (getByPath(stateModel, statePath) as unknown[] | undefined) ?? []; const fragments = items.map((item, index) => { const key = - resolvedElement.repeat!.key && typeof item === "object" && item !== null - ? String( - (item as Record)[resolvedElement.repeat!.key!] ?? - index, - ) + repeat.key && typeof item === "object" && item !== null + ? String((item as Record)[repeat.key!] ?? index) : String(index); - const childPath = `${resolvedElement.repeat!.statePath}/${index}`; + const childPath = `${statePath}/${index}`; const children = resolvedElement.children?.map((childKey) => renderElement( childKey, diff --git a/packages/ink/src/renderer.tsx b/packages/ink/src/renderer.tsx index 6bc68ea4..47e432b3 100644 --- a/packages/ink/src/renderer.tsx +++ b/packages/ink/src/renderer.tsx @@ -320,8 +320,32 @@ function RepeatChildren({ fallback?: ComponentRenderer; }) { const { state } = useStateStore(); + const parentScope = useRepeatScope(); const repeat = element.repeat!; - const statePath = repeat.statePath; + let statePath: string; + + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (parentScope) { + statePath = + field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } else { + console.warn( + "[json-render/ink] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } const raw = getByPath(state, statePath); const items = Array.isArray(raw) ? raw : []; diff --git a/packages/react-email/src/render.tsx b/packages/react-email/src/render.tsx index 2740432d..7fcfa3bc 100644 --- a/packages/react-email/src/render.tsx +++ b/packages/react-email/src/render.tsx @@ -57,12 +57,32 @@ function renderElement( if (!Component) return null; if (resolvedElement.repeat) { + const repeat = resolvedElement.repeat; + let statePath: string; + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (repeatBasePath) { + statePath = + field === "" ? repeatBasePath : `${repeatBasePath}/${field}`; + } else { + console.warn( + "[json-render/react-email] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } + const items = - (getByPath(stateModel, resolvedElement.repeat.statePath) as - | unknown[] - | undefined) ?? []; + (getByPath(stateModel, statePath) as unknown[] | undefined) ?? []; - const repeat = resolvedElement.repeat!; const fragments = items.map((item, index) => { const repeatKey = repeat.key; const key = @@ -70,7 +90,7 @@ function renderElement( ? String((item as Record)[repeatKey] ?? index) : String(index); - const childPath = `${repeat.statePath}/${index}`; + const childPath = `${statePath}/${index}`; const children = resolvedElement.children?.map((childKey) => renderElement( childKey, diff --git a/packages/react-email/src/renderer.tsx b/packages/react-email/src/renderer.tsx index 4947010a..c6ebda44 100644 --- a/packages/react-email/src/renderer.tsx +++ b/packages/react-email/src/renderer.tsx @@ -248,8 +248,32 @@ function RepeatChildren({ fallback?: ComponentRenderer; }) { const { state } = useStateStore(); + const parentScope = useRepeatScope(); const repeat = element.repeat!; - const statePath = repeat.statePath; + let statePath: string; + + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (parentScope) { + statePath = + field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } else { + console.warn( + "[json-render/react-email] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; diff --git a/packages/react-native/src/renderer.tsx b/packages/react-native/src/renderer.tsx index f9601a8b..f73b8281 100644 --- a/packages/react-native/src/renderer.tsx +++ b/packages/react-native/src/renderer.tsx @@ -300,8 +300,32 @@ function RepeatChildren({ fallback?: ComponentRenderer; }) { const { state } = useStateStore(); + const parentScope = useRepeatScope(); const repeat = element.repeat!; - const statePath = repeat.statePath; + let statePath: string; + + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (parentScope) { + statePath = + field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } else { + console.warn( + "[json-render/react-native] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; diff --git a/packages/react-pdf/src/render.tsx b/packages/react-pdf/src/render.tsx index 695c3eda..e6247471 100644 --- a/packages/react-pdf/src/render.tsx +++ b/packages/react-pdf/src/render.tsx @@ -61,21 +61,39 @@ function renderElement( if (!Component) return null; if (resolvedElement.repeat) { + const repeat = resolvedElement.repeat; + let statePath: string; + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (repeatBasePath) { + statePath = + field === "" ? repeatBasePath : `${repeatBasePath}/${field}`; + } else { + console.warn( + "[json-render/react-pdf] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } + const items = - (getByPath(stateModel, resolvedElement.repeat.statePath) as - | unknown[] - | undefined) ?? []; + (getByPath(stateModel, statePath) as unknown[] | undefined) ?? []; const fragments = items.map((item, index) => { const key = - resolvedElement.repeat!.key && typeof item === "object" && item !== null - ? String( - (item as Record)[resolvedElement.repeat!.key!] ?? - index, - ) + repeat.key && typeof item === "object" && item !== null + ? String((item as Record)[repeat.key!] ?? index) : String(index); - const childPath = `${resolvedElement.repeat!.statePath}/${index}`; + const childPath = `${statePath}/${index}`; const children = resolvedElement.children?.map((childKey) => renderElement( childKey, diff --git a/packages/react-pdf/src/renderer.tsx b/packages/react-pdf/src/renderer.tsx index 413e1704..3824a590 100644 --- a/packages/react-pdf/src/renderer.tsx +++ b/packages/react-pdf/src/renderer.tsx @@ -256,8 +256,32 @@ function RepeatChildren({ fallback?: ComponentRenderer; }) { const { state } = useStateStore(); + const parentScope = useRepeatScope(); const repeat = element.repeat!; - const statePath = repeat.statePath; + let statePath: string; + + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (parentScope) { + statePath = + field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } else { + console.warn( + "[json-render/react-pdf] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; diff --git a/packages/react/src/renderer.test.tsx b/packages/react/src/renderer.test.tsx index 7afaf7bb..e03fe6d9 100644 --- a/packages/react/src/renderer.test.tsx +++ b/packages/react/src/renderer.test.tsx @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import React from "react"; -import { Renderer } from "./renderer"; +import { render, screen } from "@testing-library/react"; +import type { Spec } from "@json-render/core"; +import { + JSONUIProvider, + Renderer, + type ComponentRenderProps, +} from "./renderer"; describe("Renderer", () => { it("renders null for null spec", () => { @@ -40,4 +46,52 @@ describe("Renderer", () => { }); expect(element.props.fallback).toBe(Fallback); }); + + it("resolves nested repeat statePath from parent $item scope", () => { + function Group({ children }: ComponentRenderProps) { + return
{children}
; + } + + function Text({ element }: ComponentRenderProps<{ text: unknown }>) { + return {String(element.props.text)}; + } + + const spec: Spec = { + root: "groups", + state: { + groups: [ + { subitems: [{ label: "a1" }, { label: "a2" }] }, + { subitems: [{ label: "b1" }] }, + ], + }, + elements: { + groups: { + type: "Group", + props: {}, + repeat: { statePath: "/groups" }, + children: ["subitems"], + }, + subitems: { + type: "Group", + props: {}, + repeat: { statePath: { $item: "subitems" } }, + children: ["label"], + }, + label: { + type: "Text", + props: { text: { $item: "label" } }, + }, + }, + }; + + render( + + + , + ); + + expect( + screen.getAllByTestId("item-text").map((el) => el.textContent), + ).toEqual(["a1", "a2", "b1"]); + }); }); diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index 1ff7faaf..6373fdfc 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -415,8 +415,32 @@ function RepeatChildren({ fallback?: ComponentRenderer; }) { const { state } = useStateStore(); + const parentScope = useRepeatScope(); const repeat = element.repeat!; - const statePath = repeat.statePath; + let statePath: string; + + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (parentScope) { + statePath = + field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } else { + console.warn( + "[json-render] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; diff --git a/packages/solid/src/renderer.tsx b/packages/solid/src/renderer.tsx index e2f541fb..434e2c09 100644 --- a/packages/solid/src/renderer.tsx +++ b/packages/solid/src/renderer.tsx @@ -379,7 +379,26 @@ interface RepeatChildrenProps { function RepeatChildren(props: RepeatChildrenProps) { const stateStore = useStateStore(); const repeat = () => props.element.repeat!; - const statePath = () => repeat().statePath; + const parentScope = useRepeatScope(); + const statePath = () => { + const current = repeat().statePath; + if (typeof current === "string") return current; + + if (typeof current === "object" && current !== null && "$item" in current) { + const field = (current as Record).$item; + if (parentScope) { + return field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } + console.warn( + "[json-render/solid] $item in repeat.statePath used outside of a repeat scope", + ); + return field === "" ? "/" : `/${field}`; + } + + return String(current); + }; const items = () => (getByPath(stateStore.state, statePath()) as unknown[] | undefined) ?? []; diff --git a/packages/vue/src/renderer.ts b/packages/vue/src/renderer.ts index e1d0066a..8f92d906 100644 --- a/packages/vue/src/renderer.ts +++ b/packages/vue/src/renderer.ts @@ -420,11 +420,34 @@ const RepeatChildren = defineComponent({ }, setup(props) { const { state } = useStateStore(); + const parentScope = useRepeatScope(); return () => { const repeat = props.element.repeat; if (!repeat?.statePath) return null; - const statePath = repeat.statePath; + let statePath: string; + if (typeof repeat.statePath === "string") { + statePath = repeat.statePath; + } else if ( + typeof repeat.statePath === "object" && + repeat.statePath !== null && + "$item" in repeat.statePath + ) { + const field = (repeat.statePath as Record).$item; + if (parentScope) { + statePath = + field === "" + ? parentScope.basePath + : `${parentScope.basePath}/${field}`; + } else { + console.warn( + "[json-render/vue] $item in repeat.statePath used outside of a repeat scope", + ); + statePath = field === "" ? "/" : `/${field}`; + } + } else { + statePath = String(repeat.statePath); + } const raw = getByPath(state.value, statePath); const items = Array.isArray(raw) ? (raw as unknown[]) : [];