diff --git a/.eslintrc.json b/.eslintrc.json index 1ea58c28..84c39c76 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,6 +7,9 @@ "plugins": [ "simple-import-sort" ], + "ignorePatterns": [ + "src/lib/preview-server/client/**" + ], "rules": { "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", diff --git a/package.json b/package.json index eceab5e7..e4753bb4 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,11 @@ "@oclif/plugin-help": "^6", "@prantlf/jsonlint": "^14.1.0", "axios": "^1.13.2", + "chokidar": "^5.0.0", "date-fns": "^2.30.0", "degit": "^2.8.4", "enquirer": "^2.4.1", + "express": "^5.2.1", "find-up": "^5.0.0", "fs-extra": "^11.3.3", "liquidjs": "^10.24.0", @@ -32,6 +34,7 @@ "lodash": "^4.17.21", "open": "8.4.2", "quicktype-core": "^23.2.6", + "ws": "^8.19.0", "zod": "^4.3.5" }, "devDependencies": { @@ -41,9 +44,11 @@ "@swc/helpers": "^0.5.18", "@types/chai": "^4", "@types/degit": "^2.8.6", + "@types/express": "^5.0.6", "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.10", "@types/node": "^20.19.28", + "@types/ws": "^8.18.1", "chai": "^4", "eslint": "^7.32.0", "eslint-config-oclif": "^4", @@ -101,7 +106,8 @@ } }, "scripts": { - "build": "shx rm -rf dist && swc src -d dist --strip-leading-paths", + "build": "shx rm -rf dist && swc src -d dist --strip-leading-paths --ignore '**/preview-server/client/**' && yarn build:preview-client", + "build:preview-client": "cd src/lib/preview-server/client && yarn install --frozen-lockfile && yarn build", "lint": "eslint . --ext .ts --config .eslintrc.json", "lint.fix": "yarn run lint --fix", "format": "prettier \"src/**/*.+(ts|js)\" \"test/**/*.+(ts|js)\"", diff --git a/src/commands/workflow/preview.ts b/src/commands/workflow/preview.ts new file mode 100644 index 00000000..2cf933e9 --- /dev/null +++ b/src/commands/workflow/preview.ts @@ -0,0 +1,184 @@ +import * as path from "node:path"; + +import { Args, Flags } from "@oclif/core"; + +import BaseCommand from "@/lib/base-command"; +import * as CustomFlags from "@/lib/helpers/flag"; +import { pathExists } from "@/lib/helpers/fs"; +import { readJson } from "@/lib/helpers/json"; +import { resolveResourceDir } from "@/lib/helpers/project-config"; +import * as Workflow from "@/lib/marshal/workflow"; +import { createPreviewServer } from "@/lib/preview-server"; +import { WorkflowDirContext } from "@/lib/run-context"; + +export default class WorkflowPreview extends BaseCommand< + typeof WorkflowPreview +> { + static summary = "Start a local preview server for workflow templates."; + + static description = ` +Starts a local development server that allows you to preview and edit workflow +templates in your browser. The server watches for file changes and automatically +reloads the preview. + +Currently supports email channel templates with HTML/text toggle and responsive +preview modes. +`; + + static examples = [ + { + command: "<%= config.bin %> <%= command.id %> my-workflow", + description: "Start preview server for a workflow", + }, + { + command: + "<%= config.bin %> <%= command.id %> my-workflow --data-file ./sample-data.json", + description: "Start with sample trigger data from a file", + }, + { + command: "<%= config.bin %> <%= command.id %> my-workflow --port 4000", + description: "Start on a custom port", + }, + ]; + + static flags = { + environment: Flags.string({ + default: "development", + summary: "The environment to use for template preview.", + }), + branch: CustomFlags.branch, + "data-file": Flags.string({ + summary: "Path to a JSON file containing sample trigger data.", + }), + port: Flags.integer({ + default: 3004, + summary: "Port to run the preview server on.", + }), + }; + + static args = { + workflowKey: Args.string({ + required: true, + description: "The workflow key to preview.", + }), + }; + + async run(): Promise { + const { args, flags } = this.props; + + // Resolve the workflow directory + const workflowDirCtx = await this.resolveWorkflowDir(args.workflowKey); + + if (!workflowDirCtx.exists) { + this.error( + `Cannot locate workflow directory at \`${workflowDirCtx.abspath}\``, + ); + } + + // Read the workflow to validate it exists + const [workflow, readErrors] = await Workflow.readWorkflowDir( + workflowDirCtx, + { withExtractedFiles: true }, + ); + + if (readErrors.length > 0 || !workflow) { + this.error(`Failed to read workflow: ${readErrors[0]?.message}`); + } + + // Load sample data from file if provided + let sampleData: Record | undefined; + if (flags["data-file"]) { + const dataFilePath = path.resolve(process.cwd(), flags["data-file"]); + if (!(await pathExists(dataFilePath))) { + this.error(`Data file not found: ${dataFilePath}`); + } + + const [data, dataErrors] = await readJson(dataFilePath); + if (dataErrors.length > 0 || !data) { + this.error(`Failed to read data file: ${dataErrors[0]?.message}`); + } + + sampleData = data as Record; + } + + // Resolve layouts and partials directories + const layoutsDir = await resolveResourceDir( + this.projectConfig, + "email_layout", + this.runContext.cwd, + ); + const partialsDir = await resolveResourceDir( + this.projectConfig, + "partial", + this.runContext.cwd, + ); + + this.log(`‣ Starting preview server for workflow \`${args.workflowKey}\``); + this.log(` Workflow directory: ${workflowDirCtx.abspath}`); + this.log(` Port: ${flags.port}`); + this.log(""); + + // Start the preview server + const server = await createPreviewServer({ + workflowDirCtx, + layoutsDir, + partialsDir, + sampleData, + port: flags.port, + apiClient: this.apiV1, + environment: flags.environment, + branch: flags.branch, + }); + + this.log(`‣ Preview server running at http://localhost:${flags.port}`); + this.log(" Press Ctrl+C to stop"); + this.log(""); + + // Keep the process running until interrupted + await new Promise((resolve) => { + const cleanup = () => { + this.log("\n‣ Shutting down preview server..."); + server.close(() => { + this.log(" Server stopped."); + resolve(); + }); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + }); + } + + private async resolveWorkflowDir( + workflowKey: string, + ): Promise { + const { resourceDir: resourceDirCtx, cwd: runCwd } = this.runContext; + + // If we're inside a workflow directory, use it + if (resourceDirCtx?.type === "workflow") { + if (resourceDirCtx.key !== workflowKey) { + this.error( + `Cannot preview \`${workflowKey}\` inside another workflow directory:\n${resourceDirCtx.key}`, + ); + } + + return resourceDirCtx; + } + + // Otherwise, resolve the workflows index directory + const workflowsIndexDir = await resolveResourceDir( + this.projectConfig, + "workflow", + runCwd, + ); + + const targetDirPath = path.resolve(workflowsIndexDir.abspath, workflowKey); + + return { + type: "workflow", + key: workflowKey, + abspath: targetDirPath, + exists: await Workflow.isWorkflowDir(targetDirPath), + }; + } +} diff --git a/src/lib/helpers/fs.ts b/src/lib/helpers/fs.ts index 3c9a8471..8cf219b6 100644 --- a/src/lib/helpers/fs.ts +++ b/src/lib/helpers/fs.ts @@ -5,6 +5,13 @@ export type DirContext = { exists: boolean; }; +/* + * Check if a given file path exists. + */ +export const pathExists = async (abspath: string): Promise => { + return fs.pathExists(abspath); +}; + /* * Check if a given file path is a directory. */ diff --git a/src/lib/preview-server/api.ts b/src/lib/preview-server/api.ts new file mode 100644 index 00000000..670d2748 --- /dev/null +++ b/src/lib/preview-server/api.ts @@ -0,0 +1,568 @@ +import * as path from "node:path"; + +import express, { Request, Response, Router } from "express"; +import * as fs from "fs-extra"; + +import { DirContext } from "@/lib/helpers/fs"; +import { AnyObj } from "@/lib/helpers/object.isomorphic"; +import * as EmailLayout from "@/lib/marshal/email-layout"; +import * as Partial from "@/lib/marshal/partial"; +import * as Workflow from "@/lib/marshal/workflow"; +import { StepType } from "@/lib/marshal/workflow/types"; +import { EmailLayoutDirContext, PartialDirContext } from "@/lib/run-context"; + +import { + ChannelStep, + ChannelType, + LayoutData, + PartialData, + PreviewContext, + PreviewServerConfig, + TemplatePreviewRequest, + WorkflowPreviewData, +} from "./types"; + +/** + * Extract channel steps from workflow data + */ +function extractChannelSteps(workflowData: AnyObj): ChannelStep[] { + const channelSteps: ChannelStep[] = []; + + function processSteps(steps: AnyObj[]): void { + for (const step of steps) { + if (step.type === StepType.Channel) { + // Determine channel type from channel_key or template structure + const channelType = inferChannelType(step); + + channelSteps.push({ + ref: String(step.ref || ""), + name: step.name as string | undefined, + channelKey: step.channel_key as string | undefined, + channelGroupKey: step.channel_group_key as string | undefined, + channelType, + template: (step.template as Record) || {}, + }); + } else if ( + step.type === StepType.Branch && + Array.isArray(step.branches) + ) { + for (const branch of step.branches) { + if (Array.isArray(branch.steps)) { + processSteps(branch.steps); + } + } + } + } + } + + if (Array.isArray(workflowData.steps)) { + processSteps(workflowData.steps); + } + + return channelSteps; +} + +/** + * Infer channel type from step data + */ +function inferChannelType(step: AnyObj): ChannelType { + const channelKey = String(step.channel_key || "").toLowerCase(); + const template = (step.template || {}) as AnyObj; + + // Check template structure to infer type + if ( + template.subject !== undefined || + template.html_body !== undefined || + template.visual_blocks !== undefined + ) { + return "email"; + } + + if ( + template.title !== undefined && + template.text_body !== undefined && + !template.subject + ) { + return "push"; + } + + if ( + template.markdown_body !== undefined && + (template.action_url !== undefined || template.action_buttons !== undefined) + ) { + return "in_app_feed"; + } + + if ( + template.markdown_body !== undefined || + template.json_body !== undefined + ) { + return "chat"; + } + + if ( + template.text_body !== undefined && + !template.subject && + !template.title + ) { + return "sms"; + } + + // Fallback: try to infer from channel key + if (channelKey.includes("email")) return "email"; + if (channelKey.includes("sms")) return "sms"; + if (channelKey.includes("push")) return "push"; + if ( + channelKey.includes("chat") || + channelKey.includes("slack") || + channelKey.includes("discord") + ) + return "chat"; + if (channelKey.includes("in_app") || channelKey.includes("feed")) + return "in_app_feed"; + + // Default to email + return "email"; +} + +/** + * Read all layouts from the layouts directory + */ +async function readAllLayouts( + layoutsDir: DirContext, +): Promise> { + const layouts = new Map(); + + if (!layoutsDir.exists) { + return layouts; + } + + try { + const dirents = await fs.readdir(layoutsDir.abspath, { + withFileTypes: true, + }); + + // Process directories sequentially to read layouts + // eslint-disable-next-line no-await-in-loop + for (const dirent of dirents) { + if (!dirent.isDirectory()) continue; + + const layoutDirCtx: EmailLayoutDirContext = { + type: "email_layout", + key: dirent.name, + abspath: path.resolve(layoutsDir.abspath, dirent.name), + exists: true, + }; + + try { + // eslint-disable-next-line no-await-in-loop + const [layoutData] = await EmailLayout.readEmailLayoutDir( + layoutDirCtx, + { + withExtractedFiles: true, + }, + ); + + if (layoutData) { + const data = layoutData as AnyObj; + layouts.set(dirent.name, { + key: dirent.name, + name: data.name as string | undefined, + html_layout: data.html_layout as string | undefined, + text_layout: data.text_layout as string | undefined, + }); + } + } catch { + // Skip invalid layout directories + } + } + } catch { + // Directory doesn't exist or can't be read + } + + return layouts; +} + +/** + * Read all partials from the partials directory + */ +async function readAllPartials( + partialsDir: DirContext, +): Promise> { + const partials = new Map(); + + if (!partialsDir.exists) { + return partials; + } + + try { + const dirents = await fs.readdir(partialsDir.abspath, { + withFileTypes: true, + }); + + // Process directories sequentially to read partials + // eslint-disable-next-line no-await-in-loop + for (const dirent of dirents) { + if (!dirent.isDirectory()) continue; + + const partialDirCtx: PartialDirContext = { + type: "partial", + key: dirent.name, + abspath: path.resolve(partialsDir.abspath, dirent.name), + exists: true, + }; + + try { + // eslint-disable-next-line no-await-in-loop + const [partialData] = await Partial.readPartialDir(partialDirCtx, { + withExtractedFiles: true, + }); + + if (partialData) { + const data = partialData as AnyObj; + partials.set(dirent.name, { + key: dirent.name, + content: String(data.content || ""), + type: String(data.type || "html"), + }); + } + } catch { + // Skip invalid partial directories + } + } + } catch { + // Directory doesn't exist or can't be read + } + + return partials; +} + +/** + * Generate sample data from JSON schema + */ +function generateSampleDataFromSchema( + schema: Record, +): Record { + const sampleData: Record = {}; + + if (!schema || typeof schema !== "object") { + return sampleData; + } + + const properties = (schema as AnyObj).properties as + | Record + | undefined; + if (!properties) { + return sampleData; + } + + for (const [key, prop] of Object.entries(properties)) { + if (!prop || typeof prop !== "object") continue; + + switch (prop.type) { + case "string": + sampleData[key] = prop.default || prop.example || `sample_${key}`; + break; + case "number": + case "integer": + sampleData[key] = prop.default || prop.example || 0; + break; + case "boolean": + sampleData[key] = prop.default ?? prop.example ?? true; + break; + case "array": + sampleData[key] = prop.default || prop.example || []; + break; + case "object": + sampleData[key] = prop.default || prop.example || {}; + break; + default: + sampleData[key] = null; + } + } + + return sampleData; +} + +/** + * Create API router for preview server + */ +export function createApiRouter(config: PreviewServerConfig): Router { + // eslint-disable-next-line new-cap + const router = express.Router(); + + // Store workflow data and resources in memory for quick access + let cachedWorkflowData: WorkflowPreviewData | null = null; + let cachedLayouts: Map = new Map(); + let cachedPartials: Map = new Map(); + + /** + * Reload workflow data from disk + */ + async function reloadWorkflow(): Promise { + const [workflowData, errors] = await Workflow.readWorkflowDir( + config.workflowDirCtx, + { withExtractedFiles: true }, + ); + + if (errors.length > 0 || !workflowData) { + throw new Error(`Failed to read workflow: ${errors[0]?.message}`); + } + + const channelSteps = extractChannelSteps(workflowData); + + const data = workflowData as AnyObj; + cachedWorkflowData = { + key: String(data.key || ""), + name: String(data.name || ""), + description: data.description as string | undefined, + triggerDataJsonSchema: data.trigger_data_json_schema as + | Record + | undefined, + channelSteps, + }; + + return cachedWorkflowData; + } + + /** + * Reload layouts and partials from disk + */ + async function reloadResources(): Promise { + cachedLayouts = await readAllLayouts(config.layoutsDir); + cachedPartials = await readAllPartials(config.partialsDir); + } + + // Initialize caches + reloadWorkflow().catch(console.error); + reloadResources().catch(console.error); + + /** + * GET /api/workflow + * Get workflow data including channel steps + */ + router.get("/workflow", async (_req: Request, res: Response) => { + try { + const workflow = await reloadWorkflow(); + res.json(workflow); + } catch (error) { + res.status(500).json({ + error: + error instanceof Error ? error.message : "Failed to load workflow", + }); + } + }); + + /** + * GET /api/workflow/steps + * Get list of channel steps for selector + */ + router.get("/workflow/steps", async (_req: Request, res: Response) => { + try { + const workflow = await reloadWorkflow(); + res.json(workflow.channelSteps); + } catch (error) { + res.status(500).json({ + error: + error instanceof Error + ? error.message + : "Failed to load workflow steps", + }); + } + }); + + /** + * GET /api/schema + * Get trigger data JSON schema and sample data + */ + router.get("/schema", async (_req: Request, res: Response) => { + try { + const workflow = await reloadWorkflow(); + const schema = workflow.triggerDataJsonSchema || {}; + const sampleData = + config.sampleData || generateSampleDataFromSchema(schema); + + res.json({ + schema, + sampleData, + }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to load schema", + }); + } + }); + + /** + * GET /api/layouts + * Get all available layouts + */ + router.get("/layouts", async (_req: Request, res: Response) => { + try { + await reloadResources(); + const layouts = [...cachedLayouts.values()]; + res.json(layouts); + } catch (error) { + res.status(500).json({ + error: + error instanceof Error ? error.message : "Failed to load layouts", + }); + } + }); + + /** + * GET /api/layouts/:key + * Get a specific layout by key + */ + router.get("/layouts/:key", async (req: Request, res: Response) => { + try { + await reloadResources(); + const layoutKey = Array.isArray(req.params.key) + ? req.params.key[0] + : req.params.key; + const layout = cachedLayouts.get(layoutKey); + + if (!layout) { + res.status(404).json({ error: "Layout not found" }); + return; + } + + res.json(layout); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Failed to load layout", + }); + } + }); + + /** + * GET /api/partials + * Get all available partials + */ + router.get("/partials", async (_req: Request, res: Response) => { + try { + await reloadResources(); + const partials = [...cachedPartials.values()]; + res.json(partials); + } catch (error) { + res.status(500).json({ + error: + error instanceof Error ? error.message : "Failed to load partials", + }); + } + }); + + /** + * POST /api/preview + * Generate template preview using Knock API + */ + router.post("/preview", async (req: Request, res: Response) => { + try { + const { + stepRef, + context, + }: { + stepRef: string; + context: PreviewContext; + } = req.body; + + // Reload workflow to get latest template + const workflow = await reloadWorkflow(); + const step = workflow.channelSteps.find((s) => s.ref === stepRef); + + if (!step) { + res.status(404).json({ error: `Step not found: ${stepRef}` }); + return; + } + + // Reload resources + await reloadResources(); + + // Build template preview request + const previewRequest: TemplatePreviewRequest = { + channel_type: step.channelType, + template: step.template, + recipient: { id: context.recipient || "user-1" }, + data: context.data || {}, + }; + + // Add optional context + if (context.actor) { + previewRequest.actor = { id: context.actor }; + } + + if (context.tenant) { + previewRequest.tenant = { id: context.tenant }; + } + + // For email templates, resolve layout + if (step.channelType === "email") { + const settings = step.template.settings as AnyObj | undefined; + const layoutKey = settings?.layout_key as string | undefined; + + if (layoutKey && cachedLayouts.has(layoutKey)) { + const layout = cachedLayouts.get(layoutKey)!; + previewRequest.layout = { + html_content: layout.html_layout || "{{ content }}", + text_content: layout.text_layout, + }; + } + } + + // Add partials + if (cachedPartials.size > 0) { + previewRequest.partials = {}; + for (const [key, partial] of cachedPartials) { + previewRequest.partials[key] = partial.content; + } + } + + // Call Knock API + const response = await config.apiClient.client.post( + "/v1/templates/preview", + previewRequest, + { + headers: { + "Knock-Environment": config.environment, + }, + }, + ); + + if (response.status >= 400) { + res.status(response.status).json({ + error: "Preview generation failed", + errors: response.data?.errors || [ + { message: response.data?.message || "Unknown error" }, + ], + }); + return; + } + + res.json(response.data); + } catch (error) { + console.error("[api] Preview error:", error); + res.status(500).json({ + error: + error instanceof Error ? error.message : "Preview generation failed", + }); + } + }); + + /** + * POST /api/reload + * Force reload all data from disk + */ + router.post("/reload", async (_req: Request, res: Response) => { + try { + await reloadWorkflow(); + await reloadResources(); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Reload failed", + }); + } + }); + + return router; +} diff --git a/src/lib/preview-server/client/index.html b/src/lib/preview-server/client/index.html new file mode 100644 index 00000000..c435f3da --- /dev/null +++ b/src/lib/preview-server/client/index.html @@ -0,0 +1,27 @@ + + + + + + Knock Template Preview + + + +
+ + + diff --git a/src/lib/preview-server/client/package.json b/src/lib/preview-server/client/package.json new file mode 100644 index 00000000..afde8fa0 --- /dev/null +++ b/src/lib/preview-server/client/package.json @@ -0,0 +1,30 @@ +{ + "name": "@knocklabs/cli-preview-client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@telegraph/button": "latest", + "@telegraph/combobox": "latest", + "@telegraph/icon": "latest", + "@telegraph/input": "latest", + "@telegraph/layout": "latest", + "@telegraph/select": "latest", + "@telegraph/toggle": "latest", + "@telegraph/typography": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.6" + } +} diff --git a/src/lib/preview-server/client/src/App.tsx b/src/lib/preview-server/client/src/App.tsx new file mode 100644 index 00000000..013733e7 --- /dev/null +++ b/src/lib/preview-server/client/src/App.tsx @@ -0,0 +1,156 @@ +import { Button } from "@telegraph/button"; +import { Box, Stack } from "@telegraph/layout"; +import { Text } from "@telegraph/typography"; +import { useCallback, useState } from "react"; + +import { Header } from "./components/Header"; +import { PreviewPanel } from "./components/PreviewPanel"; +import { Sidebar } from "./components/Sidebar"; +import { usePreview } from "./hooks/usePreview"; +import { useWebSocket } from "./hooks/useWebSocket"; +import { ViewMode, ViewportSize } from "./types"; + +export function App() { + // Preview state and data + const { + workflow, + channelSteps, + isLoadingWorkflow, + workflowError, + selectedStep, + setSelectedStepRef, + context, + updateContext, + preview, + isLoadingPreview, + previewError, + refreshPreview, + refreshWorkflow, + } = usePreview(); + + // View controls + const [viewMode, setViewMode] = useState("html"); + const [viewportSize, setViewportSize] = useState("desktop"); + + // WebSocket for live reload + const handleReload = useCallback(() => { + refreshWorkflow(); + refreshPreview(); + }, [refreshWorkflow, refreshPreview]); + + const { isConnected } = useWebSocket({ + onReload: handleReload, + }); + + // Loading state + if (isLoadingWorkflow && !workflow) { + return ( + + + + + Loading workflow... + + + + + ); + } + + // Error state + if (workflowError) { + return ( + + + ⚠️ + + Failed to load workflow + + + {workflowError} + + + + + ); + } + + return ( + + + +
+ + + + ); +} diff --git a/src/lib/preview-server/client/src/components/ContextEditor.tsx b/src/lib/preview-server/client/src/components/ContextEditor.tsx new file mode 100644 index 00000000..b5a8ff28 --- /dev/null +++ b/src/lib/preview-server/client/src/components/ContextEditor.tsx @@ -0,0 +1,79 @@ +import { Input } from "@telegraph/input"; +import { Box, Stack } from "@telegraph/layout"; +import { Text } from "@telegraph/typography"; +import { ChangeEvent } from "react"; + +import { PreviewContext } from "../types"; + +interface ContextEditorProps { + context: PreviewContext; + onChange: (updates: Partial) => void; + disabled?: boolean; +} + +export function ContextEditor({ + context, + onChange, + disabled = false, +}: ContextEditorProps) { + return ( + + + + Recipient ID + + ) => onChange({ recipient: e.target.value })} + placeholder="user-1" + disabled={disabled} + size="2" + /> + + + + + Actor ID (optional) + + ) => onChange({ actor: e.target.value })} + placeholder="actor-1" + disabled={disabled} + size="2" + /> + + + + + Tenant ID (optional) + + ) => onChange({ tenant: e.target.value })} + placeholder="tenant-1" + disabled={disabled} + size="2" + /> + + + ); +} diff --git a/src/lib/preview-server/client/src/components/DataEditor.tsx b/src/lib/preview-server/client/src/components/DataEditor.tsx new file mode 100644 index 00000000..b4990680 --- /dev/null +++ b/src/lib/preview-server/client/src/components/DataEditor.tsx @@ -0,0 +1,78 @@ +import { Box } from "@telegraph/layout"; +import { Text } from "@telegraph/typography"; +import { ChangeEvent, useCallback, useEffect, useState } from "react"; + +interface DataEditorProps { + data: Record; + onChange: (data: Record) => void; + schema?: Record; + disabled?: boolean; +} + +export function DataEditor({ + data, + onChange, + disabled = false, +}: DataEditorProps) { + const [jsonText, setJsonText] = useState(""); + const [error, setError] = useState(null); + + // Sync external data to text + useEffect(() => { + setJsonText(JSON.stringify(data, null, 2)); + setError(null); + }, [data]); + + const handleChange = useCallback( + (e: ChangeEvent) => { + const text = e.target.value; + setJsonText(text); + + try { + const parsed = JSON.parse(text); + setError(null); + onChange(parsed); + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid JSON"); + } + }, + [onChange] + ); + + return ( + + + Trigger Data (JSON) + +