Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const ButtonDefinition: ComponentDefinition = {
},
],
category: "Controls",
usage: `<Button size="sm">
usage: `<Button size="sm" onClick={action({type: "button.click", payload: data.id})}>
Click Me
</Button>`,
};
Expand Down
7 changes: 7 additions & 0 deletions apps/widget-builder/src/hooks/use-widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export const defaultWidgetTemplate: Omit<Widget, "id"> = {
radius="full"
/>
</Row>

<Button
size="sm"
onClick={action({type: "driver.contact", payload: data.driver.name})}
>
Contact Driver
</Button>
</Card>
`,
states: [
Expand Down
5 changes: 5 additions & 0 deletions apps/widget-builder/src/pages/builder/WidgetBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}}
/>
</ErrorBoundary>
</div>
Expand Down
18 changes: 18 additions & 0 deletions packages/widget-renderer/src/ActionContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ActionCallback | undefined>(undefined);

/**
* Hook to access the action handler from context
*/
export function useAction(): ActionCallback | undefined {
return useContext(ActionContext);
}
135 changes: 89 additions & 46 deletions packages/widget-renderer/src/WidgetRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ComponentType<unknown>>;
data?: Record<string, unknown>;
template?: string;
onAction?: ActionCallback;
};

// Helper to resolve expression values from data context
function resolveValue(value: JSXElementSchema["value"], data: Record<string, unknown>): unknown {
function resolveValue(
value: JSXElementSchema["value"],
data: Record<string, unknown>,
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;
Expand All @@ -23,7 +30,75 @@ function resolveValue(value: JSXElementSchema["value"], data: Record<string, unk
return value;
}

export function WidgetRenderer({ schema, components, data, template }: WidgetRendererProps): React.ReactElement | null {
// Internal component that has access to action context
function WidgetRendererInternal({
schema,
components,
data,
action,
}: {
schema: JSXElementSchema;
components: Record<string, ComponentType<unknown>>;
data: Record<string, unknown>;
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<string, unknown> = {};
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) => (
<WidgetRendererInternal
key={index}
schema={child}
components={components}
data={data}
action={action}
/>
))
: 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<JSXElementSchema | null>(schema);
const lastTemplateRef = useRef<string | undefined>(template);
Expand Down Expand Up @@ -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<string, unknown> = {};
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) => (
<WidgetRenderer key={index} schema={child} components={components} data={data} />
))
: undefined;

return React.createElement(Component, props, children);
}

default:
console.warn("Unknown schema node type:", effectiveSchema.type);
return null;
}
return (
<ActionContext.Provider value={onAction}>
<WidgetRendererInternal
schema={effectiveSchema}
components={components}
data={data ?? {}}
action={onAction}
/>
</ActionContext.Provider>
);
}
1 change: 1 addition & 0 deletions packages/widget-renderer/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./WidgetRenderer";
export * from "./ActionContext";
17 changes: 14 additions & 3 deletions packages/widget/src/Expression.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
function createSandbox(data: Record<string, unknown>, action?: ActionCallback) {
// Create a safe globals object
const safeGlobals = {
// Basic JavaScript constructors and objects
Expand All @@ -20,6 +25,8 @@ function createSandbox(data: Record<string, unknown>) {
isFinite,
// Data object
data,
// Action function for handling events
action: action || (() => {}),
};

return safeGlobals;
Expand All @@ -28,10 +35,14 @@ function createSandbox(data: Record<string, unknown>) {
/**
* Execute expression within sandbox
*/
export function executeExpression(expression: string, data: Record<string, unknown>): unknown {
export function executeExpression(
expression: string,
data: Record<string, unknown>,
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);
Expand Down
85 changes: 85 additions & 0 deletions packages/widget/src/__tests__/Expression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
});