diff --git a/apps/widget-builder/src/components/widget-components/button.tsx b/apps/widget-builder/src/components/widget-components/button.tsx index 333d7fe..79e0ea0 100644 --- a/apps/widget-builder/src/components/widget-components/button.tsx +++ b/apps/widget-builder/src/components/widget-components/button.tsx @@ -30,7 +30,7 @@ const ButtonDefinition: ComponentDefinition = { }, ], category: "Controls", - usage: ``, }; diff --git a/apps/widget-builder/src/hooks/use-widgets.ts b/apps/widget-builder/src/hooks/use-widgets.ts index 48079f6..4266c77 100644 --- a/apps/widget-builder/src/hooks/use-widgets.ts +++ b/apps/widget-builder/src/hooks/use-widgets.ts @@ -26,6 +26,13 @@ export const defaultWidgetTemplate: Omit = { radius="full" /> + + `, states: [ diff --git a/apps/widget-builder/src/pages/builder/WidgetBuilder.tsx b/apps/widget-builder/src/pages/builder/WidgetBuilder.tsx index 0a168da..1debbd4 100644 --- a/apps/widget-builder/src/pages/builder/WidgetBuilder.tsx +++ b/apps/widget-builder/src/pages/builder/WidgetBuilder.tsx @@ -182,6 +182,11 @@ export const WidgetBuilder = () => { schema={currentWidget.uiSchema} components={components} data={currentWidget.states?.[Number(activeState)]?.data ?? {}} + onAction={(action) => { + console.log("Action triggered:", action); + // Handle actions from widgets here + // You can dispatch Redux actions, call APIs, etc. + }} /> diff --git a/packages/widget-renderer/src/ActionContext.tsx b/packages/widget-renderer/src/ActionContext.tsx new file mode 100644 index 0000000..3bfeb19 --- /dev/null +++ b/packages/widget-renderer/src/ActionContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +/** + * Action callback type for handling events in widgets + */ +export type ActionCallback = (action: { type: string; payload?: unknown }) => void; + +/** + * Context for providing action handler to widget components + */ +export const ActionContext = createContext(undefined); + +/** + * Hook to access the action handler from context + */ +export function useAction(): ActionCallback | undefined { + return useContext(ActionContext); +} diff --git a/packages/widget-renderer/src/WidgetRenderer.tsx b/packages/widget-renderer/src/WidgetRenderer.tsx index c61db92..3f047a4 100644 --- a/packages/widget-renderer/src/WidgetRenderer.tsx +++ b/packages/widget-renderer/src/WidgetRenderer.tsx @@ -1,20 +1,27 @@ -import { JSXElementSchema, executeExpression, parseJSXTemplate } from "@deer-flow/widget"; +import { JSXElementSchema, executeExpression, parseJSXTemplate, ActionCallback } from "@deer-flow/widget"; import React, { useRef, useEffect } from "react"; import { ComponentType } from "react"; +import { ActionContext } from "./ActionContext"; + export type WidgetRendererProps = { schema?: JSXElementSchema; components: Record>; data?: Record; template?: string; + onAction?: ActionCallback; }; // Helper to resolve expression values from data context -function resolveValue(value: JSXElementSchema["value"], data: Record): unknown { +function resolveValue( + value: JSXElementSchema["value"], + data: Record, + action?: ActionCallback +): unknown { if (typeof value === "object" && value !== null && value.__expression) { const expression = value.__expression; try { - return executeExpression(expression, data); + return executeExpression(expression, data, action); } catch (error) { console.warn(`Failed to evaluate expression "${expression}":`, error); return undefined; @@ -23,7 +30,75 @@ function resolveValue(value: JSXElementSchema["value"], data: Record>; + data: Record; + action?: ActionCallback; +}): React.ReactElement | null { + switch (schema.type) { + case "text": { + const content = resolveValue(schema.value, data, action); + return <>{content}; + } + case "expression": { + const content = resolveValue(schema.value, data, action); + return <>{content}; + } + case "element": { + if (!schema.name) { + console.warn("Element schema missing 'name' property:", schema); + return null; + } + + // Resolve component from the map, or fall back to a string for native HTML elements + const Component = components[schema.name] || schema.name; + + const props: Record = {}; + if (schema.props) { + for (const key in schema.props) { + if (Object.prototype.hasOwnProperty.call(schema.props, key)) { + // Resolve prop values, which might be expressions + props[key] = resolveValue(schema.props[key] as JSXElementSchema["value"], data, action); + } + } + } + + // Recursively render children + const children = schema.children + ? schema.children.map((child, index) => ( + + )) + : undefined; + + return React.createElement(Component, props, children); + } + + default: + console.warn("Unknown schema node type:", schema.type); + return null; + } +} + +export function WidgetRenderer({ + schema, + components, + data, + template, + onAction, +}: WidgetRendererProps): React.ReactElement | null { // Cache the parsed schema to avoid re-parsing on every render const schemaRef = useRef(schema); const lastTemplateRef = useRef(template); @@ -62,46 +137,14 @@ export function WidgetRenderer({ schema, components, data, template }: WidgetRen return null; } - switch (effectiveSchema.type) { - case "text": { - const content = resolveValue(effectiveSchema.value, data ?? {}); - return <>{content}; - } - case "expression": { - const content = resolveValue(effectiveSchema.value, data ?? {}); - return <>{content}; - } - case "element": { - if (!effectiveSchema.name) { - console.warn("Element schema missing 'name' property:", effectiveSchema); - return null; - } - - // Resolve component from the map, or fall back to a string for native HTML elements - const Component = components[effectiveSchema.name] || effectiveSchema.name; - - const props: Record = {}; - if (effectiveSchema.props) { - for (const key in effectiveSchema.props) { - if (Object.prototype.hasOwnProperty.call(effectiveSchema.props, key)) { - // Resolve prop values, which might be expressions - props[key] = resolveValue(effectiveSchema.props[key] as JSXElementSchema["value"], data ?? {}); - } - } - } - - // Recursively render children - const children = effectiveSchema.children - ? effectiveSchema.children.map((child, index) => ( - - )) - : undefined; - - return React.createElement(Component, props, children); - } - - default: - console.warn("Unknown schema node type:", effectiveSchema.type); - return null; - } + return ( + + + + ); } diff --git a/packages/widget-renderer/src/index.ts b/packages/widget-renderer/src/index.ts index 9a4f758..7a5c9af 100644 --- a/packages/widget-renderer/src/index.ts +++ b/packages/widget-renderer/src/index.ts @@ -1 +1,2 @@ export * from "./WidgetRenderer"; +export * from "./ActionContext"; diff --git a/packages/widget/src/Expression.ts b/packages/widget/src/Expression.ts index 3d50044..1da929b 100644 --- a/packages/widget/src/Expression.ts +++ b/packages/widget/src/Expression.ts @@ -1,7 +1,12 @@ +/** + * Action callback type for handling events in widgets + */ +export type ActionCallback = (action: { type: string; payload?: unknown }) => void; + /** * Create a simple sandbox environment to execute expressions */ -function createSandbox(data: Record) { +function createSandbox(data: Record, action?: ActionCallback) { // Create a safe globals object const safeGlobals = { // Basic JavaScript constructors and objects @@ -20,6 +25,8 @@ function createSandbox(data: Record) { isFinite, // Data object data, + // Action function for handling events + action: action || (() => {}), }; return safeGlobals; @@ -28,10 +35,14 @@ function createSandbox(data: Record) { /** * Execute expression within sandbox */ -export function executeExpression(expression: string, data: Record): unknown { +export function executeExpression( + expression: string, + data: Record, + action?: ActionCallback +): unknown { try { // Create sandbox environment - const sandbox = createSandbox(data); + const sandbox = createSandbox(data, action); // Create arrays of parameter names and values const paramNames = Object.keys(sandbox); diff --git a/packages/widget/src/__tests__/Expression.test.ts b/packages/widget/src/__tests__/Expression.test.ts index bd9b1c0..940f9ae 100644 --- a/packages/widget/src/__tests__/Expression.test.ts +++ b/packages/widget/src/__tests__/Expression.test.ts @@ -304,4 +304,89 @@ describe("executeExpression", () => { ); }); }); + + describe("Action callback", () => { + it("should invoke action callback with correct parameters", () => { + const data = { id: 123 }; + const actionMock = vi.fn(); + + executeExpression( + "action({ type: 'test.action', payload: data.id })", + data, + actionMock + ); + + expect(actionMock).toHaveBeenCalledWith({ + type: "test.action", + payload: 123, + }); + }); + + it("should return a function that can be used as event handler", () => { + const data = { userId: 456, action: "click" }; + const actionMock = vi.fn(); + + const handler = executeExpression( + "() => action({ type: 'user.click', payload: data.userId })", + data, + actionMock + ); + + expect(typeof handler).toBe("function"); + if (typeof handler === "function") { + handler(); + expect(actionMock).toHaveBeenCalledWith({ + type: "user.click", + payload: 456, + }); + } + }); + + it("should work with complex payload objects", () => { + const data = { + user: { id: 123, name: "John" }, + timestamp: Date.now(), + }; + const actionMock = vi.fn(); + + executeExpression( + "action({ type: 'user.submit', payload: { user: data.user, timestamp: data.timestamp } })", + data, + actionMock + ); + + expect(actionMock).toHaveBeenCalledWith({ + type: "user.submit", + payload: { + user: { id: 123, name: "John" }, + timestamp: data.timestamp, + }, + }); + }); + + it("should handle missing action callback gracefully", () => { + const data = { id: 123 }; + + // Should not throw even without action callback + expect(() => { + executeExpression("action({ type: 'test.action', payload: data.id })", data); + }).not.toThrow(); + }); + + it("should allow action in conditional expressions", () => { + const data = { shouldAct: true, id: 789 }; + const actionMock = vi.fn(); + + executeExpression( + "data.shouldAct ? action({ type: 'conditional.action', payload: data.id }) : null", + data, + actionMock + ); + + expect(actionMock).toHaveBeenCalledWith({ + type: "conditional.action", + payload: 789, + }); + }); + }); });