Compare commits

...
Author SHA1 Message Date
Chris Tate 9626b09681 update dashboard example 2026-02-06 13:50:36 -06:00
Chris Tate bb501e49ec defineRegistry 2026-02-06 13:22:07 -06:00
13 changed files with 1336 additions and 1224 deletions
+27 -31
View File
@@ -56,45 +56,39 @@ const catalog = defineCatalog(schema, {
});
```
### 2. Register Component Implementations
### 2. Define Your Components
```tsx
import { defineComponents } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.(props.action)}>
{props.label}
</button>
),
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
```
### 3. Render AI-Generated Specs
```tsx
import { Renderer } from "@json-render/react";
function Dashboard({ spec }) {
return (
<Renderer
spec={spec}
catalog={catalog}
components={components}
/>
);
return <Renderer spec={spec} registry={registry} />;
}
```
@@ -115,7 +109,7 @@ function Dashboard({ spec }) {
### React (UI)
```tsx
import { Renderer } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
// Element tree spec format
@@ -129,7 +123,9 @@ const spec = {
}
};
<Renderer spec={spec} catalog={catalog} components={components} />
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
+29 -14
View File
@@ -43,36 +43,51 @@ interface AuthState {
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">createRenderer</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Factory function to create a pre-configured renderer component.
Create a type-safe component registry from a catalog. Components receive{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>,{" "}
<code className="text-foreground">onAction</code>, and{" "}
<code className="text-foreground">loading</code> with catalog-inferred
types.
</p>
<Code lang="tsx">{`import { createRenderer } from '@json-render/react';
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
const MyRenderer = createRenderer({
registry: componentRegistry,
data?: initialData,
actionHandlers?: actionHandlerMap,
auth?: authState,
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
// Usage
<MyRenderer spec={spec} loading={isStreaming} />`}</Code>
// Pass to <Renderer>
<Renderer spec={spec} registry={registry} />`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
<Code lang="tsx">{`<Renderer
spec={Spec} // The UI spec to render
registry={Registry} // Component registry
registry={Registry} // Component registry (from defineRegistry)
loading={boolean} // Optional loading state
fallback={Component} // Optional fallback for unknown types
/>
type Registry = Record<string, React.ComponentType<ComponentProps>>;
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
interface ComponentProps<T = Record<string, unknown>> {
props: T; // Component props from spec
<h3 className="text-lg font-semibold mt-8 mb-4">
Component Props (via defineRegistry)
</h3>
<Code lang="tsx">{`interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: { name: string; params?: object }) => void;
loading?: boolean;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
+34 -31
View File
@@ -61,39 +61,42 @@ export const catalog = defineCatalog(schema, {
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create your components and actions
2. Define your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define React components that render each catalog type. Each component
receives <code className="text-foreground">props</code>,{" "}
Use <code className="text-foreground">defineRegistry</code> to map
catalog types to React components. Each component receives type-safe{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>, and{" "}
<code className="text-foreground">onAction</code>:
</p>
<Code lang="tsx">{`// lib/components.tsx
import { defineComponents } from '@json-render/react';
<Code lang="tsx">{`// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { catalog } from './catalog';
export const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.(props.action)}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.({ name: props.action })}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
@@ -123,14 +126,14 @@ export async function POST(req: Request) {
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the providers and renderer to display AI-generated UI:
Use providers and the <code className="text-foreground">Renderer</code>{" "}
with your registry to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
import { catalog } from '@/lib/catalog';
import { components } from '@/lib/components';
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
const { spec, isStreaming, send } = useUIStream({
@@ -162,7 +165,7 @@ export default function Page() {
</form>
<div className="mt-8">
<Renderer spec={spec} catalog={catalog} components={components} loading={isStreaming} />
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
</ActionProvider>
</VisibilityProvider>
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from "lucide-react";
import { useUIStream, type Spec } from "@json-render/react";
import { DashboardRenderer } from "@/lib/render/renderer";
import { executeAction } from "@/lib/render/catalog/actions";
import { executeAction } from "@/lib/render/registry";
import { CodeHighlight } from "@/components/code-highlight";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -1,255 +0,0 @@
import { toast } from "sonner";
import { findFormValue } from "@json-render/core";
import {
type Actions,
type ActionFn,
type SetData,
type DataModel,
} from "@json-render/react";
import { dashboardCatalog } from "../catalog";
// =============================================================================
// Action Handlers - Type-safe with Catalog
// =============================================================================
export const actionHandlers: Actions<typeof dashboardCatalog> = {
viewCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setData, data) => {
const name = findFormValue("name", params, data) as string;
const email =
(findFormValue("email", params, data) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, data) as string | undefined;
if (!name) {
toast.error("Customer name is required");
return;
}
try {
const res = await fetch("/api/v1/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, phone }),
});
const customer = await res.json();
if (res.ok) {
toast.success(`Customer "${customer.name}" created`);
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
toast.error(customer.error || "Failed to create customer");
}
} catch (err) {
console.error("Failed to create customer:", err);
toast.error("Failed to create customer");
}
},
deleteCustomer: async (params, setData, data) => {
const customerId =
findFormValue("customerId", params, data) ||
findFormValue("id", params, data);
if (!customerId) {
toast.error("Customer ID required");
return;
}
try {
const res = await fetch(`/api/v1/customers/${customerId}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("Customer deleted");
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete customer");
}
} catch (err) {
console.error("Failed to delete customer:", err);
toast.error("Failed to delete customer");
}
},
viewInvoices: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setData) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setData) => {
if (!params?.customerId || !params?.dueDate) {
toast.error("Customer ID and due date required");
return;
}
try {
const res = await fetch("/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const invoice = await res.json();
if (res.ok) {
toast.success("Invoice created");
const listRes = await fetch("/api/v1/invoices");
const invoices = await listRes.json();
setData((prev) => ({ ...prev, invoices }));
} else {
toast.error(invoice.error || "Failed to create invoice");
}
} catch (err) {
console.error("Failed to create invoice:", err);
toast.error("Failed to create invoice");
}
},
sendInvoice: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice sent");
} else {
toast.error(result.error || "Failed to send invoice");
}
},
markInvoicePaid: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/mark-paid`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice marked paid");
} else {
toast.error(result.error || "Failed to mark invoice paid");
}
},
viewExpenses: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setData) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setData) => {
if (!params?.vendor || !params?.category || params?.amount === undefined) {
toast.error("Vendor, category, and amount required");
return;
}
try {
const res = await fetch("/api/v1/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const expense = await res.json();
if (res.ok) {
toast.success("Expense created");
const listRes = await fetch("/api/v1/expenses");
const expenses = await listRes.json();
setData((prev) => ({ ...prev, expenses }));
} else {
toast.error(expense.error || "Failed to create expense");
}
} catch (err) {
console.error("Failed to create expense:", err);
toast.error("Failed to create expense");
}
},
approveExpense: async (params) => {
if (!params?.expenseId) {
toast.error("Expense ID required");
return;
}
const res = await fetch(`/api/v1/expenses/${params.expenseId}/approve`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Expense approved");
} else {
toast.error(result.error || "Failed to approve expense");
}
},
};
// =============================================================================
// Execute Action
// =============================================================================
type Catalog = typeof dashboardCatalog;
type CatalogActions = Catalog["data"]["actions"];
/**
* Execute an action by name with the given parameters.
*/
export async function executeAction(
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel = {},
): Promise<void> {
const handler = actionHandlers[actionName as keyof CatalogActions];
if (handler) {
await (handler as ActionFn<Catalog, keyof CatalogActions>)(
params as never,
setData,
data,
);
} else {
console.log("Unknown action:", actionName, params);
toast(`Action: ${actionName}`);
}
}
@@ -1,771 +0,0 @@
"use client";
import { useData, type Components } from "@json-render/react";
import { getByPath } from "@json-render/core";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { dashboardCatalog } from "../catalog";
// =============================================================================
// Components - Type-safe with Catalog
// =============================================================================
export const components: Components<typeof dashboardCatalog> = {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
>
{children}
</div>
);
},
Accordion: ({ props, children }) => (
<Accordion type={props.type ?? "single"} collapsible>
{children}
</Accordion>
),
AccordionItem: ({ props, children }) => (
<AccordionItem value={props.value}>
<AccordionTrigger>{props.title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</AccordionItem>
),
Button: ({ props, onAction, loading }) => (
<Button
variant={props.variant ?? "default"}
disabled={loading || (props.disabled ?? false)}
onClick={() =>
onAction?.({
name: props.action,
params: props.actionParams ?? undefined,
})
}
>
{loading ? "..." : props.label}
</Button>
),
Input: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Input
type={props.type ?? "text"}
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Form: ({ props, children, onAction }) => (
<form
onSubmit={(e) => {
e.preventDefault();
onAction?.({
name: props.submitAction,
params: props.submitActionParams ?? undefined,
});
}}
className="flex flex-col gap-4"
>
{children}
</form>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Avatar: ({ props }) => (
<Avatar>
{props.src ? <AvatarImage src={props.src} alt={props.alt ?? ""} /> : null}
<AvatarFallback>{props.fallback}</AvatarFallback>
</Avatar>
),
Checkbox: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Checkbox
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Dialog: ({ props, children }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
{props.description ? (
<DialogDescription>{props.description}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
),
Drawer: ({ props, children }) => (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{props.title}</DrawerTitle>
{props.description ? (
<DrawerDescription>{props.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div className="p-4">{children}</div>
</DrawerContent>
</Drawer>
),
DropdownMenu: ({ props, onAction }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.items.map((item, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
item.action
? onAction?.({
name: item.action,
params: item.actionParams ?? undefined,
})
: undefined
}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
Label: ({ props }) => (
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink href="#" isActive={page === props.currentPage}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
);
},
Popover: ({ props, children }) => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</PopoverTrigger>
<PopoverContent>{children}</PopoverContent>
</Popover>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
RadioGroup: ({ props }) => {
const { data, set } = useData();
const value =
(getByPath(data, props.valuePath) as string) ?? props.defaultValue ?? "";
return (
<RadioGroup value={value} onValueChange={(v) => set(props.valuePath, v)}>
{props.options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
},
Select: ({ props }) => {
const { data, set } = useData();
const value = (getByPath(data, props.valuePath) as string) ?? "";
return (
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Spinner: ({ props }) => {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
const size = sizes[props.size ?? "md"];
return (
<div
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
/>
);
},
Switch: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Switch
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
<TabsList>
{props.tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Textarea: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Textarea
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Tooltip: ({ props, children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{props.content}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Table: ({ props, onAction }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: Array.isArray((rawData as Record<string, unknown>)?.items)
? ((rawData as Record<string, unknown>).items as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => (
<TableHead key={col.key}>{col.label}</TableHead>
))}
{hasRowActions ? <TableHead>Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
{hasRowActions ? (
<TableCell>
<div className="flex gap-1">
{props.rowActions!.map((rowAction) => (
<Button
key={rowAction.action}
variant={rowAction.variant ?? "ghost"}
size="sm"
onClick={() =>
onAction?.({
name: rowAction.action,
params: { id: item.id as string },
})
}
>
{rowAction.label}
</Button>
))}
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
);
},
BarChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
};
// =============================================================================
// Chart Helpers
// =============================================================================
function isISODate(value: unknown): boolean {
if (typeof value !== "string") return false;
// Check for ISO date format: YYYY-MM-DDTHH:mm:ss
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
}
function formatDateLabel(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
// Format as "Mon DD" (e.g., "Feb 04")
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
// Check if xKey contains date values
const firstXValue = items[0]?.[xKey];
const isDateKey = isISODate(firstXValue);
// If no aggregation, just format dates and return
if (!aggregate) {
const formatted = items.map((item) => {
const xValue = item[xKey];
return {
...item,
label:
isDateKey && typeof xValue === "string"
? formatDateLabel(xValue)
: String(xValue ?? ""),
};
});
return { items: formatted, valueKey: yKey };
}
// Group items by xKey (use date string for dates)
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const xValue = item[xKey];
let groupKey: string;
if (isDateKey && typeof xValue === "string") {
// Group by date (YYYY-MM-DD)
groupKey = xValue.split("T")[0] ?? xValue;
} else {
groupKey = String(xValue ?? "unknown");
}
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
// Aggregate each group
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
// Sort keys (for dates, this will be chronological)
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
// avg
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
// Format label
let label: string;
if (isDateKey) {
const date = new Date(key);
label = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
} else {
label = key;
}
aggregated.push({
label,
[valueKey]: value,
_groupKey: key,
});
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+998
View File
@@ -0,0 +1,998 @@
"use client";
import { toast } from "sonner";
import { findFormValue, getByPath } from "@json-render/core";
import { useData, defineRegistry } from "@json-render/react";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { dashboardCatalog } from "./catalog";
// =============================================================================
// Registry
// =============================================================================
export const { registry, handlers, executeAction } = defineRegistry(
dashboardCatalog,
{
components: {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ??
"gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
>
{children}
</div>
);
},
Accordion: ({ props, children }) => (
<Accordion type={props.type ?? "single"} collapsible>
{children}
</Accordion>
),
AccordionItem: ({ props, children }) => (
<AccordionItem value={props.value}>
<AccordionTrigger>{props.title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</AccordionItem>
),
Button: ({ props, onAction, loading }) => (
<Button
variant={props.variant ?? "default"}
disabled={loading || (props.disabled ?? false)}
onClick={() =>
onAction?.({
name: props.action,
params: props.actionParams ?? undefined,
})
}
>
{loading ? "..." : props.label}
</Button>
),
Input: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Input
type={props.type ?? "text"}
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Form: ({ props, children, onAction }) => (
<form
onSubmit={(e) => {
e.preventDefault();
onAction?.({
name: props.submitAction,
params: props.submitActionParams ?? undefined,
});
}}
className="flex flex-col gap-4"
>
{children}
</form>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Avatar: ({ props }) => (
<Avatar>
{props.src ? (
<AvatarImage src={props.src} alt={props.alt ?? ""} />
) : null}
<AvatarFallback>{props.fallback}</AvatarFallback>
</Avatar>
),
Checkbox: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Checkbox
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Dialog: ({ props, children }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
{props.description ? (
<DialogDescription>{props.description}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
),
Drawer: ({ props, children }) => (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{props.title}</DrawerTitle>
{props.description ? (
<DrawerDescription>{props.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div className="p-4">{children}</div>
</DrawerContent>
</Drawer>
),
DropdownMenu: ({ props, onAction }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.items.map((item, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
item.action
? onAction?.({
name: item.action,
params: item.actionParams ?? undefined,
})
: undefined
}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
Label: ({ props }) => (
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={page === props.currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
);
},
Popover: ({ props, children }) => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</PopoverTrigger>
<PopoverContent>{children}</PopoverContent>
</Popover>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
RadioGroup: ({ props }) => {
const { data, set } = useData();
const value =
(getByPath(data, props.valuePath) as string) ??
props.defaultValue ??
"";
return (
<RadioGroup
value={value}
onValueChange={(v) => set(props.valuePath, v)}
>
{props.options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
},
Select: ({ props }) => {
const { data, set } = useData();
const value = (getByPath(data, props.valuePath) as string) ?? "";
return (
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Spinner: ({ props }) => {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
const size = sizes[props.size ?? "md"];
return (
<div
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
/>
);
},
Switch: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Switch
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
<TabsList>
{props.tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Textarea: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Textarea
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Tooltip: ({ props, children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{props.content}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Table: ({ props, onAction }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: Array.isArray((rawData as Record<string, unknown>)?.items)
? ((rawData as Record<string, unknown>).items as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => (
<TableHead key={col.key}>{col.label}</TableHead>
))}
{hasRowActions ? <TableHead>Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
{hasRowActions ? (
<TableCell>
<div className="flex gap-1">
{props.rowActions!.map((rowAction) => (
<Button
key={rowAction.action}
variant={rowAction.variant ?? "ghost"}
size="sm"
onClick={() =>
onAction?.({
name: rowAction.action,
params: { id: item.id as string },
})
}
>
{rowAction.label}
</Button>
))}
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
);
},
BarChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
},
actions: {
viewCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setData, data) => {
const name = findFormValue("name", params, data) as string;
const email =
(findFormValue("email", params, data) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, data) as
| string
| undefined;
if (!name) {
toast.error("Customer name is required");
return;
}
try {
const res = await fetch("/api/v1/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, phone }),
});
const customer = await res.json();
if (res.ok) {
toast.success(`Customer "${customer.name}" created`);
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
toast.error(customer.error || "Failed to create customer");
}
} catch (err) {
console.error("Failed to create customer:", err);
toast.error("Failed to create customer");
}
},
deleteCustomer: async (params, setData, data) => {
const customerId =
findFormValue("customerId", params, data) ||
findFormValue("id", params, data);
if (!customerId) {
toast.error("Customer ID required");
return;
}
try {
const res = await fetch(`/api/v1/customers/${customerId}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("Customer deleted");
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete customer");
}
} catch (err) {
console.error("Failed to delete customer:", err);
toast.error("Failed to delete customer");
}
},
viewInvoices: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setData) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setData) => {
if (!params?.customerId || !params?.dueDate) {
toast.error("Customer ID and due date required");
return;
}
try {
const res = await fetch("/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const invoice = await res.json();
if (res.ok) {
toast.success("Invoice created");
const listRes = await fetch("/api/v1/invoices");
const invoices = await listRes.json();
setData((prev) => ({ ...prev, invoices }));
} else {
toast.error(invoice.error || "Failed to create invoice");
}
} catch (err) {
console.error("Failed to create invoice:", err);
toast.error("Failed to create invoice");
}
},
sendInvoice: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice sent");
} else {
toast.error(result.error || "Failed to send invoice");
}
},
markInvoicePaid: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(
`/api/v1/invoices/${params.invoiceId}/mark-paid`,
{ method: "POST" },
);
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice marked paid");
} else {
toast.error(result.error || "Failed to mark invoice paid");
}
},
viewExpenses: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setData) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setData) => {
if (
!params?.vendor ||
!params?.category ||
params?.amount === undefined
) {
toast.error("Vendor, category, and amount required");
return;
}
try {
const res = await fetch("/api/v1/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const expense = await res.json();
if (res.ok) {
toast.success("Expense created");
const listRes = await fetch("/api/v1/expenses");
const expenses = await listRes.json();
setData((prev) => ({ ...prev, expenses }));
} else {
toast.error(expense.error || "Failed to create expense");
}
} catch (err) {
console.error("Failed to create expense:", err);
toast.error("Failed to create expense");
}
},
approveExpense: async (params) => {
if (!params?.expenseId) {
toast.error("Expense ID required");
return;
}
const res = await fetch(
`/api/v1/expenses/${params.expenseId}/approve`,
{ method: "POST" },
);
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Expense approved");
} else {
toast.error(result.error || "Failed to approve expense");
}
},
},
},
);
// =============================================================================
// Chart Helpers
// =============================================================================
function isISODate(value: unknown): boolean {
if (typeof value !== "string") return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
}
function formatDateLabel(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
const firstXValue = items[0]?.[xKey];
const isDateKey = isISODate(firstXValue);
if (!aggregate) {
const formatted = items.map((item) => {
const xValue = item[xKey];
return {
...item,
label:
isDateKey && typeof xValue === "string"
? formatDateLabel(xValue)
: String(xValue ?? ""),
};
});
return { items: formatted, valueKey: yKey };
}
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const xValue = item[xKey];
let groupKey: string;
if (isDateKey && typeof xValue === "string") {
groupKey = xValue.split("T")[0] ?? xValue;
} else {
groupKey = String(xValue ?? "unknown");
}
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
let label: string;
if (isDateKey) {
const date = new Date(key);
label = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
} else {
label = key;
}
aggregated.push({
label,
[valueKey]: value,
_groupKey: key,
});
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+16 -42
View File
@@ -3,15 +3,14 @@
import { useMemo, useRef, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type ComponentRenderer,
type Spec,
DataProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
import { registry, Fallback, handlers as createHandlers } from "./registry";
// =============================================================================
// DashboardRenderer
@@ -29,39 +28,9 @@ interface DashboardRendererProps {
loading?: boolean;
}
// Build registry - uses refs to avoid recreating on data changes
function buildRegistry(
dataRef: React.RefObject<Record<string, unknown>>,
setDataRef: React.RefObject<SetData | undefined>,
loading?: boolean,
): ComponentRegistry {
const registry: ComponentRegistry = {};
for (const [name, componentFn] of Object.entries(components)) {
registry[name] = (renderProps: {
element: { props: Record<string, unknown> };
children?: ReactNode;
}) =>
componentFn({
props: renderProps.element.props as never,
children: renderProps.children,
onAction: (a) => {
const setData = setDataRef.current;
const data = dataRef.current;
if (setData) {
executeAction(a.name, a.params, setData, data);
}
},
loading,
});
}
return registry;
}
// Fallback component for unknown types
const fallbackRegistry = (renderProps: { element: { type: string } }) => (
<Fallback type={renderProps.element.type} />
const fallback: ComponentRenderer = ({ element }) => (
<Fallback type={element.type} />
);
export function DashboardRenderer({
@@ -71,16 +40,21 @@ export function DashboardRenderer({
onDataChange,
loading,
}: DashboardRendererProps): ReactNode {
// Use refs to keep registry stable while still accessing latest data/setData
// Use refs so action handlers always see the latest data/setData
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
// Memoize registry - only changes when loading changes
const registry = useMemo(
() => buildRegistry(dataRef, setDataRef, loading),
[loading],
// Create ActionProvider-compatible handlers using getters so they
// always read the latest data/setData from refs
const actionHandlers = useMemo(
() =>
createHandlers(
() => setDataRef.current,
() => dataRef.current,
),
[],
);
if (!spec) return null;
@@ -88,11 +62,11 @@ export function DashboardRenderer({
return (
<DataProvider initialData={data} onDataChange={onDataChange}>
<VisibilityProvider>
<ActionProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
fallback={fallback}
loading={loading}
/>
</ActionProvider>
File diff suppressed because one or more lines are too long
+44 -55
View File
@@ -51,33 +51,36 @@ export const catalog = defineCatalog(schema, {
### 2. Define Component Implementations
```tsx
import { defineComponents, useData } from "@json-render/react";
import { defineRegistry, useData } from "@json-render/react";
import { catalog } from "./catalog";
export const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.(props.action)}>
{props.label}
</button>
),
Input: ({ props }) => {
const { get, set } = useData();
return (
<label>
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={get("/form/value") ?? ""}
onChange={(e) => set("/form/value", e.target.value)}
/>
</label>
);
</button>
),
Input: ({ props }) => {
const { get, set } = useData();
return (
<label>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={get("/form/value") ?? ""}
onChange={(e) => set("/form/value", e.target.value)}
/>
</label>
);
},
},
});
```
@@ -86,20 +89,15 @@ export const components = defineComponents(catalog, {
```tsx
import { Renderer, DataProvider, ActionProvider } from "@json-render/react";
import { registry } from "./registry";
function App({ spec }) {
const handleAction = (action: string) => {
console.log("Action triggered:", action);
};
return (
<DataProvider initialData={{ form: { value: "" } }}>
<ActionProvider onAction={handleAction}>
<Renderer
spec={spec}
catalog={catalog}
components={components}
/>
<ActionProvider handlers={{
submit: () => console.log("Submit"),
}}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</DataProvider>
);
@@ -253,13 +251,14 @@ const { errors, validate } = useFieldValidation("/form/email", {
## Component Props
Components receive these props:
When using `defineRegistry`, components receive these props:
```typescript
interface ComponentProps<P> {
props: P; // Props from spec
interface ComponentContext<P> {
props: P; // Typed props from the catalog
children?: React.ReactNode; // Rendered children
onAction?: (action: string) => void;
onAction?: (action: { name: string; params?: Record<string, unknown> }) => void;
loading?: boolean; // Whether the parent is loading
}
```
@@ -274,13 +273,7 @@ const systemPrompt = catalog.prompt();
```tsx
import { defineCatalog } from "@json-render/core";
import {
schema,
defineComponents,
Renderer,
DataProvider,
ActionProvider,
} from "@json-render/react";
import { schema, defineRegistry, Renderer } from "@json-render/react";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -293,8 +286,10 @@ const catalog = defineCatalog(schema, {
actions: {},
});
const components = defineComponents(catalog, {
Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
const { registry } = defineRegistry(catalog, {
components: {
Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
},
});
const spec = {
@@ -305,12 +300,6 @@ const spec = {
};
function App() {
return (
<DataProvider initialData={{}}>
<ActionProvider onAction={() => {}}>
<Renderer spec={spec} catalog={catalog} components={components} />
</ActionProvider>
</DataProvider>
);
return <Renderer spec={spec} registry={registry} />;
}
```
+5 -2
View File
@@ -64,11 +64,14 @@ export type {
// Renderer
export {
// New API
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Legacy API
// Low-level
Renderer,
JSONUIProvider,
createRendererFromCatalog,
+154 -2
View File
@@ -5,11 +5,18 @@ import type {
UIElement,
Spec,
Action,
Catalog as NewCatalog,
Catalog,
SchemaDefinition,
LegacyCatalog,
ComponentDefinition,
} from "@json-render/core";
import type {
Components,
Actions,
ActionFn,
SetData,
DataModel,
} from "./catalog-types";
import { useIsVisible } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
import { useData } from "./contexts/data";
@@ -232,6 +239,151 @@ export function createRendererFromCatalog<
};
}
// ============================================================================
// defineRegistry
// ============================================================================
/**
* Result returned by defineRegistry
*/
export interface DefineRegistryResult {
/** Component registry for `<Renderer registry={...} />` */
registry: ComponentRegistry;
/**
* Create ActionProvider-compatible handlers.
* Accepts getter functions so handlers always read the latest data/setData
* (e.g. from React refs).
*/
handlers: (
getSetData: () => SetData | undefined,
getData: () => DataModel,
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
/**
* Execute an action by name imperatively
* (for use outside the React tree, e.g. initial data loading).
*/
executeAction: (
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data?: DataModel,
) => Promise<void>;
}
/**
* Create a registry from a catalog with components and/or actions.
*
* @example
* ```tsx
* // Components only
* const { registry } = defineRegistry(catalog, {
* components: {
* Card: ({ props, children }) => (
* <div className="card">{props.title}{children}</div>
* ),
* },
* });
*
* // Actions only
* const { handlers, executeAction } = defineRegistry(catalog, {
* actions: {
* viewCustomers: async (params, setData) => { ... },
* },
* });
*
* // Both
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
* components: { ... },
* actions: { ... },
* });
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: {
components?: Components<C>;
actions?: Actions<C>;
},
): DefineRegistryResult {
// Build component registry
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = ({
element,
children,
onAction,
loading,
}: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
onAction,
loading,
});
};
}
}
// Build action helpers
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
[string, DefineRegistryActionFn]
>)
: [];
const handlers = (
getSetData: () => SetData | undefined,
getData: () => DataModel,
): 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 setData = getSetData();
const data = getData();
if (setData) {
await actionFn(params, setData, data);
}
};
}
return result;
};
const executeAction = async (
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel = {},
): Promise<void> => {
const entry = actionMap.find(([name]) => name === actionName);
if (entry) {
await entry[1](params, setData, data);
} else {
console.warn(`Unknown action: ${actionName}`);
}
};
return { registry, handlers, executeAction };
}
/** @internal */
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
onAction?: (action: Action) => void;
loading?: boolean;
}) => React.ReactNode;
/** @internal */
type DefineRegistryActionFn = (
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel,
) => Promise<void>;
// ============================================================================
// NEW API
// ============================================================================
@@ -289,7 +441,7 @@ export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: NewCatalog<TDef, TCatalog>,
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): ComponentType<CreateRendererProps> {
// Convert component map to registry
+27 -19
View File
@@ -10,34 +10,27 @@ React renderer that converts JSON specs into React component trees.
## Quick Start
```typescript
import { Renderer } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
import { catalog } from "./catalog";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
},
});
function App({ spec }) {
return <Renderer spec={spec} catalog={catalog} />;
return <Renderer spec={spec} registry={registry} />;
}
```
## Creating a Catalog
```typescript
import { defineCatalog, defineComponents } from "@json-render/react";
import { schema } from "@json-render/react"; // Uses element tree schema
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import { z } from "zod";
// Define component implementations
const components = defineComponents(catalog, {
Button: ({ props }) => (
<button className={props.variant}>{props.label}</button>
),
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{children}
</div>
),
});
// Create catalog with props schemas
export const catalog = defineCatalog(schema, {
components: {
@@ -54,6 +47,21 @@ export const catalog = defineCatalog(schema, {
},
},
});
// Define component implementations with type-safe props
const { registry } = defineRegistry(catalog, {
components: {
Button: ({ props }) => (
<button className={props.variant}>{props.label}</button>
),
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{children}
</div>
),
},
});
```
## Spec Structure (Element Tree)
@@ -85,8 +93,8 @@ The React schema uses an element tree format:
| Export | Purpose |
|--------|---------|
| `Renderer` | Render spec to React components |
| `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry |
| `schema` | Element tree schema |
| `defineComponents` | Type-safe component registry |
| `useData` | Access data context |
| `useActions` | Access actions context |