mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9626b09681 | ||
|
|
bb501e49ec |
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user