Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface UIElement<
/** Event bindings — maps event names to action bindings */
on?: Record<string, ActionBinding | ActionBinding[]>;
/** 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.
Expand Down
36 changes: 27 additions & 9 deletions packages/image/src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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<string, unknown>)[resolvedElement.repeat!.key!] ??
index,
)
repeat.key && typeof item === "object" && item !== null
? String((item as Record<string, unknown>)[repeat.key!] ?? index)
: String(index);

const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
const childPath = `${statePath}/${index}`;
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
Expand Down
26 changes: 25 additions & 1 deletion packages/ink/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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 : [];
Expand Down
30 changes: 25 additions & 5 deletions packages/react-email/src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,40 @@ 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<string, string>).$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 =
repeatKey && typeof item === "object" && item !== null
? String((item as Record<string, unknown>)[repeatKey] ?? index)
: String(index);

const childPath = `${repeat.statePath}/${index}`;
const childPath = `${statePath}/${index}`;
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
Expand Down
26 changes: 25 additions & 1 deletion packages/react-email/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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) ?? [];

Expand Down
26 changes: 25 additions & 1 deletion packages/react-native/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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) ?? [];

Expand Down
36 changes: 27 additions & 9 deletions packages/react-pdf/src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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<string, unknown>)[resolvedElement.repeat!.key!] ??
index,
)
repeat.key && typeof item === "object" && item !== null
? String((item as Record<string, unknown>)[repeat.key!] ?? index)
: String(index);

const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
const childPath = `${statePath}/${index}`;
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
Expand Down
26 changes: 25 additions & 1 deletion packages/react-pdf/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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) ?? [];

Expand Down
56 changes: 55 additions & 1 deletion packages/react/src/renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 <div>{children}</div>;
}

function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
return <span data-testid="item-text">{String(element.props.text)}</span>;
}

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(
<JSONUIProvider registry={{ Group, Text }} initialState={spec.state}>
<Renderer spec={spec} registry={{ Group, Text }} />
</JSONUIProvider>,
);

expect(
screen.getAllByTestId("item-text").map((el) => el.textContent),
).toEqual(["a1", "a2", "b1"]);
});
});
26 changes: 25 additions & 1 deletion packages/react/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).$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) ?? [];

Expand Down
Loading