mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ea9c782b | ||
|
|
dd17549ee8 | ||
|
|
1eb6212dd7 | ||
|
|
711e79b069 | ||
|
|
f3611860a7 | ||
|
|
61ee8e5291 | ||
|
|
d38b281e5e | ||
|
|
39fcc192e9 | ||
|
|
96c7a452b3 | ||
|
|
844f18d1ee | ||
|
|
54bce097a5 | ||
|
|
e2cb239c88 | ||
|
|
90c59c674d | ||
|
|
bba38727e8 | ||
|
|
1d547b579c | ||
|
|
795cdaac56 | ||
|
|
4ae049f218 | ||
|
|
f004caf95e | ||
|
|
72d6468e70 | ||
|
|
e6f2b1e9b8 | ||
|
|
85f82590a8 |
@@ -0,0 +1,13 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in the repository](https://github.com/changesets/changesets).
|
||||
|
||||
## Adding a changeset
|
||||
|
||||
To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select
|
||||
which packages have changed and what type of version bump (major, minor, or patch) should be applied.
|
||||
|
||||
All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all
|
||||
packages to the same version.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/remotion",
|
||||
"@json-render/codegen"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm ci:version
|
||||
publish: pnpm ci:publish
|
||||
title: "chore: version packages"
|
||||
commit: "chore: version packages"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
||||
@@ -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)
|
||||
|
||||
@@ -273,8 +273,8 @@ compiler.reset();`}</Code>
|
||||
</p>
|
||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = \`{"op":"set","path":"/root","value":{}}
|
||||
{"op":"set","path":"/root/type","value":"Card"}\`;
|
||||
const jsonl = \`{"op":"add","path":"/root","value":{}}
|
||||
{"op":"add","path":"/root/type","value":"Card"}\`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
||||
|
||||
@@ -285,22 +285,36 @@ const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
||||
} from '@json-render/core';
|
||||
|
||||
// Parse a single line
|
||||
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
|
||||
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||
|
||||
// Apply patch to object (mutates in place)
|
||||
const obj = {};
|
||||
applySpecStreamPatch(obj, patch);`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fully compliant with{" "}
|
||||
<a
|
||||
href="https://datatracker.ietf.org/doc/html/rfc6902"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
RFC 6902
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface SpecStreamLine {
|
||||
op: 'set' | 'add' | 'replace' | 'remove';
|
||||
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
|
||||
path: string;
|
||||
value?: unknown;
|
||||
value?: unknown; // Required for add, replace, test
|
||||
from?: string; // Required for move, copy
|
||||
}
|
||||
|
||||
interface SpecStreamCompiler<T> {
|
||||
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
|
||||
getResult(): T;
|
||||
getPatches(): SpecStreamLine[];
|
||||
reset(): void;
|
||||
}`}</Code>
|
||||
|
||||
|
||||
@@ -43,51 +43,66 @@ 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>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
|
||||
<Code lang="typescript">{`const {
|
||||
spec, // Spec - current UI state
|
||||
spec, // Spec | null - current UI state
|
||||
isStreaming, // boolean - true while streaming
|
||||
error, // Error | null
|
||||
send, // (prompt: string) => void
|
||||
abort, // () => void
|
||||
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
|
||||
clear, // () => void - reset spec and error
|
||||
} = useUIStream({
|
||||
api: string, // API endpoint URL
|
||||
onChunk?: (chunk: string) => void, // Called for each chunk
|
||||
onFinish?: (spec: Spec) => void, // Called when streaming completes
|
||||
api: string, // API endpoint URL
|
||||
onComplete?: (spec: Spec) => void, // Called when streaming completes
|
||||
onError?: (error: Error) => void, // Called when an error occurs
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
|
||||
|
||||
@@ -116,13 +116,10 @@ const customPrompt = catalog.prompt({
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn how to{" "}
|
||||
<Link
|
||||
href="/docs/components"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
register React components
|
||||
<Link href="/docs/registry" className="text-foreground hover:underline">
|
||||
register components
|
||||
</Link>{" "}
|
||||
for your catalog.
|
||||
in your registry.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -275,11 +275,9 @@ export const openapiCatalog = createCatalog({
|
||||
}
|
||||
|
||||
interface SpecElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children: string[];
|
||||
parentKey: string;
|
||||
}
|
||||
|
||||
function schemaToSpec(
|
||||
@@ -295,7 +293,6 @@ function schemaToSpec(
|
||||
|
||||
if (schema.enum) {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'EnumField',
|
||||
props: {
|
||||
name,
|
||||
@@ -306,11 +303,9 @@ function schemaToSpec(
|
||||
defaultValue: schema.default as string,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'string') {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'StringField',
|
||||
props: {
|
||||
name,
|
||||
@@ -323,11 +318,9 @@ function schemaToSpec(
|
||||
defaultValue: schema.default as string,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'NumberField',
|
||||
props: {
|
||||
name,
|
||||
@@ -340,11 +333,9 @@ function schemaToSpec(
|
||||
defaultValue: schema.default as number,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'boolean') {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'BooleanField',
|
||||
props: {
|
||||
name,
|
||||
@@ -353,7 +344,6 @@ function schemaToSpec(
|
||||
defaultValue: schema.default as boolean,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'array' && schema.items) {
|
||||
const childKeys: string[] = [];
|
||||
@@ -361,7 +351,6 @@ function schemaToSpec(
|
||||
childKeys.push(itemKey);
|
||||
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'ArrayField',
|
||||
props: {
|
||||
name,
|
||||
@@ -369,7 +358,6 @@ function schemaToSpec(
|
||||
description: schema.description,
|
||||
},
|
||||
children: childKeys,
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'object' && schema.properties) {
|
||||
const childKeys: string[] = [];
|
||||
@@ -386,7 +374,6 @@ function schemaToSpec(
|
||||
}
|
||||
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'ObjectField',
|
||||
props: {
|
||||
name,
|
||||
@@ -394,7 +381,6 @@ function schemaToSpec(
|
||||
description: schema.description,
|
||||
},
|
||||
children: childKeys,
|
||||
parentKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,7 +414,6 @@ export function operationToSpec(
|
||||
}
|
||||
|
||||
elements.set(rootKey, {
|
||||
key: rootKey,
|
||||
type: 'Form',
|
||||
props: {
|
||||
operationId,
|
||||
@@ -438,7 +423,6 @@ export function operationToSpec(
|
||||
description,
|
||||
},
|
||||
children: childKeys,
|
||||
parentKey: '',
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,82 +14,86 @@ export default function RegistryPage() {
|
||||
life.
|
||||
</p>
|
||||
|
||||
{/* Components Section */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
|
||||
{/* defineRegistry */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a registry that maps catalog component types to React components:
|
||||
Use <code>defineRegistry</code> to create a type-safe registry from your
|
||||
catalog. Pass your components, actions, or both in a single call:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useAction } from '@json-render/react';
|
||||
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
|
||||
import { myCatalog } from './catalog';
|
||||
|
||||
const registry = {
|
||||
Card: ({ props, children }) => (
|
||||
<div className="card">
|
||||
<h2>{props.title}</h2>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Button: ({ props }) => {
|
||||
const executeAction = useAction(props.action);
|
||||
return (
|
||||
<button onClick={() => executeAction({})}>
|
||||
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<div className="card">
|
||||
<h2>{props.title}</h2>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
{props.label}
|
||||
</button>
|
||||
);
|
||||
),
|
||||
},
|
||||
};`}</Code>
|
||||
|
||||
actions: {
|
||||
submit_form: async (params, setData) => {
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const result = await res.json();
|
||||
setData((prev) => ({ ...prev, formResult: result }));
|
||||
},
|
||||
|
||||
export_data: async (params) => {
|
||||
const blob = await generateExport(params.format);
|
||||
downloadBlob(blob, \`export.\${params.format}\`);
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-4">
|
||||
The returned object contains:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
|
||||
<li>
|
||||
<code>registry</code> - component registry for{" "}
|
||||
<code>{"<Renderer />"}</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>handlers</code> - factory for ActionProvider-compatible handlers
|
||||
</li>
|
||||
<li>
|
||||
<code>executeAction</code> - imperative action dispatch (for use
|
||||
outside the React tree)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Component Props */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Each component receives these props:
|
||||
Each component in the registry receives a <code>ComponentContext</code>{" "}
|
||||
object:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface ComponentProps<T = Record<string, unknown>> {
|
||||
props: T; // Component props from the spec
|
||||
<Code lang="typescript">{`interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
}
|
||||
onAction?: (action: ActionTrigger) => void; // Dispatch an action
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
}`}</Code>
|
||||
|
||||
// Type-safe props by extracting from your catalog
|
||||
type CardProps = ComponentProps<{
|
||||
title: string;
|
||||
description: string | null;
|
||||
}>;
|
||||
|
||||
// Use hooks for actions and data within components
|
||||
import { useAction, useDataValue } from '@json-render/react';`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use hooks to read and write data:
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-4">
|
||||
Props are automatically inferred from your catalog, so{" "}
|
||||
<code>props.title</code> is typed as <code>string</code> if your catalog
|
||||
defines it that way.
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
|
||||
|
||||
const Metric = ({ props }) => {
|
||||
// Read-only value from data context
|
||||
const value = useDataValue(props.valuePath);
|
||||
|
||||
return (
|
||||
<div className="metric">
|
||||
<span className="label">{props.label}</span>
|
||||
<span className="value">{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TextField = ({ props }) => {
|
||||
// Two-way binding to data context
|
||||
const [value, setValue] = useDataBinding(props.valuePath);
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
);
|
||||
};`}</Code>
|
||||
|
||||
{/* Actions Section */}
|
||||
{/* Action Handlers */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Instead of AI generating arbitrary code, it declares <em>intent</em> by
|
||||
@@ -117,9 +121,6 @@ const catalog = defineCatalog(schema, {
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
filters: z.object({
|
||||
dateRange: z.string().nullable(),
|
||||
}).nullable(),
|
||||
}),
|
||||
},
|
||||
navigate: {
|
||||
@@ -130,78 +131,104 @@ const catalog = defineCatalog(schema, {
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">ActionProvider</h3>
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">
|
||||
Implementing Action Handlers
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Provide action handlers to your app:
|
||||
Action handlers receive <code>(params, setData, data)</code> and are
|
||||
defined inside <code>defineRegistry</code>:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const handlers = {
|
||||
submit_form: async (params) => {
|
||||
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
actions: {
|
||||
submit_form: async (params, setData) => {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ formId: params.formId }),
|
||||
});
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
setData((prev) => ({ ...prev, formResult: result }));
|
||||
},
|
||||
|
||||
|
||||
export_data: async (params) => {
|
||||
const blob = await generateExport(params.format, params.filters);
|
||||
const blob = await generateExport(params.format);
|
||||
downloadBlob(blob, \`export.\${params.format}\`);
|
||||
},
|
||||
|
||||
|
||||
navigate: (params) => {
|
||||
window.location.href = params.url;
|
||||
},
|
||||
};
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
{/* Using Data Binding */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use hooks inside your registry components to read and write data:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useData } from '@json-render/react';
|
||||
import { getByPath } from '@json-render/core';
|
||||
|
||||
// Inside defineRegistry components:
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const { data } = useData();
|
||||
const value = getByPath(data, props.valuePath);
|
||||
|
||||
return (
|
||||
<ActionProvider handlers={handlers}>
|
||||
{/* Your UI */}
|
||||
</ActionProvider>
|
||||
<div className="metric">
|
||||
<span className="label">{props.label}</span>
|
||||
<span className="value">{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
},
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">
|
||||
Using Actions in Components
|
||||
</h3>
|
||||
<Code lang="tsx">{`import { useAction } from '@json-render/react';
|
||||
TextField: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const value = getByPath(data, props.valuePath) as string;
|
||||
|
||||
// Using the useAction hook (recommended)
|
||||
const Button = ({ props }) => {
|
||||
const executeAction = useAction(props.action);
|
||||
|
||||
return (
|
||||
<button onClick={() => executeAction({})}>
|
||||
{props.label}
|
||||
</button>
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Or for standalone use
|
||||
function SubmitButton() {
|
||||
const submitForm = useAction('submit_form');
|
||||
|
||||
return (
|
||||
<button onClick={() => submitForm({ formId: 'contact' })}>
|
||||
Submit
|
||||
</button>
|
||||
);
|
||||
}`}</Code>
|
||||
},`}</Code>
|
||||
|
||||
{/* Renderer Section */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
|
||||
<Code lang="tsx">{`import { Renderer, ActionProvider } from '@json-render/react';
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Wire everything together with providers and the{" "}
|
||||
<code>{"<Renderer />"}</code> component:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
|
||||
import {
|
||||
Renderer,
|
||||
DataProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from '@json-render/react';
|
||||
import { registry, handlers } from './registry';
|
||||
|
||||
function App({ spec, data, setData }) {
|
||||
const dataRef = useRef(data);
|
||||
const setDataRef = useRef(setData);
|
||||
dataRef.current = data;
|
||||
setDataRef.current = setData;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() => handlers(() => setDataRef.current, () => dataRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
spec={uiSpec}
|
||||
registry={registry}
|
||||
/>
|
||||
</ActionProvider>
|
||||
<DataProvider initialData={data}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
|
||||
@@ -98,25 +98,19 @@ export default function SchemasPage() {
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "Dashboard" },
|
||||
"children": ["text-1", "button-1"],
|
||||
"parentKey": ""
|
||||
"children": ["text-1", "button-1"]
|
||||
},
|
||||
"text-1": {
|
||||
"key": "text-1",
|
||||
"type": "Text",
|
||||
"props": { "content": "Welcome, $data.user.name" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
"key": "button-1",
|
||||
"type": "Button",
|
||||
"props": { "label": "Click me" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
@@ -129,11 +123,9 @@ export default function SchemasPage() {
|
||||
structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface Element {
|
||||
key: string; // Unique identifier
|
||||
type: string; // Component type from catalog
|
||||
props: Record<string, any>; // Component properties
|
||||
children: string[]; // Array of child element keys
|
||||
parentKey: string; // Parent element key (empty for root)
|
||||
visible?: VisibilityRule; // Conditional display
|
||||
}`}</Code>
|
||||
|
||||
@@ -143,14 +135,12 @@ export default function SchemasPage() {
|
||||
<code className="text-foreground">$data</code> prefix in props:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "greeting",
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"content": "$data.user.name",
|
||||
"count": "$data.items.length"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
"children": []
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
|
||||
|
||||
@@ -50,18 +50,14 @@ export default function SpecsPage() {
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "Welcome" },
|
||||
"children": ["text-1"],
|
||||
"parentKey": ""
|
||||
"children": ["text-1"]
|
||||
},
|
||||
"text-1": {
|
||||
"key": "text-1",
|
||||
"type": "Text",
|
||||
"props": { "content": "Hello, $data.user.name!" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
@@ -74,53 +70,39 @@ export default function SpecsPage() {
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "User Profile", "padding": "md" },
|
||||
"children": ["row-1", "button-1"],
|
||||
"parentKey": ""
|
||||
"children": ["row-1", "button-1"]
|
||||
},
|
||||
"row-1": {
|
||||
"key": "row-1",
|
||||
"type": "Row",
|
||||
"props": { "gap": "md" },
|
||||
"children": ["avatar-1", "stack-1"],
|
||||
"parentKey": "card-1"
|
||||
"children": ["avatar-1", "stack-1"]
|
||||
},
|
||||
"avatar-1": {
|
||||
"key": "avatar-1",
|
||||
"type": "Avatar",
|
||||
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
|
||||
"children": [],
|
||||
"parentKey": "row-1"
|
||||
"children": []
|
||||
},
|
||||
"stack-1": {
|
||||
"key": "stack-1",
|
||||
"type": "Stack",
|
||||
"props": { "gap": "sm" },
|
||||
"children": ["name-text", "email-text"],
|
||||
"parentKey": "row-1"
|
||||
"children": ["name-text", "email-text"]
|
||||
},
|
||||
"name-text": {
|
||||
"key": "name-text",
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.name", "variant": "heading" },
|
||||
"children": [],
|
||||
"parentKey": "stack-1"
|
||||
"children": []
|
||||
},
|
||||
"email-text": {
|
||||
"key": "email-text",
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.email", "variant": "caption" },
|
||||
"children": [],
|
||||
"parentKey": "stack-1"
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
"key": "button-1",
|
||||
"type": "Button",
|
||||
"props": { "label": "Edit Profile" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
@@ -133,21 +115,16 @@ export default function SpecsPage() {
|
||||
"root": "page",
|
||||
"elements": {
|
||||
"page": {
|
||||
"key": "page",
|
||||
"type": "Page",
|
||||
"props": {},
|
||||
"children": ["header", "hero", "features", "footer"],
|
||||
"parentKey": ""
|
||||
"children": ["header", "hero", "features", "footer"]
|
||||
},
|
||||
"header": {
|
||||
"key": "header",
|
||||
"type": "Header",
|
||||
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
|
||||
"children": [],
|
||||
"parentKey": "page"
|
||||
"children": []
|
||||
},
|
||||
"hero": {
|
||||
"key": "hero",
|
||||
"type": "Hero",
|
||||
"props": {
|
||||
"title": "Build UIs with JSON",
|
||||
@@ -155,43 +132,32 @@ export default function SpecsPage() {
|
||||
"ctaLabel": "Get Started",
|
||||
"ctaHref": "/docs"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "page"
|
||||
"children": []
|
||||
},
|
||||
"features": {
|
||||
"key": "features",
|
||||
"type": "Features",
|
||||
"props": { "columns": 3 },
|
||||
"children": ["feature-1", "feature-2", "feature-3"],
|
||||
"parentKey": "page"
|
||||
"children": ["feature-1", "feature-2", "feature-3"]
|
||||
},
|
||||
"feature-1": {
|
||||
"key": "feature-1",
|
||||
"type": "Feature",
|
||||
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
|
||||
"children": [],
|
||||
"parentKey": "features"
|
||||
"children": []
|
||||
},
|
||||
"feature-2": {
|
||||
"key": "feature-2",
|
||||
"type": "Feature",
|
||||
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
|
||||
"children": [],
|
||||
"parentKey": "features"
|
||||
"children": []
|
||||
},
|
||||
"feature-3": {
|
||||
"key": "feature-3",
|
||||
"type": "Feature",
|
||||
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
|
||||
"children": [],
|
||||
"parentKey": "features"
|
||||
"children": []
|
||||
},
|
||||
"footer": {
|
||||
"key": "footer",
|
||||
"type": "Footer",
|
||||
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
|
||||
"children": [],
|
||||
"parentKey": "page"
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
@@ -209,11 +175,9 @@ export default function SpecsPage() {
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "My Card" },
|
||||
"children": ["text-1"],
|
||||
"parentKey": ""
|
||||
"children": ["text-1"]
|
||||
},
|
||||
"text-1": { ... }
|
||||
}
|
||||
@@ -224,17 +188,11 @@ export default function SpecsPage() {
|
||||
Each element in the map has a consistent shape:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "unique-id",
|
||||
"type": "ComponentName",
|
||||
"props": { "label": "Hello" },
|
||||
"children": ["child-1", "child-2"],
|
||||
"parentKey": "parent-id"
|
||||
"children": ["child-1", "child-2"]
|
||||
}`}</Code>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">key</code> — Unique identifier for
|
||||
this element
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">type</code> — Component type from
|
||||
your catalog
|
||||
@@ -246,10 +204,6 @@ export default function SpecsPage() {
|
||||
<code className="text-foreground">children</code> — Array of child
|
||||
element keys
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">parentKey</code> — Key of parent
|
||||
element (empty string for root)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
|
||||
@@ -258,15 +212,13 @@ export default function SpecsPage() {
|
||||
<code className="text-foreground">$data</code> paths:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "metric-1",
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"value": "$data.metrics.revenue",
|
||||
"change": "$data.metrics.revenueChange"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "dashboard"
|
||||
"children": []
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
|
||||
@@ -275,13 +227,11 @@ export default function SpecsPage() {
|
||||
<code className="text-foreground">visible</code> property:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "alert-1",
|
||||
"type": "Alert",
|
||||
"props": {
|
||||
"message": "You have unsaved changes"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "form",
|
||||
"visible": {
|
||||
"path": "$data.form.isDirty",
|
||||
"operator": "eq",
|
||||
|
||||
@@ -18,9 +18,10 @@ export default function StreamingPage() {
|
||||
format where each line is a JSON patch operation that progressively
|
||||
builds your spec:
|
||||
</p>
|
||||
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
|
||||
{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
|
||||
{"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
|
||||
<Code lang="json">{`{"op":"add","path":"/root","value":"root"}
|
||||
{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
|
||||
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
|
||||
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
@@ -34,34 +35,55 @@ function App() {
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
abort, // Function to cancel streaming
|
||||
clear, // Function to reset spec and error
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onChunk: (chunk) => {}, // Optional: called for each chunk
|
||||
onFinish: (spec) => {}, // Optional: called when complete
|
||||
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||
onError: (error) => {}, // Optional: called when an error occurs
|
||||
});
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Patch Operations (RFC 6902)
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Supported operations:
|
||||
SpecStream uses{" "}
|
||||
<a
|
||||
href="https://datatracker.ietf.org/doc/html/rfc6902"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
RFC 6902 JSON Patch
|
||||
</a>{" "}
|
||||
operations:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">set</code> — Set the value at a path
|
||||
(creates if needed)
|
||||
<code className="text-foreground">add</code> — Add a value at a path
|
||||
(creates or replaces for objects, inserts for arrays)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">add</code> — Add to an array at a
|
||||
path
|
||||
<code className="text-foreground">remove</code> — Remove the value at
|
||||
a path
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">replace</code> — Replace value at a
|
||||
path
|
||||
<code className="text-foreground">replace</code> — Replace an existing
|
||||
value at a path
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">remove</code> — Remove value at a
|
||||
path
|
||||
<code className="text-foreground">move</code> — Move a value from one
|
||||
path to another (requires{" "}
|
||||
<code className="text-foreground">from</code>)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">copy</code> — Copy a value from one
|
||||
path to another (requires{" "}
|
||||
<code className="text-foreground">from</code>)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">test</code> — Assert that a value at
|
||||
a path equals the given value
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -112,8 +134,14 @@ export async function POST(req: Request) {
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Calling <code className="text-foreground">send</code> again
|
||||
automatically aborts the previous request. Use{" "}
|
||||
<code className="text-foreground">clear</code> to reset the spec and
|
||||
error state:
|
||||
</p>
|
||||
<Code lang="tsx">{`function App() {
|
||||
const { isStreaming, send, abort } = useUIStream({
|
||||
const { isStreaming, send, clear } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
@@ -122,9 +150,7 @@ export async function POST(req: Request) {
|
||||
<button onClick={() => send('Create dashboard')}>
|
||||
Generate
|
||||
</button>
|
||||
{isStreaming && (
|
||||
<button onClick={abort}>Cancel</button>
|
||||
)}
|
||||
<button onClick={clear}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
@@ -165,8 +191,8 @@ async function processStream(reader: ReadableStreamDefaultReader) {
|
||||
</p>
|
||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
|
||||
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
|
||||
const jsonl = \`{"op":"add","path":"/root","value":{"type":"Card"}}
|
||||
{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
|
||||
|
||||
@@ -127,15 +127,16 @@ export const catalog = createCatalog({
|
||||
Constrained output that your components render natively.
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "dashboard",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": "Revenue Dashboard",
|
||||
"description": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"key": "revenue",
|
||||
"root": "dashboard",
|
||||
"elements": {
|
||||
"dashboard": {
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": "Revenue Dashboard"
|
||||
},
|
||||
"children": ["revenue"]
|
||||
},
|
||||
"revenue": {
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
@@ -143,7 +144,7 @@ export const catalog = createCatalog({
|
||||
"format": "currency"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}</Code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,13 +171,11 @@ export const catalog = createCatalog({
|
||||
"root": "card",
|
||||
"elements": {
|
||||
"card": {
|
||||
"key": "card",
|
||||
"type": "Card",
|
||||
"props": { "title": "Revenue" },
|
||||
"children": ["metric", "chart"]
|
||||
},
|
||||
"metric": {
|
||||
"key": "metric",
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
@@ -185,7 +184,6 @@ export const catalog = createCatalog({
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
"key": "chart",
|
||||
"type": "Chart",
|
||||
"props": {
|
||||
"dataPath": "analytics/salesByRegion"
|
||||
|
||||
@@ -62,8 +62,9 @@ USER REQUEST: ${sanitizedPrompt}
|
||||
|
||||
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
|
||||
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
|
||||
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
|
||||
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
|
||||
- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
|
||||
- To remove an element: {"op":"remove","path":"/elements/old-key"}
|
||||
- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
|
||||
- To add children: update the parent element with new children array
|
||||
|
||||
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
|
||||
|
||||
@@ -29,124 +29,109 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
stream: '{"op":"set","path":"/root","value":"card"}',
|
||||
stream: '{"op":"add","path":"/root","value":"card"}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
|
||||
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
key: "email",
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
key: "email",
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
key: "message",
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message", "submit"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
key: "email",
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
key: "message",
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
submit: {
|
||||
key: "submit",
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -10,55 +10,15 @@ import {
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
items: [
|
||||
{ title: "Introduction", href: "/docs" },
|
||||
{ title: "Installation", href: "/docs/installation" },
|
||||
{ title: "Quick Start", href: "/docs/quick-start" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Core Concepts",
|
||||
items: [
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Components", href: "/docs/components" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Actions", href: "/docs/actions" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Guides",
|
||||
items: [
|
||||
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "API Reference",
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flatten all pages for current page lookup
|
||||
const allPages = navigation.flatMap((section) => section.items);
|
||||
import { docsNavigation, allDocsPages } from "@/lib/docs-navigation";
|
||||
|
||||
export function DocsMobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const currentPage = useMemo(() => {
|
||||
const page = allPages.find((page) => page.href === pathname);
|
||||
return page ?? allPages[0];
|
||||
const page = allDocsPages.find((page) => page.href === pathname);
|
||||
return page ?? allDocsPages[0];
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
@@ -72,27 +32,49 @@ export function DocsMobileNav() {
|
||||
<SheetContent className="overflow-y-auto p-6">
|
||||
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
|
||||
<nav className="space-y-6">
|
||||
{navigation.map((section) => (
|
||||
{docsNavigation.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{section.title}
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`text-sm block py-2 transition-colors ${
|
||||
pathname === item.href
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{section.items.map((item) => {
|
||||
const isExternal = item.external;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
{...(isExternal && {
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
})}
|
||||
className={`text-sm block py-2 transition-colors ${
|
||||
pathname === item.href
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
} ${isExternal ? "inline-flex items-center gap-1" : ""}`}
|
||||
>
|
||||
{item.title}
|
||||
{isExternal && (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,79 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
items: [
|
||||
{ title: "Introduction", href: "/docs" },
|
||||
{ title: "Installation", href: "/docs/installation" },
|
||||
{ title: "Quick Start", href: "/docs/quick-start" },
|
||||
{ title: "Changelog", href: "/docs/changelog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Core Concepts",
|
||||
items: [
|
||||
{ title: "Specs", href: "/docs/specs" },
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Examples",
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Remotion",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Guides",
|
||||
items: [
|
||||
{ title: "Custom Schema", href: "/docs/custom-schema" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
items: [
|
||||
{ title: "AI SDK", href: "/docs/ai-sdk" },
|
||||
{ title: "A2UI", href: "/docs/a2ui" },
|
||||
{ title: "Adaptive Cards", href: "/docs/adaptive-cards" },
|
||||
{ title: "AG-UI", href: "/docs/ag-ui" },
|
||||
{ title: "OpenAPI", href: "/docs/openapi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "API Reference",
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
];
|
||||
import { docsNavigation } from "@/lib/docs-navigation";
|
||||
|
||||
export function DocsSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="space-y-6 pb-8">
|
||||
{navigation.map((section) => (
|
||||
{docsNavigation.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
|
||||
{section.title}
|
||||
@@ -83,7 +18,7 @@ export function DocsSidebar() {
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const isExternal = "external" in item && item.external;
|
||||
const isExternal = item.external;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export type NavItem = {
|
||||
title: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export type NavSection = {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
export const docsNavigation: NavSection[] = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
items: [
|
||||
{ title: "Introduction", href: "/docs" },
|
||||
{ title: "Installation", href: "/docs/installation" },
|
||||
{ title: "Quick Start", href: "/docs/quick-start" },
|
||||
{ title: "Changelog", href: "/docs/changelog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Core Concepts",
|
||||
items: [
|
||||
{ title: "Specs", href: "/docs/specs" },
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Examples",
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Remotion",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Guides",
|
||||
items: [
|
||||
{ title: "Custom Schema", href: "/docs/custom-schema" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
items: [
|
||||
{ title: "AI SDK", href: "/docs/ai-sdk" },
|
||||
{ title: "A2UI", href: "/docs/a2ui" },
|
||||
{ title: "Adaptive Cards", href: "/docs/adaptive-cards" },
|
||||
{ title: "AG-UI", href: "/docs/ag-ui" },
|
||||
{ title: "OpenAPI", href: "/docs/openapi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "API Reference",
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flatten all pages for current page lookup (excludes external links)
|
||||
export const allDocsPages = docsNavigation.flatMap((section) =>
|
||||
section.items.filter((item) => !item.external),
|
||||
);
|
||||
+11
-1
@@ -1,4 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/docs/components",
|
||||
destination: "/docs/registry",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
# AI Gateway API Key
|
||||
# Get your key from Vercel AI Gateway dashboard
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# Optional: Stripe API keys if you want to use real data
|
||||
# STRIPE_SECRET_KEY=sk_test_...
|
||||
# STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
@@ -0,0 +1,99 @@
|
||||
# Stripe App Example
|
||||
|
||||
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
|
||||
|
||||
## Overview
|
||||
|
||||
This example shows how to integrate json-render with Stripe's UI Extension SDK to create dashboard views that can be dynamically generated from prompts. The app includes:
|
||||
|
||||
- **Home** - Revenue dashboard with AI-powered UI generation
|
||||
- **Customers** - Customer list and management
|
||||
- **Customer Details** - Individual customer view
|
||||
- **Payments** - Payment list and details
|
||||
- **Subscriptions** - Subscription management
|
||||
- **Invoices** - Invoice list
|
||||
- **Products** - Product catalog
|
||||
|
||||
## Features
|
||||
|
||||
- Full Stripe UIXT component catalog mapped to json-render
|
||||
- Dynamic spec generation from natural language prompts
|
||||
- Real-time data binding with Stripe API
|
||||
- Action handlers for Stripe operations (refunds, subscriptions, etc.)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
|
||||
- A Stripe account with app development enabled
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the Stripe App in development mode
|
||||
stripe apps start
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
|
||||
|
||||
```bash
|
||||
# Run linting
|
||||
pnpm lint
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Component Catalog**: Maps json-render component types to Stripe UIXT components (`Box`, `Button`, `Badge`, etc.)
|
||||
|
||||
2. **Action Handlers**: Defines actions that can be triggered from the UI (e.g., `refundPayment`, `cancelSubscription`)
|
||||
|
||||
3. **StripeRenderer**: A custom renderer that uses the Stripe component catalog to render json-render specs
|
||||
|
||||
4. **Views**: Each view fetches data from the Stripe API and renders it using json-render specs
|
||||
|
||||
## Example Usage
|
||||
|
||||
```tsx
|
||||
import { StripeRenderer, stripeCatalog } from "./lib/render";
|
||||
import type { Spec } from "@json-render/react";
|
||||
|
||||
const spec: Spec = {
|
||||
root: "container",
|
||||
elements: {
|
||||
container: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "medium" },
|
||||
children: ["heading", "metric"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Revenue", size: "large" },
|
||||
children: [],
|
||||
},
|
||||
metric: {
|
||||
type: "Metric",
|
||||
props: { label: "Total", value: "$12,345", format: "currency" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function MyView() {
|
||||
return <StripeRenderer spec={spec} catalog={stripeCatalog} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [json-render Documentation](https://json-render.com/docs)
|
||||
- [Stripe Apps Documentation](https://stripe.com/docs/stripe-apps)
|
||||
- [Stripe UI Extension SDK](https://stripe.com/docs/stripe-apps/reference/ui-extension-sdk)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...reactConfig,
|
||||
{
|
||||
rules: {
|
||||
// Disable prop-types - we use TypeScript for type checking
|
||||
"react/prop-types": "off",
|
||||
// Allow underscore-prefixed unused variables
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const UIExtensionsConfig = require("@stripe/ui-extension-tools/jest.config.ui-extension");
|
||||
|
||||
module.exports = {
|
||||
...UIExtensionsConfig,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.1",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "^9.1.0",
|
||||
"stripe": "^13.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@stripe/ui-extension-tools": "^0.0.1",
|
||||
"eslint": "^9.39.0"
|
||||
}
|
||||
}
|
||||
Generated
+5270
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
// Catalog
|
||||
export { stripeCatalog } from "./catalog";
|
||||
|
||||
// Components
|
||||
export { components, Fallback } from "./catalog/components";
|
||||
|
||||
// Actions
|
||||
export { actionHandlers, executeAction } from "./catalog/actions";
|
||||
|
||||
// Renderer
|
||||
export { StripeRenderer, type StripeRendererProps } from "./renderer";
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRegistry,
|
||||
type Spec,
|
||||
DataProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type SetData = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export interface StripeRendererProps {
|
||||
/** The UI spec to render (from json-render) */
|
||||
spec: Spec | null;
|
||||
/** Data context for components */
|
||||
data?: Record<string, unknown>;
|
||||
/** Function to update data */
|
||||
setData?: SetData;
|
||||
/** Callback when data changes */
|
||||
onDataChange?: (path: string, value: unknown) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Build Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build a component registry from our components map.
|
||||
* 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, Component] of Object.entries(components)) {
|
||||
registry[name] = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
children?: ReactNode;
|
||||
onAction?: (action: {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void;
|
||||
}) =>
|
||||
Component({
|
||||
element: renderProps.element,
|
||||
children: renderProps.children,
|
||||
onAction: (action) => {
|
||||
const setData = setDataRef.current;
|
||||
const data = dataRef.current;
|
||||
if (setData) {
|
||||
executeAction(action.name, action.params, setData, data);
|
||||
}
|
||||
},
|
||||
loading,
|
||||
data: dataRef.current,
|
||||
getValue: (path: string) => {
|
||||
const data = dataRef.current;
|
||||
const parts = path.replace(/^\//, "").split("/");
|
||||
let current: unknown = data;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === "object" && part in current) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback component for unknown types
|
||||
*/
|
||||
const fallbackRegistry = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
}) => <Fallback element={renderProps.element} />;
|
||||
|
||||
// =============================================================================
|
||||
// StripeRenderer Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Main renderer component for Stripe UIXT.
|
||||
*
|
||||
* Wraps the json-render Renderer with all necessary providers
|
||||
* and connects it to the Stripe component implementations.
|
||||
*/
|
||||
export function StripeRenderer({
|
||||
spec,
|
||||
data = {},
|
||||
setData,
|
||||
onDataChange,
|
||||
loading,
|
||||
}: StripeRendererProps): ReactNode {
|
||||
// Use refs to keep registry stable while still accessing 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],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<DataProvider initialData={data} onDataChange={onDataChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRegistry}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Stripe from "stripe";
|
||||
import {
|
||||
createHttpClient,
|
||||
STRIPE_API_KEY,
|
||||
} from "@stripe/ui-extension-sdk/http_client";
|
||||
|
||||
/**
|
||||
* Create a Stripe client for use in Stripe Apps.
|
||||
*
|
||||
* The Stripe UI Extension SDK handles authentication automatically
|
||||
* through the httpClient - no real API key is needed.
|
||||
*/
|
||||
export const stripe = new Stripe(STRIPE_API_KEY, {
|
||||
httpClient: createHttpClient(),
|
||||
apiVersion: "2024-12-18.acacia",
|
||||
});
|
||||
|
||||
/**
|
||||
* Format amount for display (converts cents to dollars)
|
||||
*/
|
||||
export function formatAmount(amount: number, currency = "usd"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
export function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, getMockContextProps } from "@stripe/ui-extension-sdk/testing";
|
||||
import { ContextView } from "@stripe/ui-extension-sdk/ui";
|
||||
|
||||
import CustomerDetails from "./CustomerDetails";
|
||||
|
||||
describe("CustomerDetailsView", () => {
|
||||
it("renders ContextView", () => {
|
||||
const { wrapper } = render(<CustomerDetails {...getMockContextProps()} />);
|
||||
|
||||
expect(wrapper.find(ContextView)).toContainText("save to reload this view");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createCustomerDetailSpec(
|
||||
data: Record<string, unknown>,
|
||||
customerId: string,
|
||||
): Spec {
|
||||
const customers = data.customers as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
status: string;
|
||||
created: string;
|
||||
balance?: number;
|
||||
currency?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const customer = customers?.data?.find((c) => c.id === customerId);
|
||||
|
||||
const payments = data.customerPayments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
formattedAmount: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const subscriptions = data.customerSubscriptions as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
planName: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
interval: string;
|
||||
currentPeriodEnd: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const invoices = data.customerInvoices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const paymentsList = payments?.data ?? [];
|
||||
const subscriptionsList = subscriptions?.data ?? [];
|
||||
const invoicesList = invoices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["header", "details", "sections"],
|
||||
},
|
||||
header: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "medium",
|
||||
distribute: "space-between",
|
||||
},
|
||||
children: ["customerInfo", "actions"],
|
||||
},
|
||||
customerInfo: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "xsmall" },
|
||||
children: ["customerName", "customerEmail"],
|
||||
},
|
||||
customerName: {
|
||||
type: "Heading",
|
||||
props: { text: customer?.name ?? "Unknown Customer", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
customerEmail: {
|
||||
type: "Text",
|
||||
props: { content: customer?.email ?? "", color: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["editBtn", "portalBtn"],
|
||||
},
|
||||
editBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Edit",
|
||||
action: "viewCustomer",
|
||||
actionParams: { customerId },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
portalBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Billing Portal",
|
||||
action: "createBillingPortalSession",
|
||||
actionParams: { customerId, returnUrl: window.location.href },
|
||||
type: "primary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
details: {
|
||||
type: "PropertyList",
|
||||
props: { orientation: "horizontal" },
|
||||
children: ["detailStatus", "detailCreated", "detailBalance"],
|
||||
},
|
||||
detailStatus: {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Status", value: customer?.status ?? "Unknown" },
|
||||
children: [],
|
||||
},
|
||||
detailCreated: {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Created", value: customer?.created ?? "Unknown" },
|
||||
children: [],
|
||||
},
|
||||
detailBalance: {
|
||||
type: "PropertyListItem",
|
||||
props: {
|
||||
label: "Balance",
|
||||
value: customer?.balance
|
||||
? `$${(customer.balance / 100).toFixed(2)}`
|
||||
: "$0.00",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
sections: {
|
||||
type: "Accordion",
|
||||
props: {},
|
||||
children: ["paymentsSection", "subscriptionsSection", "invoicesSection"],
|
||||
},
|
||||
|
||||
// Payments Section
|
||||
paymentsSection: {
|
||||
type: "AccordionItem",
|
||||
props: { title: `Payments (${payments?.total ?? 0})`, defaultOpen: true },
|
||||
children: paymentsList.length > 0 ? ["paymentsList"] : ["noPayments"],
|
||||
},
|
||||
paymentsList: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: paymentsList.slice(0, 5).map((_, i) => `payment${i}`),
|
||||
},
|
||||
noPayments: {
|
||||
type: "Text",
|
||||
props: { content: "No payments found", color: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
|
||||
// Subscriptions Section
|
||||
subscriptionsSection: {
|
||||
type: "AccordionItem",
|
||||
props: {
|
||||
title: `Subscriptions (${subscriptions?.total ?? 0})`,
|
||||
defaultOpen: false,
|
||||
},
|
||||
children:
|
||||
subscriptionsList.length > 0
|
||||
? ["subscriptionsList"]
|
||||
: ["noSubscriptions"],
|
||||
},
|
||||
subscriptionsList: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: subscriptionsList.slice(0, 5).map((_, i) => `subscription${i}`),
|
||||
},
|
||||
noSubscriptions: {
|
||||
type: "Text",
|
||||
props: { content: "No subscriptions found", color: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
|
||||
// Invoices Section
|
||||
invoicesSection: {
|
||||
type: "AccordionItem",
|
||||
props: {
|
||||
title: `Invoices (${invoices?.total ?? 0})`,
|
||||
defaultOpen: false,
|
||||
},
|
||||
children: invoicesList.length > 0 ? ["invoicesList"] : ["noInvoices"],
|
||||
},
|
||||
invoicesList: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: invoicesList.slice(0, 5).map((_, i) => `invoice${i}`),
|
||||
},
|
||||
noInvoices: {
|
||||
type: "Text",
|
||||
props: { content: "No invoices found", color: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Add payment cards
|
||||
paymentsList.slice(0, 5).forEach((p, i) => {
|
||||
elements[`payment${i}`] = {
|
||||
type: "PaymentCard",
|
||||
props: {
|
||||
amount: 0,
|
||||
currency: "usd",
|
||||
status: p.status as "succeeded" | "pending" | "failed",
|
||||
description: `${p.formattedAmount} - ${p.description}`,
|
||||
paymentId: p.id,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Add subscription cards
|
||||
subscriptionsList.slice(0, 5).forEach((s, i) => {
|
||||
elements[`subscription${i}`] = {
|
||||
type: "SubscriptionCard",
|
||||
props: {
|
||||
planName: s.planName,
|
||||
status: s.status as "active" | "trialing" | "past_due" | "canceled",
|
||||
amount: s.amount,
|
||||
currency: "usd",
|
||||
interval: s.interval as "month" | "year",
|
||||
currentPeriodEnd: s.currentPeriodEnd,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Add invoice cards
|
||||
invoicesList.slice(0, 5).forEach((inv, i) => {
|
||||
elements[`invoice${i}`] = {
|
||||
type: "InvoiceCard",
|
||||
props: {
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
amount: inv.amount,
|
||||
currency: "usd",
|
||||
status: inv.status as "draft" | "open" | "paid" | "void",
|
||||
dueDate: inv.dueDate,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const CustomerDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// Get customer ID from context
|
||||
const customerId = environment?.objectContext?.id ?? "";
|
||||
|
||||
// Wrap setData for action handlers
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load customer data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!customerId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Fetch customer and related data
|
||||
await Promise.all([
|
||||
executeAction(
|
||||
"fetchCustomers",
|
||||
{ email: null, limit: 100 },
|
||||
handleSetData,
|
||||
{},
|
||||
),
|
||||
executeAction(
|
||||
"fetchPayments",
|
||||
{ customerId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, customerPayments: next.payments };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
executeAction(
|
||||
"fetchSubscriptions",
|
||||
{ customerId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, customerSubscriptions: next.subscriptions };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
executeAction(
|
||||
"fetchInvoices",
|
||||
{ customerId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, customerInvoices: next.invoices };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [customerId, handleSetData]);
|
||||
|
||||
// Update spec when data changes
|
||||
useEffect(() => {
|
||||
if (!loading && customerId) {
|
||||
setSpec(createCustomerDetailSpec(data, customerId));
|
||||
}
|
||||
}, [data, loading, customerId]);
|
||||
|
||||
// Generate custom UI
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: `For customer ${customerId}: ${prompt}`,
|
||||
systemPrompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createCustomerDetailSpec(data, customerId));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (!customerId) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box css={{ color: "secondary", padding: "large" }}>
|
||||
No customer selected. Please select a customer from the list.
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading customer details...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction("viewCustomer", { customerId }, handleSetData, data)
|
||||
}
|
||||
>
|
||||
View in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
{/* AI Generation Input */}
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the customer view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Rendered Spec */}
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerDetails;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, getMockContextProps } from "@stripe/ui-extension-sdk/testing";
|
||||
import { ContextView } from "@stripe/ui-extension-sdk/ui";
|
||||
|
||||
import Customers from "./Customers";
|
||||
|
||||
describe("CustomersView", () => {
|
||||
it("renders ContextView", () => {
|
||||
const { wrapper } = render(<Customers {...getMockContextProps()} />);
|
||||
|
||||
expect(wrapper.find(ContextView)).toContainText("save to reload this view");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createCustomersListSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const customerList = customers?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "actions", "list", "pagination"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Customer Directory", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
stats: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["totalMetric"],
|
||||
},
|
||||
totalMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "exportBtn"],
|
||||
},
|
||||
refreshBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh",
|
||||
action: "refreshCustomers",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
exportBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "customers" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: customerList.slice(0, 10).map((_, i) => `customer${i}`),
|
||||
},
|
||||
pagination: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: customers?.hasMore ? ["loadMore"] : [],
|
||||
},
|
||||
loadMore: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchCustomers",
|
||||
actionParams: {
|
||||
limit: 10,
|
||||
startingAfter: customerList[customerList.length - 1]?.id,
|
||||
},
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
customerList.slice(0, 10).forEach((c, i) => {
|
||||
elements[`customer${i}`] = {
|
||||
type: "CustomerCard",
|
||||
props: {
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
status: c.status as "active" | "inactive",
|
||||
customerId: c.id,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Customers = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// Wrap setData for action handlers
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction("fetchCustomers", { limit: 10 }, handleSetData, {});
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
// Update spec when data changes
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createCustomersListSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
// Generate custom UI
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
// Use catalog prompt for system context
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
// Fallback to data-driven spec
|
||||
setSpec(createCustomersListSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Directory"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading customers...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Customer Directory"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "customers" },
|
||||
handleSetData,
|
||||
data,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
{/* AI Generation Input */}
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the customer view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Rendered Spec */}
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Customers;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, getMockContextProps } from "@stripe/ui-extension-sdk/testing";
|
||||
import { ContextView } from "@stripe/ui-extension-sdk/ui";
|
||||
|
||||
import Home from "./Home";
|
||||
|
||||
describe("DashboardHomepageView", () => {
|
||||
it("renders ContextView", () => {
|
||||
const { wrapper } = render(<Home {...getMockContextProps()} />);
|
||||
|
||||
expect(wrapper.find(ContextView)).toContainText("save to reload this view");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,699 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Divider,
|
||||
Button,
|
||||
TextField,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
List,
|
||||
ListItem,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Specs (use real data from context)
|
||||
// =============================================================================
|
||||
|
||||
function createRevenueSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as { total?: number } | undefined;
|
||||
const payments = data.payments as
|
||||
| { totalVolume?: string; successRate?: string }
|
||||
| undefined;
|
||||
const subscriptions = data.subscriptions as
|
||||
| { active?: number; trialing?: number; pastDue?: number }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "refresh"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Revenue Dashboard", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["m1", "m2", "m3"],
|
||||
},
|
||||
m1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Payment Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
change: payments?.successRate
|
||||
? `${payments.successRate} success`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m2: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active Subscriptions",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
change: subscriptions?.trialing
|
||||
? `+${subscriptions.trialing} trialing`
|
||||
: null,
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m3: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Data",
|
||||
action: "refreshData",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPaymentsSpec(data: Record<string, unknown>): Spec {
|
||||
const payments = data.payments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
formattedAmount: string;
|
||||
status: string;
|
||||
description: string;
|
||||
}>;
|
||||
totalVolume?: string;
|
||||
successRate?: string;
|
||||
}
|
||||
| undefined;
|
||||
const paymentsList = payments?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "payments", "refresh"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Recent Payments", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
stats: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["s1", "s2"],
|
||||
},
|
||||
s1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
s2: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Success Rate",
|
||||
value: payments?.successRate ?? "0%",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
payments: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: paymentsList.slice(0, 5).map((_, i) => `p${i}`),
|
||||
},
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Payments",
|
||||
action: "fetchPayments",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
paymentsList.slice(0, 5).forEach((p, i) => {
|
||||
elements[`p${i}`] = {
|
||||
type: "PaymentCard",
|
||||
props: {
|
||||
amount: 0, // We'll use formattedAmount in description
|
||||
currency: "usd",
|
||||
status: p.status as "succeeded" | "pending" | "failed",
|
||||
description: `${p.formattedAmount} - ${p.description}`,
|
||||
paymentId: p.id,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
|
||||
const subscriptions = data.subscriptions as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
planName: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
interval: string;
|
||||
}>;
|
||||
active?: number;
|
||||
trialing?: number;
|
||||
pastDue?: number;
|
||||
}
|
||||
| undefined;
|
||||
const subsList = subscriptions?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "alert", "subs", "refresh"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Subscriptions Overview", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["m1", "m2", "m3"],
|
||||
},
|
||||
m1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m2: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Trialing",
|
||||
value: String(subscriptions?.trialing ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
m3: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Past Due",
|
||||
value: String(subscriptions?.pastDue ?? 0),
|
||||
changeType: subscriptions?.pastDue ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
alert: {
|
||||
type: "Banner",
|
||||
props: {
|
||||
title: `${subscriptions?.trialing ?? 0} subscriptions currently trialing`,
|
||||
type: "default",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
subs: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: subsList.slice(0, 5).map((_, i) => `sub${i}`),
|
||||
},
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Subscriptions",
|
||||
action: "fetchSubscriptions",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
subsList.slice(0, 5).forEach((s, i) => {
|
||||
elements[`sub${i}`] = {
|
||||
type: "SubscriptionCard",
|
||||
props: {
|
||||
planName: s.planName,
|
||||
status: s.status as "active" | "trialing" | "past_due" | "canceled",
|
||||
amount: s.amount,
|
||||
interval: (s.interval as "month" | "year") ?? "month",
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
function createCustomersSpec(data: Record<string, unknown>): Spec {
|
||||
const customers = data.customers as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
const customersList = customers?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "customers", "refresh"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Customer Directory", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
stats: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["s1"],
|
||||
},
|
||||
s1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Customers",
|
||||
value: String(customers?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
customers: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: customersList.slice(0, 5).map((_, i) => `c${i}`),
|
||||
},
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Customers",
|
||||
action: "fetchCustomers",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
customersList.slice(0, 5).forEach((c, i) => {
|
||||
elements[`c${i}`] = {
|
||||
type: "CustomerCard",
|
||||
props: {
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
status: c.status as "active" | "inactive",
|
||||
customerId: c.id,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
function createInvoicesSpec(data: Record<string, unknown>): Spec {
|
||||
const invoices = data.invoices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
}>;
|
||||
outstanding?: string;
|
||||
paid?: string;
|
||||
overdue?: string;
|
||||
}
|
||||
| undefined;
|
||||
const invoicesList = invoices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "stats", "invoices", "refresh"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Invoice Management", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
stats: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["s1", "s2", "s3"],
|
||||
},
|
||||
s1: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Outstanding",
|
||||
value: invoices?.outstanding ?? "$0",
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
s2: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Paid",
|
||||
value: invoices?.paid ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
s3: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Overdue",
|
||||
value: invoices?.overdue ?? "$0",
|
||||
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
invoices: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: invoicesList.slice(0, 5).map((_, i) => `inv${i}`),
|
||||
},
|
||||
refresh: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh Invoices",
|
||||
action: "fetchInvoices",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
invoicesList.slice(0, 5).forEach((inv, i) => {
|
||||
elements[`inv${i}`] = {
|
||||
type: "InvoiceCard",
|
||||
props: {
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
amount: inv.amount,
|
||||
status: inv.status as "draft" | "open" | "paid" | "void",
|
||||
dueDate: inv.dueDate,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Catalog Info for Display
|
||||
// =============================================================================
|
||||
|
||||
const componentCatalogInfo = Object.entries(stripeCatalog.data.components).map(
|
||||
([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
}),
|
||||
);
|
||||
|
||||
const actionsCatalogInfo = Object.entries(stripeCatalog.data.actions).map(
|
||||
([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
}),
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Home Component
|
||||
// =============================================================================
|
||||
|
||||
const Home = (_props: ExtensionContextValue) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentSpec, setCurrentSpec] = useState<Spec | null>(null);
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await executeAction("refreshData", {}, handleSetData, {});
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err);
|
||||
setError("Failed to load Stripe data");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:3000/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
systemPrompt: stripeCatalog.prompt({
|
||||
system:
|
||||
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const responseData = await response.json();
|
||||
if (responseData.spec) {
|
||||
setCurrentSpec(responseData.spec);
|
||||
return;
|
||||
} else if (responseData.error) {
|
||||
throw new Error(responseData.error);
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid response");
|
||||
} catch (err) {
|
||||
// Use dynamic specs with real data as fallback
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerPrompt.includes("payment") ||
|
||||
lowerPrompt.includes("transaction")
|
||||
) {
|
||||
setCurrentSpec(createPaymentsSpec(data));
|
||||
} else if (
|
||||
lowerPrompt.includes("subscription") ||
|
||||
lowerPrompt.includes("recurring")
|
||||
) {
|
||||
setCurrentSpec(createSubscriptionsSpec(data));
|
||||
} else if (
|
||||
lowerPrompt.includes("customer") ||
|
||||
lowerPrompt.includes("user")
|
||||
) {
|
||||
setCurrentSpec(createCustomersSpec(data));
|
||||
} else if (
|
||||
lowerPrompt.includes("invoice") ||
|
||||
lowerPrompt.includes("billing")
|
||||
) {
|
||||
setCurrentSpec(createInvoicesSpec(data));
|
||||
} else {
|
||||
setCurrentSpec(createRevenueSpec(data));
|
||||
}
|
||||
|
||||
setError(
|
||||
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="json-render Demo"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
externalLink={{
|
||||
label: "json-render docs",
|
||||
href: "https://github.com/vercel-labs/json-render",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ font: "body", color: "secondary" }}>
|
||||
Loading Stripe data...
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Tabs fitted>
|
||||
<TabList>
|
||||
<Tab id="generate">Generate</Tab>
|
||||
<Tab id="components">Components</Tab>
|
||||
<Tab id="actions">Actions</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{/* Generate Tab */}
|
||||
<TabPanel id="generate">
|
||||
<Box css={{ stack: "y", gap: "medium", paddingY: "medium" }}>
|
||||
<TextField
|
||||
label="Describe the dashboard you want"
|
||||
placeholder="e.g., Show MRR, payments, or subscriptions"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
>
|
||||
{isGenerating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Box css={{ color: "secondary", font: "caption" }}>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentSpec && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box css={{ font: "subheading" }}>
|
||||
Preview (with real Stripe data)
|
||||
</Box>
|
||||
<Box
|
||||
css={{
|
||||
padding: "medium",
|
||||
borderRadius: "medium",
|
||||
keyline: "neutral",
|
||||
}}
|
||||
>
|
||||
<StripeRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Box css={{ font: "caption", color: "secondary" }}>
|
||||
Try: "Show revenue metrics" • "Show recent
|
||||
payments" • "Show subscriptions" •
|
||||
"Show customers" • "Show invoices"
|
||||
</Box>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Components Catalog Tab */}
|
||||
<TabPanel id="components">
|
||||
<Box css={{ paddingY: "medium" }}>
|
||||
<Box css={{ font: "subheading", marginBottom: "medium" }}>
|
||||
Available Components ({componentCatalogInfo.length})
|
||||
</Box>
|
||||
<List>
|
||||
{componentCatalogInfo.map((comp) => (
|
||||
<ListItem
|
||||
key={comp.name}
|
||||
title={comp.name}
|
||||
secondaryTitle={comp.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Actions Catalog Tab */}
|
||||
<TabPanel id="actions">
|
||||
<Box css={{ paddingY: "medium" }}>
|
||||
<Box css={{ font: "subheading", marginBottom: "medium" }}>
|
||||
Available Actions ({actionsCatalogInfo.length})
|
||||
</Box>
|
||||
<List>
|
||||
{actionsCatalogInfo.map((action) => (
|
||||
<ListItem
|
||||
key={action.name}
|
||||
title={action.name}
|
||||
secondaryTitle={action.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createInvoicesSpec(data: Record<string, unknown>): Spec {
|
||||
const invoices = data.invoices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
dueDate: string;
|
||||
customerEmail: string;
|
||||
formattedAmount: string;
|
||||
}>;
|
||||
total?: number;
|
||||
outstanding?: string;
|
||||
paid?: string;
|
||||
overdue?: string;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const invoicesList = invoices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: [
|
||||
"heading",
|
||||
"metrics",
|
||||
"alert",
|
||||
"filters",
|
||||
"list",
|
||||
"pagination",
|
||||
],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Invoices", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["outstandingMetric", "paidMetric", "overdueMetric"],
|
||||
},
|
||||
outstandingMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Outstanding",
|
||||
value: invoices?.outstanding ?? "$0",
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
paidMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Paid",
|
||||
value: invoices?.paid ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
overdueMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Overdue",
|
||||
value: invoices?.overdue ?? "$0",
|
||||
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
alert: {
|
||||
type: "Banner",
|
||||
props: {
|
||||
title:
|
||||
invoices?.overdue !== "$0.00"
|
||||
? `You have ${invoices?.overdue} in overdue invoices`
|
||||
: "All invoices are up to date",
|
||||
type: invoices?.overdue !== "$0.00" ? "caution" : "default",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
filters: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "createBtn", "exportBtn"],
|
||||
},
|
||||
refreshBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Refresh", action: "refreshInvoices", type: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
createBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Create Invoice",
|
||||
action: "openDashboard",
|
||||
actionParams: { page: "invoices" },
|
||||
type: "primary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
exportBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "invoices" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: invoicesList.slice(0, 10).map((_, i) => `invoice${i}`),
|
||||
},
|
||||
pagination: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: invoices?.hasMore ? ["loadMore"] : [],
|
||||
},
|
||||
loadMore: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchInvoices",
|
||||
actionParams: { limit: 10 },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
invoicesList.slice(0, 10).forEach((inv, i) => {
|
||||
elements[`invoice${i}`] = {
|
||||
type: "InvoiceCard",
|
||||
props: {
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
amount: inv.amount,
|
||||
currency: "usd",
|
||||
status: inv.status as
|
||||
| "draft"
|
||||
| "open"
|
||||
| "paid"
|
||||
| "void"
|
||||
| "uncollectible",
|
||||
dueDate: inv.dueDate,
|
||||
customerEmail: inv.customerEmail,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Invoices = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction("fetchInvoices", { limit: 10 }, handleSetData, {});
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createInvoicesSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createInvoicesSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView title="Invoices" brandColor="#635BFF" brandIcon={BrandIcon}>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading invoices...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Invoices"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "invoices" },
|
||||
handleSetData,
|
||||
data,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the invoices view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Invoices;
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createPaymentDetailSpec(
|
||||
data: Record<string, unknown>,
|
||||
paymentId: string,
|
||||
): Spec {
|
||||
const payments = data.payments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created: string;
|
||||
formattedAmount: string;
|
||||
customerId?: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const payment = payments?.data?.find((p) => p.id === paymentId);
|
||||
|
||||
const refunds = data.paymentRefunds as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
reason: string;
|
||||
created: string;
|
||||
formattedAmount: string;
|
||||
}>;
|
||||
total?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const refundsList = refunds?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["header", "details", "actions", "refundsSection"],
|
||||
},
|
||||
header: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "horizontal",
|
||||
gap: "medium",
|
||||
distribute: "space-between",
|
||||
},
|
||||
children: ["paymentInfo", "statusBadge"],
|
||||
},
|
||||
paymentInfo: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "xsmall" },
|
||||
children: ["paymentAmount", "paymentId"],
|
||||
},
|
||||
paymentAmount: {
|
||||
type: "Heading",
|
||||
props: { text: payment?.formattedAmount ?? "$0.00", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
paymentId: {
|
||||
type: "Text",
|
||||
props: { content: paymentId, color: "secondary", size: "small" },
|
||||
children: [],
|
||||
},
|
||||
statusBadge: {
|
||||
type: "Badge",
|
||||
props: {
|
||||
label: payment?.status ?? "unknown",
|
||||
type:
|
||||
payment?.status === "succeeded"
|
||||
? "positive"
|
||||
: payment?.status === "pending"
|
||||
? "warning"
|
||||
: "negative",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
details: {
|
||||
type: "PropertyList",
|
||||
props: { orientation: "vertical" },
|
||||
children: [
|
||||
"detailDescription",
|
||||
"detailCreated",
|
||||
"detailCurrency",
|
||||
"detailCustomer",
|
||||
],
|
||||
},
|
||||
detailDescription: {
|
||||
type: "PropertyListItem",
|
||||
props: {
|
||||
label: "Description",
|
||||
value: payment?.description ?? "No description",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
detailCreated: {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Created", value: payment?.created ?? "Unknown" },
|
||||
children: [],
|
||||
},
|
||||
detailCurrency: {
|
||||
type: "PropertyListItem",
|
||||
props: {
|
||||
label: "Currency",
|
||||
value: (payment?.currency ?? "usd").toUpperCase(),
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
detailCustomer: {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Customer", value: payment?.customerId ?? "Guest" },
|
||||
children: [],
|
||||
},
|
||||
actions: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children:
|
||||
payment?.status === "succeeded"
|
||||
? ["refundBtn", "viewBtn"]
|
||||
: ["viewBtn"],
|
||||
},
|
||||
refundBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refund Payment",
|
||||
action: "refundPayment",
|
||||
actionParams: { paymentId },
|
||||
type: "destructive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
viewBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "View in Dashboard",
|
||||
action: "viewPayment",
|
||||
actionParams: { paymentId },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
refundsSection: {
|
||||
type: "Accordion",
|
||||
props: {},
|
||||
children: ["refundsAccordion"],
|
||||
},
|
||||
refundsAccordion: {
|
||||
type: "AccordionItem",
|
||||
props: {
|
||||
title: `Refunds (${refunds?.total ?? 0})`,
|
||||
defaultOpen: refundsList.length > 0,
|
||||
},
|
||||
children: refundsList.length > 0 ? ["refundsList"] : ["noRefunds"],
|
||||
},
|
||||
refundsList: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: refundsList.slice(0, 5).map((_, i) => `refund${i}`),
|
||||
},
|
||||
noRefunds: {
|
||||
type: "Text",
|
||||
props: { content: "No refunds", color: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
refundsList.slice(0, 5).forEach((r, i) => {
|
||||
elements[`refund${i}`] = {
|
||||
type: "RefundCard",
|
||||
props: {
|
||||
amount: r.amount,
|
||||
currency: "usd",
|
||||
status: r.status as "pending" | "succeeded" | "failed" | "canceled",
|
||||
reason: r.reason,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const PaymentDetails = ({ environment }: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const paymentId = environment?.objectContext?.id ?? "";
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!paymentId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
await Promise.all([
|
||||
executeAction("fetchPayments", { limit: 100 }, handleSetData, {}),
|
||||
executeAction(
|
||||
"fetchRefunds",
|
||||
{ paymentIntentId: paymentId, limit: 10 },
|
||||
(updater) => {
|
||||
setData((prev) => {
|
||||
const next = updater(prev);
|
||||
return { ...prev, paymentRefunds: next.refunds };
|
||||
});
|
||||
},
|
||||
{},
|
||||
),
|
||||
]);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [paymentId, handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && paymentId) {
|
||||
setSpec(createPaymentDetailSpec(data, paymentId));
|
||||
}
|
||||
}, [data, loading, paymentId]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: `For payment ${paymentId}: ${prompt}`,
|
||||
systemPrompt,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createPaymentDetailSpec(data, paymentId));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (!paymentId) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Payment Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box css={{ color: "secondary", padding: "large" }}>
|
||||
No payment selected. Please select a payment from the list.
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Payment Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading payment details...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Payment Details"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction("viewPayment", { paymentId }, handleSetData, data)
|
||||
}
|
||||
>
|
||||
View in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the payment view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentDetails;
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createPaymentsSpec(data: Record<string, unknown>): Spec {
|
||||
const payments = data.payments as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
formattedAmount: string;
|
||||
status: string;
|
||||
description: string;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
totalVolume?: string;
|
||||
successRate?: string;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const paymentsList = payments?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "filters", "list", "pagination"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Payments", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["volumeMetric", "rateMetric", "countMetric"],
|
||||
},
|
||||
volumeMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Volume",
|
||||
value: payments?.totalVolume ?? "$0",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
rateMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Success Rate",
|
||||
value: payments?.successRate ?? "0%",
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
countMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Payments",
|
||||
value: String(payments?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
filters: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "exportBtn"],
|
||||
},
|
||||
refreshBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Refresh", action: "refreshPayments", type: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
exportBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "payments" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: paymentsList.slice(0, 10).map((_, i) => `payment${i}`),
|
||||
},
|
||||
pagination: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: payments?.hasMore ? ["loadMore"] : [],
|
||||
},
|
||||
loadMore: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchPayments",
|
||||
actionParams: {
|
||||
limit: 10,
|
||||
startingAfter: paymentsList[paymentsList.length - 1]?.id,
|
||||
},
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
paymentsList.slice(0, 10).forEach((p, i) => {
|
||||
elements[`payment${i}`] = {
|
||||
type: "PaymentCard",
|
||||
props: {
|
||||
amount: 0,
|
||||
currency: "usd",
|
||||
status: p.status as "succeeded" | "pending" | "failed" | "canceled",
|
||||
description: `${p.formattedAmount} - ${p.description}`,
|
||||
paymentId: p.id,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Payments = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction("fetchPayments", { limit: 10 }, handleSetData, {});
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createPaymentsSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createPaymentsSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView title="Payments" brandColor="#635BFF" brandIcon={BrandIcon}>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading payments...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Payments"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "payments" },
|
||||
handleSetData,
|
||||
data,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the payments view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payments;
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createProductsSpec(data: Record<string, unknown>): Spec {
|
||||
const products = data.products as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
created: string;
|
||||
}>;
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const prices = data.prices as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
productId: string;
|
||||
formattedAmount: string;
|
||||
type: string;
|
||||
recurring?: { interval: string };
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const productsList = products?.data ?? [];
|
||||
const pricesList = prices?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: ["heading", "metrics", "filters", "list", "pagination"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Products & Prices", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: ["productsMetric", "pricesMetric", "activeMetric"],
|
||||
},
|
||||
productsMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Products",
|
||||
value: String(products?.total ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
pricesMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Total Prices",
|
||||
value: String(pricesList.length ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
activeMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active Products",
|
||||
value: String(productsList.filter((p) => p.active).length ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
filters: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "createBtn"],
|
||||
},
|
||||
refreshBtn: {
|
||||
type: "Button",
|
||||
props: { label: "Refresh", action: "fetchProducts", type: "secondary" },
|
||||
children: [],
|
||||
},
|
||||
createBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Create Product",
|
||||
action: "openDashboard",
|
||||
actionParams: { page: "products" },
|
||||
type: "primary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: productsList.slice(0, 10).map((_, i) => `product${i}`),
|
||||
},
|
||||
pagination: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: products?.hasMore ? ["loadMore"] : [],
|
||||
},
|
||||
loadMore: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchProducts",
|
||||
actionParams: { limit: 10 },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
productsList.slice(0, 10).forEach((p, i) => {
|
||||
const productPrices = pricesList.filter((pr) => pr.productId === p.id);
|
||||
const priceDisplay =
|
||||
productPrices.length > 0
|
||||
? productPrices
|
||||
.map(
|
||||
(pr) =>
|
||||
`${pr.formattedAmount}${pr.recurring ? `/${pr.recurring.interval}` : ""}`,
|
||||
)
|
||||
.join(", ")
|
||||
: "No prices";
|
||||
|
||||
elements[`product${i}`] = {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: [`productCard${i}`],
|
||||
};
|
||||
|
||||
elements[`productCard${i}`] = {
|
||||
type: "PropertyList",
|
||||
props: { orientation: "vertical" },
|
||||
children: [
|
||||
`productName${i}`,
|
||||
`productDesc${i}`,
|
||||
`productPrice${i}`,
|
||||
`productStatus${i}`,
|
||||
],
|
||||
};
|
||||
|
||||
elements[`productName${i}`] = {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Name", value: p.name },
|
||||
children: [],
|
||||
};
|
||||
|
||||
elements[`productDesc${i}`] = {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Description", value: p.description || "No description" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
elements[`productPrice${i}`] = {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Prices", value: priceDisplay },
|
||||
children: [],
|
||||
};
|
||||
|
||||
elements[`productStatus${i}`] = {
|
||||
type: "PropertyListItem",
|
||||
props: { label: "Status", value: p.active ? "Active" : "Archived" },
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Products = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
executeAction("fetchProducts", { limit: 10 }, handleSetData, {}),
|
||||
executeAction("fetchPrices", { limit: 50 }, handleSetData, {}),
|
||||
]);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createProductsSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createProductsSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView title="Products" brandColor="#635BFF" brandIcon={BrandIcon}>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading products...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Products & Prices"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "products" },
|
||||
handleSetData,
|
||||
data,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the products view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import {
|
||||
Box,
|
||||
ContextView,
|
||||
Button,
|
||||
TextField,
|
||||
Spinner,
|
||||
} from "@stripe/ui-extension-sdk/ui";
|
||||
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
|
||||
|
||||
import BrandIcon from "./brand_icon.svg";
|
||||
import { stripeCatalog, StripeRenderer } from "../lib/render";
|
||||
import { executeAction } from "../lib/render/catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Spec Creation
|
||||
// =============================================================================
|
||||
|
||||
function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
|
||||
const subscriptions = data.subscriptions as
|
||||
| {
|
||||
data?: Array<{
|
||||
id: string;
|
||||
planName: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
interval: string;
|
||||
currentPeriodEnd: string;
|
||||
customerId: string;
|
||||
}>;
|
||||
total?: number;
|
||||
active?: number;
|
||||
trialing?: number;
|
||||
pastDue?: number;
|
||||
canceled?: number;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const subscriptionsList = subscriptions?.data ?? [];
|
||||
|
||||
const elements: Spec["elements"] = {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "large" },
|
||||
children: [
|
||||
"heading",
|
||||
"metrics",
|
||||
"alert",
|
||||
"filters",
|
||||
"list",
|
||||
"pagination",
|
||||
],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Subscriptions", size: "xlarge" },
|
||||
children: [],
|
||||
},
|
||||
metrics: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "medium" },
|
||||
children: [
|
||||
"activeMetric",
|
||||
"trialingMetric",
|
||||
"pastDueMetric",
|
||||
"canceledMetric",
|
||||
],
|
||||
},
|
||||
activeMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Active",
|
||||
value: String(subscriptions?.active ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
trialingMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Trialing",
|
||||
value: String(subscriptions?.trialing ?? 0),
|
||||
changeType: "positive",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
pastDueMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Past Due",
|
||||
value: String(subscriptions?.pastDue ?? 0),
|
||||
changeType: subscriptions?.pastDue ? "negative" : "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
canceledMetric: {
|
||||
type: "Metric",
|
||||
props: {
|
||||
label: "Canceled",
|
||||
value: String(subscriptions?.canceled ?? 0),
|
||||
changeType: "neutral",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
alert: {
|
||||
type: "Banner",
|
||||
props: {
|
||||
title: subscriptions?.pastDue
|
||||
? `${subscriptions.pastDue} subscriptions need attention`
|
||||
: `${subscriptions?.trialing ?? 0} subscriptions trialing`,
|
||||
type: subscriptions?.pastDue ? "caution" : "default",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
filters: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: ["refreshBtn", "exportBtn"],
|
||||
},
|
||||
refreshBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Refresh",
|
||||
action: "refreshSubscriptions",
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
exportBtn: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Export CSV",
|
||||
action: "exportData",
|
||||
actionParams: { format: "csv", dataType: "subscriptions" },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: { direction: "vertical", gap: "small" },
|
||||
children: subscriptionsList
|
||||
.slice(0, 10)
|
||||
.map((_, i) => `subscription${i}`),
|
||||
},
|
||||
pagination: {
|
||||
type: "Stack",
|
||||
props: { direction: "horizontal", gap: "small" },
|
||||
children: subscriptions?.hasMore ? ["loadMore"] : [],
|
||||
},
|
||||
loadMore: {
|
||||
type: "Button",
|
||||
props: {
|
||||
label: "Load More",
|
||||
action: "fetchSubscriptions",
|
||||
actionParams: { limit: 10 },
|
||||
type: "secondary",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
};
|
||||
|
||||
subscriptionsList.slice(0, 10).forEach((s, i) => {
|
||||
elements[`subscription${i}`] = {
|
||||
type: "SubscriptionCard",
|
||||
props: {
|
||||
planName: s.planName,
|
||||
status: s.status as
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "past_due"
|
||||
| "canceled"
|
||||
| "unpaid",
|
||||
amount: s.amount,
|
||||
currency: "usd",
|
||||
interval: s.interval as "month" | "year",
|
||||
currentPeriodEnd: s.currentPeriodEnd,
|
||||
},
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return { root: "root", elements };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const Subscriptions = (_props: ExtensionContextValue) => {
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [spec, setSpec] = useState<Spec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleSetData = useCallback(
|
||||
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
|
||||
setData((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await executeAction(
|
||||
"fetchSubscriptions",
|
||||
{ limit: 10 },
|
||||
handleSetData,
|
||||
{},
|
||||
);
|
||||
setLoading(false);
|
||||
};
|
||||
loadData();
|
||||
}, [handleSetData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && Object.keys(data).length > 0) {
|
||||
setSpec(createSubscriptionsSpec(data));
|
||||
}
|
||||
}, [data, loading]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
setGenerating(true);
|
||||
|
||||
const systemPrompt = stripeCatalog.prompt();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, systemPrompt }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.spec) {
|
||||
setSpec(result.spec);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Generation error:", error);
|
||||
setSpec(createSubscriptionsSpec(data));
|
||||
}
|
||||
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ContextView
|
||||
title="Subscriptions"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
>
|
||||
<Box
|
||||
css={{
|
||||
stack: "y",
|
||||
gap: "medium",
|
||||
alignX: "center",
|
||||
paddingY: "xlarge",
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" />
|
||||
<Box css={{ color: "secondary" }}>Loading subscriptions...</Box>
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextView
|
||||
title="Subscriptions"
|
||||
brandColor="#635BFF"
|
||||
brandIcon={BrandIcon}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={() =>
|
||||
executeAction(
|
||||
"openDashboard",
|
||||
{ page: "subscriptions" },
|
||||
handleSetData,
|
||||
data,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open in Dashboard
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Box css={{ stack: "y", gap: "medium" }}>
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<Box css={{ width: "fill" }}>
|
||||
<TextField
|
||||
label=""
|
||||
placeholder="Describe the subscriptions view you want..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ alignSelfY: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onPress={handleGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
>
|
||||
{generating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{spec && (
|
||||
<StripeRenderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
setData={handleSetData}
|
||||
loading={generating}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ContextView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Subscriptions;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.218 9.10285C24.4008 8.99422 24.5577 8.84887 24.682 8.67797L17.5155 4.41809C16.5777 3.86064 15.4223 3.86064 14.4845 4.41809L7.31798 8.67797C7.44229 8.84887 7.59924 8.99422 7.78199 9.10285L15.2509 13.5425C15.7163 13.8192 16.2837 13.8192 16.7491 13.5425L24.218 9.10285Z" fill="#FFFFFF"/>
|
||||
<path d="M26.5 11.5603C26.5 11.4215 26.491 11.2841 26.4736 11.1486C26.2529 11.3479 26.0113 11.5268 25.7509 11.6817L18.282 16.1213C16.8718 16.9596 15.1282 16.9596 13.718 16.1213L6.2491 11.6817C5.98866 11.5268 5.7471 11.3479 5.52644 11.1486C5.50895 11.2841 5.5 11.4215 5.5 11.5603V20.4397C5.5 21.5546 6.07772 22.5848 7.01554 23.1423L14.4845 27.5819C15.4223 28.1394 16.5777 28.1394 17.5155 27.5819L24.9845 23.1423C25.9223 22.5848 26.5 21.5546 26.5 20.4397V11.5603Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 881 B |
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": "com.example.json-render-demo",
|
||||
"version": "0.0.1",
|
||||
"name": "json-render Demo",
|
||||
"icon": "",
|
||||
"permissions": [
|
||||
{
|
||||
"permission": "customer_read",
|
||||
"purpose": "Display customer information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "customer_write",
|
||||
"purpose": "Create and manage customers"
|
||||
},
|
||||
{
|
||||
"permission": "payment_intent_read",
|
||||
"purpose": "Display payment information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "charge_read",
|
||||
"purpose": "Display charge information and details"
|
||||
},
|
||||
{
|
||||
"permission": "subscription_read",
|
||||
"purpose": "Display subscription information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "subscription_write",
|
||||
"purpose": "Manage subscriptions"
|
||||
},
|
||||
{
|
||||
"permission": "invoice_read",
|
||||
"purpose": "Display invoice information in dashboards"
|
||||
},
|
||||
{
|
||||
"permission": "invoice_write",
|
||||
"purpose": "Send and manage invoices"
|
||||
},
|
||||
{
|
||||
"permission": "refund_write",
|
||||
"purpose": "Process refunds"
|
||||
},
|
||||
{
|
||||
"permission": "product_read",
|
||||
"purpose": "Display product catalog"
|
||||
},
|
||||
{
|
||||
"permission": "product_write",
|
||||
"purpose": "Create and manage products"
|
||||
},
|
||||
{
|
||||
"permission": "price_read",
|
||||
"purpose": "Display pricing information"
|
||||
},
|
||||
{
|
||||
"permission": "price_write",
|
||||
"purpose": "Create and manage prices"
|
||||
},
|
||||
{
|
||||
"permission": "balance_read",
|
||||
"purpose": "Display account balance"
|
||||
},
|
||||
{
|
||||
"permission": "payout_read",
|
||||
"purpose": "Display payout information"
|
||||
},
|
||||
{
|
||||
"permission": "event_read",
|
||||
"purpose": "Display webhook events"
|
||||
},
|
||||
{
|
||||
"permission": "dispute_read",
|
||||
"purpose": "Display dispute information"
|
||||
},
|
||||
{
|
||||
"permission": "coupon_read",
|
||||
"purpose": "Display coupon information"
|
||||
},
|
||||
{
|
||||
"permission": "coupon_write",
|
||||
"purpose": "Create and manage coupons"
|
||||
}
|
||||
],
|
||||
"ui_extension": {
|
||||
"views": [
|
||||
{
|
||||
"viewport": "stripe.dashboard.home.overview",
|
||||
"component": "Home"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.customer.list",
|
||||
"component": "Customers"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.customer.detail",
|
||||
"component": "CustomerDetails"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.payment.list",
|
||||
"component": "Payments"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.payment.detail",
|
||||
"component": "PaymentDetails"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.subscription.list",
|
||||
"component": "Subscriptions"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.invoice.list",
|
||||
"component": "Invoices"
|
||||
},
|
||||
{
|
||||
"viewport": "stripe.dashboard.product.list",
|
||||
"component": "Products"
|
||||
}
|
||||
],
|
||||
"content_security_policy": {
|
||||
"connect-src": null,
|
||||
"image-src": null,
|
||||
"purpose": "No external connections needed for core functionality"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@stripe/ui-extension-tools/tsconfig.ui-extension"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@stripe/ui-extension-tools" />
|
||||
+5
-1
@@ -20,9 +20,13 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"changeset": "changeset",
|
||||
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
|
||||
"ci:publish": "pnpm run build && changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "2.29.8",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"husky": "^9.1.7",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
|
||||
- Updated dependencies [dd17549]
|
||||
- @json-render/core@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 61ee8e5: include remove op in system prompt
|
||||
- Updated dependencies [61ee8e5]
|
||||
- @json-render/core@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54bce09: add defineRegistry function
|
||||
- Updated dependencies [54bce09]
|
||||
- @json-render/core@0.4.2
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -13,18 +13,15 @@ describe("traverseSpec", () => {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Card",
|
||||
props: {},
|
||||
children: ["child1", "child2"],
|
||||
},
|
||||
child1: {
|
||||
key: "child1",
|
||||
type: "Text",
|
||||
props: {},
|
||||
},
|
||||
child2: {
|
||||
key: "child2",
|
||||
type: "Button",
|
||||
props: {},
|
||||
},
|
||||
@@ -32,8 +29,8 @@ describe("traverseSpec", () => {
|
||||
};
|
||||
|
||||
const visited: string[] = [];
|
||||
traverseSpec(spec, (element) => {
|
||||
visited.push(element.key);
|
||||
traverseSpec(spec, (_element, key) => {
|
||||
visited.push(key);
|
||||
});
|
||||
|
||||
expect(visited).toEqual(["root", "child1", "child2"]);
|
||||
@@ -41,8 +38,8 @@ describe("traverseSpec", () => {
|
||||
|
||||
it("handles empty spec", () => {
|
||||
const visited: string[] = [];
|
||||
traverseSpec(null as unknown as Spec, (element) => {
|
||||
visited.push(element.key);
|
||||
traverseSpec(null as unknown as Spec, (_element, key) => {
|
||||
visited.push(key);
|
||||
});
|
||||
expect(visited).toEqual([]);
|
||||
});
|
||||
@@ -54,18 +51,15 @@ describe("collectUsedComponents", () => {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Card",
|
||||
props: {},
|
||||
children: ["child1", "child2"],
|
||||
},
|
||||
child1: {
|
||||
key: "child1",
|
||||
type: "Text",
|
||||
props: {},
|
||||
},
|
||||
child2: {
|
||||
key: "child2",
|
||||
type: "Text",
|
||||
props: {},
|
||||
},
|
||||
@@ -83,7 +77,6 @@ describe("collectDataPaths", () => {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Metric",
|
||||
props: { valuePath: "analytics/revenue" },
|
||||
},
|
||||
@@ -99,7 +92,6 @@ describe("collectDataPaths", () => {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Text",
|
||||
props: { content: { path: "user/name" } },
|
||||
},
|
||||
@@ -117,7 +109,6 @@ describe("collectActions", () => {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Button",
|
||||
props: { action: "submit_form" },
|
||||
},
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { Spec, UIElement } from "@json-render/core";
|
||||
* Visitor function for spec traversal
|
||||
*/
|
||||
export interface TreeVisitor {
|
||||
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||
(
|
||||
element: UIElement,
|
||||
key: string,
|
||||
depth: number,
|
||||
parent: UIElement | null,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +30,7 @@ export function traverseSpec(
|
||||
const element = spec.elements[key];
|
||||
if (!element) return;
|
||||
|
||||
visitor(element, depth, parent);
|
||||
visitor(element, key, depth, parent);
|
||||
|
||||
if (element.children) {
|
||||
for (const childKey of element.children) {
|
||||
@@ -43,7 +48,7 @@ export function traverseSpec(
|
||||
export function collectUsedComponents(spec: Spec): Set<string> {
|
||||
const components = new Set<string>();
|
||||
|
||||
traverseSpec(spec, (element) => {
|
||||
traverseSpec(spec, (element, _key) => {
|
||||
components.add(element.type);
|
||||
});
|
||||
|
||||
@@ -56,7 +61,7 @@ export function collectUsedComponents(spec: Spec): Set<string> {
|
||||
export function collectDataPaths(spec: Spec): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
|
||||
traverseSpec(spec, (element) => {
|
||||
traverseSpec(spec, (element, _key) => {
|
||||
// Check props for data paths
|
||||
for (const [propName, propValue] of Object.entries(element.props)) {
|
||||
// Check for path props (e.g., valuePath, dataPath, bindPath)
|
||||
@@ -141,7 +146,7 @@ function collectPathsFromCondition(
|
||||
export function collectActions(spec: Spec): Set<string> {
|
||||
const actions = new Set<string>();
|
||||
|
||||
traverseSpec(spec, (element) => {
|
||||
traverseSpec(spec, (element, _key) => {
|
||||
for (const propValue of Object.values(element.props)) {
|
||||
// Check for action prop (string action name)
|
||||
if (typeof propValue === "string" && propValue.startsWith("action:")) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# @json-render/core
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 61ee8e5: include remove op in system prompt
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54bce09: add defineRegistry function
|
||||
@@ -116,14 +116,16 @@ while (streaming) {
|
||||
const finalSpec = compiler.getResult();
|
||||
```
|
||||
|
||||
SpecStream format (each line is a JSON patch):
|
||||
SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch):
|
||||
|
||||
```jsonl
|
||||
{"op":"set","path":"/root/type","value":"Card"}
|
||||
{"op":"set","path":"/root/props","value":{"title":"Hello"}}
|
||||
{"op":"set","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
|
||||
{"op":"add","path":"/root/type","value":"Card"}
|
||||
{"op":"add","path":"/root/props","value":{"title":"Hello"}}
|
||||
{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
|
||||
```
|
||||
|
||||
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
|
||||
|
||||
### Low-Level Utilities
|
||||
|
||||
```typescript
|
||||
@@ -134,8 +136,8 @@ import {
|
||||
} from "@json-render/core";
|
||||
|
||||
// Parse a single line
|
||||
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
|
||||
// { op: "set", path: "/root", value: {} }
|
||||
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||
// { op: "add", path: "/root", value: {} }
|
||||
|
||||
// Apply a patch to an object
|
||||
const obj = {};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -72,12 +72,10 @@ describe("createCatalog", () => {
|
||||
});
|
||||
|
||||
const validElement = {
|
||||
key: "1",
|
||||
type: "text",
|
||||
props: { content: "Hello" },
|
||||
};
|
||||
const invalidElement = {
|
||||
key: "1",
|
||||
type: "text",
|
||||
props: { content: 123 },
|
||||
};
|
||||
@@ -96,7 +94,7 @@ describe("createCatalog", () => {
|
||||
const validSpec = {
|
||||
root: "1",
|
||||
elements: {
|
||||
"1": { key: "1", type: "text", props: { content: "Hello" } },
|
||||
"1": { type: "text", props: { content: "Hello" } },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,16 +118,13 @@ describe("createCatalog", () => {
|
||||
root: "card-1",
|
||||
elements: {
|
||||
"card-1": {
|
||||
key: "card-1",
|
||||
type: "card",
|
||||
props: { title: "Hello" },
|
||||
children: ["text-1"],
|
||||
},
|
||||
"text-1": {
|
||||
key: "text-1",
|
||||
type: "text",
|
||||
props: { content: "World" },
|
||||
parentKey: "card-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -147,7 +142,7 @@ describe("createCatalog", () => {
|
||||
const invalidSpec = {
|
||||
root: "1",
|
||||
elements: {
|
||||
"1": { key: "1", type: "nonexistent", props: { content: "Hello" } },
|
||||
"1": { type: "nonexistent", props: { content: "Hello" } },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -398,7 +393,6 @@ describe("defineCatalog (new schema API)", () => {
|
||||
root: s.string(),
|
||||
elements: s.record(
|
||||
s.object({
|
||||
key: s.string(),
|
||||
type: s.ref("catalog.components"),
|
||||
props: s.any(),
|
||||
children: s.array(s.string()),
|
||||
@@ -489,7 +483,6 @@ describe("defineCatalog (new schema API)", () => {
|
||||
root: "text-1",
|
||||
elements: {
|
||||
"text-1": {
|
||||
key: "text-1",
|
||||
type: "Text",
|
||||
props: { content: "Hello" },
|
||||
children: [],
|
||||
@@ -558,7 +551,6 @@ describe("defineCatalog (new schema API)", () => {
|
||||
root: "text-1",
|
||||
elements: {
|
||||
"text-1": {
|
||||
key: "text-1",
|
||||
type: "Text",
|
||||
props: { content: "Hello" },
|
||||
children: [],
|
||||
|
||||
@@ -143,11 +143,9 @@ export function createCatalog<
|
||||
const def = components[componentName]!;
|
||||
|
||||
return z.object({
|
||||
key: z.string(),
|
||||
type: z.literal(componentName as string),
|
||||
props: def.props,
|
||||
children: z.array(z.string()).optional(),
|
||||
parentKey: z.string().nullable().optional(),
|
||||
visible: VisibilityConditionSchema.optional(),
|
||||
});
|
||||
});
|
||||
@@ -157,11 +155,9 @@ export function createCatalog<
|
||||
|
||||
if (componentSchemas.length === 0) {
|
||||
elementSchema = z.object({
|
||||
key: z.string(),
|
||||
type: z.string(),
|
||||
props: z.record(z.string(), z.unknown()),
|
||||
children: z.array(z.string()).optional(),
|
||||
parentKey: z.string().nullable().optional(),
|
||||
visible: VisibilityConditionSchema.optional(),
|
||||
}) as unknown as z.ZodType<UIElement>;
|
||||
} else if (componentSchemas.length === 1) {
|
||||
@@ -497,21 +493,23 @@ export function generateSystemPrompt<
|
||||
}
|
||||
|
||||
// Output format
|
||||
lines.push("OUTPUT FORMAT (JSONL):");
|
||||
lines.push('{"op":"set","path":"/root","value":"element-key"}');
|
||||
lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
|
||||
lines.push('{"op":"add","path":"/root","value":"element-key"}');
|
||||
lines.push(
|
||||
'{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
|
||||
'{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
|
||||
);
|
||||
lines.push('{"op":"remove","path":"/elements/key"}');
|
||||
lines.push("");
|
||||
|
||||
// Rules
|
||||
lines.push("RULES:");
|
||||
const baseRules = [
|
||||
"First line sets /root to root element key",
|
||||
"Add elements with /elements/{key}",
|
||||
'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
|
||||
'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
|
||||
"Remove elements with op:remove - also update the parent's children array to exclude the removed key",
|
||||
"Children array contains string keys, not objects",
|
||||
"Parent first, then children",
|
||||
"Each element needs: key, type, props",
|
||||
"Each element needs: type, props",
|
||||
"ONLY use props listed above - never invent new props",
|
||||
];
|
||||
const allRules = [...baseRules, ...customRules];
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
DynamicNumber,
|
||||
DynamicBoolean,
|
||||
UIElement,
|
||||
FlatElement,
|
||||
Spec,
|
||||
VisibilityCondition,
|
||||
LogicExpression,
|
||||
@@ -27,8 +28,10 @@ export {
|
||||
resolveDynamicValue,
|
||||
getByPath,
|
||||
setByPath,
|
||||
addByPath,
|
||||
removeByPath,
|
||||
findFormValue,
|
||||
// SpecStream - streaming format for building specs
|
||||
// SpecStream - streaming format for building specs (RFC 6902)
|
||||
parseSpecStreamLine,
|
||||
applySpecStreamPatch,
|
||||
compileSpecStream,
|
||||
|
||||
@@ -545,10 +545,10 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
|
||||
lines.push("");
|
||||
lines.push("Example output (each line is a separate JSON object):");
|
||||
lines.push("");
|
||||
lines.push(`{"op":"set","path":"/root","value":"card-1"}
|
||||
{"op":"set","path":"/elements/card-1","value":{"key":"card-1","type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"],"parentKey":""}}
|
||||
{"op":"set","path":"/elements/metric-1","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[],"parentKey":"card-1"}}
|
||||
{"op":"set","path":"/elements/chart-1","value":{"key":"chart-1","type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[],"parentKey":"card-1"}}`);
|
||||
lines.push(`{"op":"add","path":"/root","value":"card-1"}
|
||||
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
|
||||
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
|
||||
{"op":"add","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
|
||||
lines.push("");
|
||||
|
||||
// Components section
|
||||
@@ -591,12 +591,11 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
|
||||
lines.push("RULES:");
|
||||
const baseRules = [
|
||||
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
||||
'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
|
||||
'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
|
||||
'First line sets root: {"op":"add","path":"/root","value":"<root-key>"}',
|
||||
'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
|
||||
"ONLY use components listed above",
|
||||
"Each element value needs: key, type, props, children (array of child keys), parentKey",
|
||||
"Use unique keys (e.g., 'header', 'metric-1', 'chart-revenue')",
|
||||
"Root element's parentKey is empty string, children reference their parent's key",
|
||||
"Each element value needs: type, props, children (array of child keys)",
|
||||
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
|
||||
];
|
||||
const allRules = [...baseRules, ...customRules];
|
||||
allRules.forEach((rule, i) => {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveDynamicValue, getByPath, setByPath } from "./types";
|
||||
import {
|
||||
resolveDynamicValue,
|
||||
getByPath,
|
||||
setByPath,
|
||||
addByPath,
|
||||
removeByPath,
|
||||
applySpecStreamPatch,
|
||||
compileSpecStream,
|
||||
createSpecStreamCompiler,
|
||||
} from "./types";
|
||||
|
||||
describe("getByPath", () => {
|
||||
it("gets nested values with JSON pointer paths", () => {
|
||||
@@ -123,3 +132,428 @@ describe("resolveDynamicValue", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// JSON Pointer (RFC 6901) escaping
|
||||
// =============================================================================
|
||||
|
||||
describe("JSON Pointer escaping (RFC 6901)", () => {
|
||||
it("getByPath unescapes ~1 to /", () => {
|
||||
const data = { "a/b": { c: 42 } };
|
||||
expect(getByPath(data, "/a~1b/c")).toBe(42);
|
||||
});
|
||||
|
||||
it("getByPath unescapes ~0 to ~", () => {
|
||||
const data = { "a~b": 99 };
|
||||
expect(getByPath(data, "/a~0b")).toBe(99);
|
||||
});
|
||||
|
||||
it("getByPath handles combined ~0 and ~1 escaping", () => {
|
||||
const data = { "a~/b": "found" };
|
||||
expect(getByPath(data, "/a~0~1b")).toBe("found");
|
||||
});
|
||||
|
||||
it("setByPath unescapes ~1 to / in keys", () => {
|
||||
const data: Record<string, unknown> = {};
|
||||
setByPath(data, "/a~1b", "value");
|
||||
expect(data["a/b"]).toBe("value");
|
||||
});
|
||||
|
||||
it("setByPath unescapes ~0 to ~ in keys", () => {
|
||||
const data: Record<string, unknown> = {};
|
||||
setByPath(data, "/a~0b", "value");
|
||||
expect(data["a~b"]).toBe("value");
|
||||
});
|
||||
|
||||
it("getByPath reads array elements properly", () => {
|
||||
const data = { items: [10, 20, 30] };
|
||||
expect(getByPath(data, "/items/0")).toBe(10);
|
||||
expect(getByPath(data, "/items/2")).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// addByPath (RFC 6902 "add" semantics)
|
||||
// =============================================================================
|
||||
|
||||
describe("addByPath", () => {
|
||||
it("adds a property to an object", () => {
|
||||
const data: Record<string, unknown> = { a: 1 };
|
||||
addByPath(data, "/b", 2);
|
||||
expect(data.b).toBe(2);
|
||||
});
|
||||
|
||||
it("replaces an existing object property (add semantics)", () => {
|
||||
const data: Record<string, unknown> = { a: 1 };
|
||||
addByPath(data, "/a", 99);
|
||||
expect(data.a).toBe(99);
|
||||
});
|
||||
|
||||
it("inserts into an array at a specific index", () => {
|
||||
const data: Record<string, unknown> = { arr: [1, 2, 3] };
|
||||
addByPath(data, "/arr/1", 99);
|
||||
expect(data.arr).toEqual([1, 99, 2, 3]);
|
||||
});
|
||||
|
||||
it("appends to an array with - (end-of-array)", () => {
|
||||
const data: Record<string, unknown> = { arr: [1, 2] };
|
||||
addByPath(data, "/arr/-", 3);
|
||||
expect(data.arr).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("inserts at index 0 of an array", () => {
|
||||
const data: Record<string, unknown> = { arr: ["b", "c"] };
|
||||
addByPath(data, "/arr/0", "a");
|
||||
expect(data.arr).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("creates intermediate objects", () => {
|
||||
const data: Record<string, unknown> = {};
|
||||
addByPath(data, "/x/y/z", "deep");
|
||||
expect(
|
||||
((data.x as Record<string, unknown>).y as Record<string, unknown>).z,
|
||||
).toBe("deep");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// removeByPath (RFC 6902 "remove" semantics)
|
||||
// =============================================================================
|
||||
|
||||
describe("removeByPath", () => {
|
||||
it("deletes an object property", () => {
|
||||
const data: Record<string, unknown> = { a: 1, b: 2 };
|
||||
removeByPath(data, "/a");
|
||||
expect(data).toEqual({ b: 2 });
|
||||
expect("a" in data).toBe(false);
|
||||
});
|
||||
|
||||
it("removes an element from an array by index", () => {
|
||||
const data: Record<string, unknown> = { arr: [1, 2, 3] };
|
||||
removeByPath(data, "/arr/1");
|
||||
expect(data.arr).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("removes nested properties", () => {
|
||||
const data: Record<string, unknown> = { user: { name: "John", age: 30 } };
|
||||
removeByPath(data, "/user/age");
|
||||
expect(data.user).toEqual({ name: "John" });
|
||||
});
|
||||
|
||||
it("is a no-op for non-existent paths", () => {
|
||||
const data: Record<string, unknown> = { a: 1 };
|
||||
removeByPath(data, "/b/c/d");
|
||||
expect(data).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// applySpecStreamPatch - RFC 6902 operations
|
||||
// =============================================================================
|
||||
|
||||
describe("applySpecStreamPatch", () => {
|
||||
describe("add operation", () => {
|
||||
it("adds a new object property", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
applySpecStreamPatch(obj, { op: "add", path: "/name", value: "Alice" });
|
||||
expect(obj.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("adds nested properties creating intermediates", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "add",
|
||||
path: "/user/name",
|
||||
value: "Bob",
|
||||
});
|
||||
expect((obj.user as Record<string, unknown>).name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("inserts into array at index", () => {
|
||||
const obj: Record<string, unknown> = { items: [1, 3] };
|
||||
applySpecStreamPatch(obj, { op: "add", path: "/items/1", value: 2 });
|
||||
expect(obj.items).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("appends to array with -", () => {
|
||||
const obj: Record<string, unknown> = { items: [1, 2] };
|
||||
applySpecStreamPatch(obj, { op: "add", path: "/items/-", value: 3 });
|
||||
expect(obj.items).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove operation", () => {
|
||||
it("removes an object property", () => {
|
||||
const obj: Record<string, unknown> = { name: "Alice", age: 30 };
|
||||
applySpecStreamPatch(obj, { op: "remove", path: "/age" });
|
||||
expect(obj).toEqual({ name: "Alice" });
|
||||
expect("age" in obj).toBe(false);
|
||||
});
|
||||
|
||||
it("removes from array by index", () => {
|
||||
const obj: Record<string, unknown> = { items: ["a", "b", "c"] };
|
||||
applySpecStreamPatch(obj, { op: "remove", path: "/items/1" });
|
||||
expect(obj.items).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace operation", () => {
|
||||
it("replaces an existing value", () => {
|
||||
const obj: Record<string, unknown> = { name: "Alice" };
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "replace",
|
||||
path: "/name",
|
||||
value: "Bob",
|
||||
});
|
||||
expect(obj.name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("replaces nested values", () => {
|
||||
const obj: Record<string, unknown> = { user: { name: "Alice" } };
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "replace",
|
||||
path: "/user/name",
|
||||
value: "Bob",
|
||||
});
|
||||
expect((obj.user as Record<string, unknown>).name).toBe("Bob");
|
||||
});
|
||||
});
|
||||
|
||||
describe("move operation", () => {
|
||||
it("moves a value from one path to another", () => {
|
||||
const obj: Record<string, unknown> = { a: 1, b: 2 };
|
||||
applySpecStreamPatch(obj, { op: "move", path: "/c", from: "/a" });
|
||||
expect(obj).toEqual({ b: 2, c: 1 });
|
||||
expect("a" in obj).toBe(false);
|
||||
});
|
||||
|
||||
it("moves nested values", () => {
|
||||
const obj: Record<string, unknown> = {
|
||||
source: { val: 42 },
|
||||
target: {},
|
||||
};
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "move",
|
||||
path: "/target/val",
|
||||
from: "/source/val",
|
||||
});
|
||||
expect((obj.target as Record<string, unknown>).val).toBe(42);
|
||||
expect("val" in (obj.source as Record<string, unknown>)).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op if from is missing", () => {
|
||||
const obj: Record<string, unknown> = { a: 1 };
|
||||
applySpecStreamPatch(obj, { op: "move", path: "/b" });
|
||||
expect(obj).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy operation", () => {
|
||||
it("copies a value from one path to another", () => {
|
||||
const obj: Record<string, unknown> = { a: 1 };
|
||||
applySpecStreamPatch(obj, { op: "copy", path: "/b", from: "/a" });
|
||||
expect(obj).toEqual({ a: 1, b: 1 });
|
||||
});
|
||||
|
||||
it("copies complex values", () => {
|
||||
const obj: Record<string, unknown> = {
|
||||
source: { nested: { value: 42 } },
|
||||
};
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "copy",
|
||||
path: "/dest",
|
||||
from: "/source/nested",
|
||||
});
|
||||
expect(obj.dest).toEqual({ value: 42 });
|
||||
// Source should still exist
|
||||
expect((obj.source as Record<string, unknown>).nested).toEqual({
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op if from is missing", () => {
|
||||
const obj: Record<string, unknown> = { a: 1 };
|
||||
applySpecStreamPatch(obj, { op: "copy", path: "/b" });
|
||||
expect(obj).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("test operation", () => {
|
||||
it("succeeds when values match", () => {
|
||||
const obj: Record<string, unknown> = { name: "Alice" };
|
||||
expect(() =>
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "test",
|
||||
path: "/name",
|
||||
value: "Alice",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("succeeds for matching objects", () => {
|
||||
const obj: Record<string, unknown> = { user: { name: "Alice", age: 30 } };
|
||||
expect(() =>
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "test",
|
||||
path: "/user",
|
||||
value: { name: "Alice", age: 30 },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("succeeds for matching arrays", () => {
|
||||
const obj: Record<string, unknown> = { items: [1, 2, 3] };
|
||||
expect(() =>
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "test",
|
||||
path: "/items",
|
||||
value: [1, 2, 3],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when values do not match", () => {
|
||||
const obj: Record<string, unknown> = { name: "Alice" };
|
||||
expect(() =>
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "test",
|
||||
path: "/name",
|
||||
value: "Bob",
|
||||
}),
|
||||
).toThrow('Test operation failed: value at "/name" does not match');
|
||||
});
|
||||
|
||||
it("throws when path does not exist", () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
expect(() =>
|
||||
applySpecStreamPatch(obj, {
|
||||
op: "test",
|
||||
path: "/missing",
|
||||
value: "anything",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// compileSpecStream
|
||||
// =============================================================================
|
||||
|
||||
describe("compileSpecStream", () => {
|
||||
it("compiles a series of add patches", () => {
|
||||
const stream = `{"op":"add","path":"/name","value":"Alice"}
|
||||
{"op":"add","path":"/age","value":30}`;
|
||||
const result = compileSpecStream(stream);
|
||||
expect(result).toEqual({ name: "Alice", age: 30 });
|
||||
});
|
||||
|
||||
it("handles remove operations", () => {
|
||||
const stream = `{"op":"add","path":"/a","value":1}
|
||||
{"op":"add","path":"/b","value":2}
|
||||
{"op":"remove","path":"/a"}`;
|
||||
const result = compileSpecStream(stream);
|
||||
expect(result).toEqual({ b: 2 });
|
||||
expect("a" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("handles replace operations", () => {
|
||||
const stream = `{"op":"add","path":"/name","value":"Alice"}
|
||||
{"op":"replace","path":"/name","value":"Bob"}`;
|
||||
const result = compileSpecStream(stream);
|
||||
expect(result).toEqual({ name: "Bob" });
|
||||
});
|
||||
|
||||
it("handles move operations", () => {
|
||||
const stream = `{"op":"add","path":"/old","value":"data"}
|
||||
{"op":"move","path":"/new","from":"/old"}`;
|
||||
const result = compileSpecStream(stream);
|
||||
expect(result).toEqual({ new: "data" });
|
||||
});
|
||||
|
||||
it("handles copy operations", () => {
|
||||
const stream = `{"op":"add","path":"/original","value":"data"}
|
||||
{"op":"copy","path":"/duplicate","from":"/original"}`;
|
||||
const result = compileSpecStream(stream);
|
||||
expect(result).toEqual({ original: "data", duplicate: "data" });
|
||||
});
|
||||
|
||||
it("skips empty lines", () => {
|
||||
const stream = `{"op":"add","path":"/a","value":1}
|
||||
|
||||
{"op":"add","path":"/b","value":2}`;
|
||||
const result = compileSpecStream(stream);
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it("uses initial value", () => {
|
||||
const stream = `{"op":"add","path":"/b","value":2}`;
|
||||
const result = compileSpecStream(stream, { a: 1 });
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createSpecStreamCompiler
|
||||
// =============================================================================
|
||||
|
||||
describe("createSpecStreamCompiler", () => {
|
||||
it("processes chunks incrementally", () => {
|
||||
const compiler = createSpecStreamCompiler();
|
||||
const { result, newPatches } = compiler.push(
|
||||
'{"op":"add","path":"/name","value":"Alice"}\n',
|
||||
);
|
||||
expect(newPatches).toHaveLength(1);
|
||||
expect(result).toEqual({ name: "Alice" });
|
||||
});
|
||||
|
||||
it("handles partial lines across chunks", () => {
|
||||
const compiler = createSpecStreamCompiler();
|
||||
|
||||
// First chunk is incomplete
|
||||
const r1 = compiler.push('{"op":"add","path":"/na');
|
||||
expect(r1.newPatches).toHaveLength(0);
|
||||
|
||||
// Complete the line
|
||||
const r2 = compiler.push('me","value":"Alice"}\n');
|
||||
expect(r2.newPatches).toHaveLength(1);
|
||||
expect(r2.result).toEqual({ name: "Alice" });
|
||||
});
|
||||
|
||||
it("processes remaining buffer on getResult", () => {
|
||||
const compiler = createSpecStreamCompiler();
|
||||
compiler.push('{"op":"add","path":"/name","value":"Alice"}');
|
||||
// No newline, so not processed yet
|
||||
const result = compiler.getResult();
|
||||
expect(result).toEqual({ name: "Alice" });
|
||||
});
|
||||
|
||||
it("resets to clean state", () => {
|
||||
const compiler = createSpecStreamCompiler();
|
||||
compiler.push('{"op":"add","path":"/name","value":"Alice"}\n');
|
||||
compiler.reset();
|
||||
const result = compiler.getResult();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("tracks all applied patches", () => {
|
||||
const compiler = createSpecStreamCompiler();
|
||||
compiler.push('{"op":"add","path":"/a","value":1}\n');
|
||||
compiler.push('{"op":"add","path":"/b","value":2}\n');
|
||||
const patches = compiler.getPatches();
|
||||
expect(patches).toHaveLength(2);
|
||||
expect(patches[0]!.op).toBe("add");
|
||||
expect(patches[1]!.op).toBe("add");
|
||||
});
|
||||
|
||||
it("handles all RFC 6902 operations", () => {
|
||||
const compiler = createSpecStreamCompiler();
|
||||
compiler.push('{"op":"add","path":"/x","value":1}\n');
|
||||
compiler.push('{"op":"add","path":"/y","value":2}\n');
|
||||
compiler.push('{"op":"move","path":"/z","from":"/x"}\n');
|
||||
compiler.push('{"op":"copy","path":"/w","from":"/y"}\n');
|
||||
compiler.push('{"op":"remove","path":"/y"}\n');
|
||||
const result = compiler.getResult();
|
||||
expect(result).toEqual({ z: 1, w: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
+207
-26
@@ -53,20 +53,31 @@ export interface UIElement<
|
||||
T extends string = string,
|
||||
P = Record<string, unknown>,
|
||||
> {
|
||||
/** Unique key for reconciliation */
|
||||
key: string;
|
||||
/** Component type from the catalog */
|
||||
type: T;
|
||||
/** Component props */
|
||||
props: P;
|
||||
/** Child element keys (flat structure) */
|
||||
children?: string[];
|
||||
/** Parent element key (null for root) */
|
||||
parentKey?: string | null;
|
||||
/** Visibility condition */
|
||||
visible?: VisibilityCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element with key and parentKey for use with flatToTree.
|
||||
* When elements are in an array (not a keyed map), key and parentKey
|
||||
* are needed to establish identity and parent-child relationships.
|
||||
*/
|
||||
export interface FlatElement<
|
||||
T extends string = string,
|
||||
P = Record<string, unknown>,
|
||||
> extends UIElement<T, P> {
|
||||
/** Unique key identifying this element */
|
||||
key: string;
|
||||
/** Parent element key (null for root) */
|
||||
parentKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility condition types
|
||||
*/
|
||||
@@ -125,17 +136,20 @@ export type ComponentSchema = z.ZodType<Record<string, unknown>>;
|
||||
export type ValidationMode = "strict" | "warn" | "ignore";
|
||||
|
||||
/**
|
||||
* JSON patch operation types
|
||||
* JSON patch operation types (RFC 6902)
|
||||
*/
|
||||
export type PatchOp = "add" | "remove" | "replace" | "set";
|
||||
export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test";
|
||||
|
||||
/**
|
||||
* JSON patch operation
|
||||
* JSON patch operation (RFC 6902)
|
||||
*/
|
||||
export interface JsonPatch {
|
||||
op: PatchOp;
|
||||
path: string;
|
||||
/** Required for add, replace, test */
|
||||
value?: unknown;
|
||||
/** Required for move, copy (source location) */
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,16 +171,30 @@ export function resolveDynamicValue<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from an object by JSON Pointer path
|
||||
* Unescape a JSON Pointer token per RFC 6901 Section 4.
|
||||
* ~1 is decoded to / and ~0 is decoded to ~ (order matters).
|
||||
*/
|
||||
function unescapeJsonPointer(token: string): string {
|
||||
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON Pointer path into unescaped segments.
|
||||
*/
|
||||
function parseJsonPointer(path: string): string[] {
|
||||
const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||
return raw.map(unescapeJsonPointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from an object by JSON Pointer path (RFC 6901)
|
||||
*/
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
if (!path || path === "/") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const segments = path.startsWith("/")
|
||||
? path.slice(1).split("/")
|
||||
: path.split("/");
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
let current: unknown = obj;
|
||||
|
||||
@@ -175,7 +203,10 @@ export function getByPath(obj: unknown, path: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof current === "object") {
|
||||
if (Array.isArray(current)) {
|
||||
const index = parseInt(segment, 10);
|
||||
current = current[index];
|
||||
} else if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
@@ -193,7 +224,7 @@ function isNumericIndex(str: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in an object by JSON Pointer path.
|
||||
* Set a value in an object by JSON Pointer path (RFC 6901).
|
||||
* Automatically creates arrays when the path segment is a numeric index.
|
||||
*/
|
||||
export function setByPath(
|
||||
@@ -201,9 +232,7 @@ export function setByPath(
|
||||
path: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const segments = path.startsWith("/")
|
||||
? path.slice(1).split("/")
|
||||
: path.split("/");
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
|
||||
@@ -213,7 +242,8 @@ export function setByPath(
|
||||
const segment = segments[i]!;
|
||||
const nextSegment = segments[i + 1];
|
||||
const nextIsNumeric =
|
||||
nextSegment !== undefined && isNumericIndex(nextSegment);
|
||||
nextSegment !== undefined &&
|
||||
(isNumericIndex(nextSegment) || nextSegment === "-");
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const index = parseInt(segment, 10);
|
||||
@@ -231,13 +261,131 @@ export function setByPath(
|
||||
|
||||
const lastSegment = segments[segments.length - 1]!;
|
||||
if (Array.isArray(current)) {
|
||||
const index = parseInt(lastSegment, 10);
|
||||
current[index] = value;
|
||||
if (lastSegment === "-") {
|
||||
current.push(value);
|
||||
} else {
|
||||
const index = parseInt(lastSegment, 10);
|
||||
current[index] = value;
|
||||
}
|
||||
} else {
|
||||
current[lastSegment] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a value per RFC 6902 "add" semantics.
|
||||
* For objects: create-or-replace the member.
|
||||
* For arrays: insert before the given index, or append if "-".
|
||||
*/
|
||||
export function addByPath(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i]!;
|
||||
const nextSegment = segments[i + 1];
|
||||
const nextIsNumeric =
|
||||
nextSegment !== undefined &&
|
||||
(isNumericIndex(nextSegment) || nextSegment === "-");
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const index = parseInt(segment, 10);
|
||||
if (current[index] === undefined || typeof current[index] !== "object") {
|
||||
current[index] = nextIsNumeric ? [] : {};
|
||||
}
|
||||
current = current[index] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (!(segment in current) || typeof current[segment] !== "object") {
|
||||
current[segment] = nextIsNumeric ? [] : {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1]!;
|
||||
if (Array.isArray(current)) {
|
||||
if (lastSegment === "-") {
|
||||
current.push(value);
|
||||
} else {
|
||||
const index = parseInt(lastSegment, 10);
|
||||
current.splice(index, 0, value);
|
||||
}
|
||||
} else {
|
||||
current[lastSegment] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value per RFC 6902 "remove" semantics.
|
||||
* For objects: delete the property.
|
||||
* For arrays: splice out the element at the given index.
|
||||
*/
|
||||
export function removeByPath(obj: Record<string, unknown>, path: string): void {
|
||||
const segments = parseJsonPointer(path);
|
||||
|
||||
if (segments.length === 0) return;
|
||||
|
||||
let current: Record<string, unknown> | unknown[] = obj;
|
||||
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i]!;
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const index = parseInt(segment, 10);
|
||||
if (current[index] === undefined || typeof current[index] !== "object") {
|
||||
return; // path does not exist
|
||||
}
|
||||
current = current[index] as Record<string, unknown> | unknown[];
|
||||
} else {
|
||||
if (!(segment in current) || typeof current[segment] !== "object") {
|
||||
return; // path does not exist
|
||||
}
|
||||
current = current[segment] as Record<string, unknown> | unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1]!;
|
||||
if (Array.isArray(current)) {
|
||||
const index = parseInt(lastSegment, 10);
|
||||
if (index >= 0 && index < current.length) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
delete current[lastSegment];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality check for RFC 6902 "test" operation.
|
||||
*/
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a !== "object") return false;
|
||||
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b)) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item, i) => deepEqual(item, b[i]));
|
||||
}
|
||||
|
||||
const aObj = a as Record<string, unknown>;
|
||||
const bObj = b as Record<string, unknown>;
|
||||
const aKeys = Object.keys(aObj);
|
||||
const bKeys = Object.keys(bObj);
|
||||
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a form value from params and/or data.
|
||||
* Useful in action handlers to locate form input values regardless of path format.
|
||||
@@ -334,17 +482,50 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single SpecStream patch to an object.
|
||||
* Apply a single RFC 6902 JSON Patch operation to an object.
|
||||
* Mutates the object in place.
|
||||
*
|
||||
* Supports all six RFC 6902 operations: add, remove, replace, move, copy, test.
|
||||
*
|
||||
* @throws {Error} If a "test" operation fails (value mismatch).
|
||||
*/
|
||||
export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
patch: SpecStreamLine,
|
||||
): T {
|
||||
if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
|
||||
setByPath(obj, patch.path, patch.value);
|
||||
} else if (patch.op === "remove") {
|
||||
setByPath(obj, patch.path, undefined);
|
||||
switch (patch.op) {
|
||||
case "add":
|
||||
addByPath(obj, patch.path, patch.value);
|
||||
break;
|
||||
case "replace":
|
||||
// RFC 6902: target must exist. For streaming tolerance we set regardless.
|
||||
setByPath(obj, patch.path, patch.value);
|
||||
break;
|
||||
case "remove":
|
||||
removeByPath(obj, patch.path);
|
||||
break;
|
||||
case "move": {
|
||||
if (!patch.from) break;
|
||||
const moveValue = getByPath(obj, patch.from);
|
||||
removeByPath(obj, patch.from);
|
||||
addByPath(obj, patch.path, moveValue);
|
||||
break;
|
||||
}
|
||||
case "copy": {
|
||||
if (!patch.from) break;
|
||||
const copyValue = getByPath(obj, patch.from);
|
||||
addByPath(obj, patch.path, copyValue);
|
||||
break;
|
||||
}
|
||||
case "test": {
|
||||
const actual = getByPath(obj, patch.path);
|
||||
if (!deepEqual(actual, patch.value)) {
|
||||
throw new Error(
|
||||
`Test operation failed: value at "${patch.path}" does not match`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -354,8 +535,8 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
||||
* Each line should be a patch operation.
|
||||
*
|
||||
* @example
|
||||
* const stream = `{"op":"set","path":"/name","value":"Alice"}
|
||||
* {"op":"set","path":"/age","value":30}`;
|
||||
* const stream = `{"op":"add","path":"/name","value":"Alice"}
|
||||
* {"op":"add","path":"/age","value":30}`;
|
||||
* const result = compileSpecStream(stream);
|
||||
* // { name: "Alice", age: 30 }
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# @json-render/react
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
|
||||
- Updated dependencies [dd17549]
|
||||
- @json-render/core@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 61ee8e5: include remove op in system prompt
|
||||
- Updated dependencies [61ee8e5]
|
||||
- @json-render/core@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54bce09: add defineRegistry function
|
||||
- Updated dependencies [54bce09]
|
||||
- @json-render/core@0.4.2
|
||||
+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} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
|
||||
+105
-47
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { Spec, UIElement, JsonPatch } from "@json-render/core";
|
||||
import { setByPath } from "@json-render/core";
|
||||
import type {
|
||||
Spec,
|
||||
UIElement,
|
||||
FlatElement,
|
||||
JsonPatch,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
setByPath,
|
||||
getByPath,
|
||||
addByPath,
|
||||
removeByPath,
|
||||
} from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Parse a single JSON patch line
|
||||
@@ -20,56 +30,104 @@ function parsePatchLine(line: string): JsonPatch | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a JSON patch to the current spec
|
||||
* Set a value at a spec path (for add/replace operations).
|
||||
*/
|
||||
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
|
||||
if (path === "/root") {
|
||||
newSpec.root = value as string;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith("/elements/")) {
|
||||
const pathParts = path.slice("/elements/".length).split("/");
|
||||
const elementKey = pathParts[0];
|
||||
if (!elementKey) return;
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
newSpec.elements[elementKey] = value as UIElement;
|
||||
} else {
|
||||
const element = newSpec.elements[elementKey];
|
||||
if (element) {
|
||||
const propPath = "/" + pathParts.slice(1).join("/");
|
||||
const newElement = { ...element };
|
||||
setByPath(
|
||||
newElement as unknown as Record<string, unknown>,
|
||||
propPath,
|
||||
value,
|
||||
);
|
||||
newSpec.elements[elementKey] = newElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value at a spec path.
|
||||
*/
|
||||
function removeSpecValue(newSpec: Spec, path: string): void {
|
||||
if (path.startsWith("/elements/")) {
|
||||
const pathParts = path.slice("/elements/".length).split("/");
|
||||
const elementKey = pathParts[0];
|
||||
if (!elementKey) return;
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
const { [elementKey]: _, ...rest } = newSpec.elements;
|
||||
newSpec.elements = rest;
|
||||
} else {
|
||||
const element = newSpec.elements[elementKey];
|
||||
if (element) {
|
||||
const propPath = "/" + pathParts.slice(1).join("/");
|
||||
const newElement = { ...element };
|
||||
removeByPath(
|
||||
newElement as unknown as Record<string, unknown>,
|
||||
propPath,
|
||||
);
|
||||
newSpec.elements[elementKey] = newElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value at a spec path.
|
||||
*/
|
||||
function getSpecValue(spec: Spec, path: string): unknown {
|
||||
if (path === "/root") return spec.root;
|
||||
return getByPath(spec as unknown as Record<string, unknown>, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an RFC 6902 JSON patch to the current spec.
|
||||
* Supports add, remove, replace, move, copy, and test operations.
|
||||
*/
|
||||
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||
const newSpec = { ...spec, elements: { ...spec.elements } };
|
||||
|
||||
switch (patch.op) {
|
||||
case "set":
|
||||
case "add":
|
||||
case "replace": {
|
||||
// Handle root path
|
||||
if (patch.path === "/root") {
|
||||
newSpec.root = patch.value as string;
|
||||
return newSpec;
|
||||
}
|
||||
|
||||
// Handle elements paths
|
||||
if (patch.path.startsWith("/elements/")) {
|
||||
const pathParts = patch.path.slice("/elements/".length).split("/");
|
||||
const elementKey = pathParts[0];
|
||||
|
||||
if (!elementKey) return newSpec;
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
// Setting entire element
|
||||
newSpec.elements[elementKey] = patch.value as UIElement;
|
||||
} else {
|
||||
// Setting property of element
|
||||
const element = newSpec.elements[elementKey];
|
||||
if (element) {
|
||||
const propPath = "/" + pathParts.slice(1).join("/");
|
||||
const newElement = { ...element };
|
||||
setByPath(
|
||||
newElement as unknown as Record<string, unknown>,
|
||||
propPath,
|
||||
patch.value,
|
||||
);
|
||||
newSpec.elements[elementKey] = newElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
setSpecValue(newSpec, patch.path, patch.value);
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
if (patch.path.startsWith("/elements/")) {
|
||||
const elementKey = patch.path.slice("/elements/".length).split("/")[0];
|
||||
if (elementKey) {
|
||||
const { [elementKey]: _, ...rest } = newSpec.elements;
|
||||
newSpec.elements = rest;
|
||||
}
|
||||
}
|
||||
removeSpecValue(newSpec, patch.path);
|
||||
break;
|
||||
}
|
||||
case "move": {
|
||||
if (!patch.from) break;
|
||||
const moveValue = getSpecValue(newSpec, patch.from);
|
||||
removeSpecValue(newSpec, patch.from);
|
||||
setSpecValue(newSpec, patch.path, moveValue);
|
||||
break;
|
||||
}
|
||||
case "copy": {
|
||||
if (!patch.from) break;
|
||||
const copyValue = getSpecValue(newSpec, patch.from);
|
||||
setSpecValue(newSpec, patch.path, copyValue);
|
||||
break;
|
||||
}
|
||||
case "test": {
|
||||
// test is a no-op for rendering purposes (validation only)
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -236,18 +294,18 @@ export function useUIStream({
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat element list to a Spec
|
||||
* Convert a flat element list to a Spec.
|
||||
* Input elements use key/parentKey to establish identity and relationships.
|
||||
* Output spec uses the map-based format where key is the map entry key
|
||||
* and parent-child relationships are expressed through children arrays.
|
||||
*/
|
||||
export function flatToTree(
|
||||
elements: Array<UIElement & { parentKey?: string | null }>,
|
||||
): Spec {
|
||||
export function flatToTree(elements: FlatElement[]): Spec {
|
||||
const elementMap: Record<string, UIElement> = {};
|
||||
let root = "";
|
||||
|
||||
// First pass: add all elements to map
|
||||
for (const element of elements) {
|
||||
elementMap[element.key] = {
|
||||
key: element.key,
|
||||
type: element.type,
|
||||
props: element.props,
|
||||
children: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,16 +15,12 @@ export const schema = defineSchema((s) => ({
|
||||
/** Flat map of elements by key */
|
||||
elements: s.record(
|
||||
s.object({
|
||||
/** Unique key for this element */
|
||||
key: s.string(),
|
||||
/** Component type from catalog */
|
||||
type: s.ref("catalog.components"),
|
||||
/** Component props */
|
||||
props: s.propsOf("catalog.components"),
|
||||
/** Child element keys (flat reference) */
|
||||
children: s.array(s.string()),
|
||||
/** Parent element key (null for root) */
|
||||
parentKey: s.string(),
|
||||
/** Visibility condition */
|
||||
visible: s.any(),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# @json-render/remotion
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
|
||||
- Updated dependencies [dd17549]
|
||||
- @json-render/core@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 61ee8e5: include remove op in system prompt
|
||||
- Updated dependencies [61ee8e5]
|
||||
- @json-render/core@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54bce09: add defineRegistry function
|
||||
- Updated dependencies [54bce09]
|
||||
- @json-render/core@0.4.2
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
|
||||
@@ -25,11 +25,12 @@ function remotionPromptTemplate(context: PromptContext): string {
|
||||
lines.push("");
|
||||
lines.push("Example output (each line is a separate JSON object):");
|
||||
lines.push("");
|
||||
lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
|
||||
{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
|
||||
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
|
||||
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
|
||||
{"op":"set","path":"/audio","value":{"tracks":[]}}`);
|
||||
lines.push(`{"op":"add","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
|
||||
{"op":"add","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
|
||||
{"op":"add","path":"/clips","value":[]}
|
||||
{"op":"add","path":"/clips/-","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
|
||||
{"op":"add","path":"/clips/-","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
|
||||
{"op":"add","path":"/audio","value":{"tracks":[]}}`);
|
||||
lines.push("");
|
||||
|
||||
// Components
|
||||
@@ -107,10 +108,10 @@ function remotionPromptTemplate(context: PromptContext): string {
|
||||
lines.push("RULES:");
|
||||
const baseRules = [
|
||||
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
||||
"First set /composition with {id, fps:30, width:1920, height:1080, durationInFrames}",
|
||||
"Then set /tracks array with video/overlay tracks",
|
||||
"Then set each clip: /clips/0, /clips/1, etc.",
|
||||
"Finally set /audio with {tracks:[]}",
|
||||
'First add /composition with {id, fps:30, width:1920, height:1080, durationInFrames}: {"op":"add","path":"/composition","value":{...}}',
|
||||
'Then add /tracks array with video/overlay tracks: {"op":"add","path":"/tracks","value":[...]}',
|
||||
'Then add each clip by appending to the array: {"op":"add","path":"/clips/-","value":{...}}',
|
||||
'Finally add /audio with {tracks:[]}: {"op":"add","path":"/audio","value":{...}}',
|
||||
"ONLY use components listed above",
|
||||
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
|
||||
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
|
||||
|
||||
Generated
+3541
File diff suppressed because it is too large
Load Diff
@@ -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