@davidrandoll/automation-engine-ui
v0.1.0
Published
Embeddable React pages for the Automation Engine: Builder, User-Defined Types, Playground.
Readme
@davidrandoll/automation-engine-ui
Embeddable React components for the Automation Engine. Drop the visual / code / workflow automation editors and the user-defined-block management UI into your own React app, wired to your own routes and styled with your own theme.
- Per-mode editors — use just the visual form, just the JSON/YAML editor, or just the workflow canvas.
- Controlled-via-callback — every component takes a
valueand emits anonChange; you own the data. - Bring your own routing — components never call
useNavigate(). They expose callbacks (onSuccess,onTabChange, etc.) that you wire into your router. - Scoped styles — design tokens live under a
.ae-rootclass, not:root, so they don't collide with your design system.
Table of contents
- Install
- Quick start
- Backend requirement
- Configuration
- Components
AutomationInteractive– form-based editorAutomationCode– JSON / YAML editorAutomationWorkflow– visual canvasModeSelector– tab switcher between the three modesUserDefinedPage– list/manage user-defined blocksUserDefinedRegisterPage– create formUserDefinedEditPage– edit formPlaygroundPage– run automations + view traces
- Building a builder page (composition example)
- Custom widgets
- Theming and styles
- Toasts
- Monaco editor
- Type reference
- Limitations
- Troubleshooting
Install
npm install @davidrandoll/automation-engine-uiPeer dependencies your host app must provide:
| Package | Version |
|---|---|
| react | ^18 or ^19 |
| react-dom | ^18 or ^19 |
| react-hook-form | ^7 |
Everything else (Radix UI, lucide-react, Monaco, xyflow, sonner, js-yaml, Tailwind v4 runtime, etc.) is bundled inside the package — you do not need to install them separately.
Quick start
import {
AutomationEngineConfigProvider,
AutomationInteractive,
} from "@davidrandoll/automation-engine-ui";
import "@davidrandoll/automation-engine-ui/styles.css";
import { Toaster } from "sonner";
export function App() {
return (
<AutomationEngineConfigProvider
config={{
apiBaseUrl: "https://api.example.com",
apiPath: "/automation-engine",
}}
>
<AutomationInteractive
onChange={(automation) => console.log("user edited:", automation)}
/>
<Toaster richColors position="top-right" />
</AutomationEngineConfigProvider>
);
}That's the minimum: provider for API config, one editor, one CSS import, one Toaster (recommended). Everything else is optional.
Backend requirement
These components are a thin client over the Automation Engine HTTP API. They expect endpoints under {apiBaseUrl}{apiPath} such as:
GET /automation-definition/schema— schema for the automation formGET /block/{area}— list available blocks for an areaGET /block/{name}/schema— schema for a specific blockGET|POST|PUT|DELETE /user-defined/{type}— user-defined block CRUDPOST /playground/execute— execute an automation and return a trace
If your backend is on a different origin, configure CORS on the server. The client sends Content-Type: application/json and no auth headers — your backend should handle session/cookie auth, or you can extend the agent (see escape hatch below).
Configuration
AutomationEngineConfigProvider
Wrap any subtree that mounts components from this package once.
import { AutomationEngineConfigProvider } from "@davidrandoll/automation-engine-ui";
<AutomationEngineConfigProvider
config={{
apiBaseUrl: "https://api.example.com", // origin
apiPath: "/automation-engine", // path prefix
}}
>
{/* … your pages … */}
</AutomationEngineConfigProvider>| Prop | Type | Default | Notes |
|---|---|---|---|
| config.apiBaseUrl | string | window.location.origin + contextPath | Absolute origin or absolute URL |
| config.apiPath | string | "/automation-engine" | Appended to apiBaseUrl for every request |
| config.contextPath | string | from window.__APP_CONFIG__ | Used when apiBaseUrl is omitted |
| config.uiPath | string | from window.__APP_CONFIG__ | Reserved for legacy server-side bootstrap |
The provider is optional. If omitted, the package falls back to window.__APP_CONFIG__ (set by some server bootstraps), then to defaults.
Escape hatch: configureAgent
For non-React bootstraps (e.g. a CDN-style script), call configureAgent once before mounting any component:
import { configureAgent } from "@davidrandoll/automation-engine-ui";
configureAgent({
apiBaseUrl: "https://api.example.com",
apiPath: "/automation-engine",
});The provider does this for you internally.
Components
AutomationInteractive
Form-based editor with a live JSON/YAML preview pane. No header, no mode toggle.
import { AutomationInteractive } from "@davidrandoll/automation-engine-ui";
<AutomationInteractive
value={initialAutomation}
onChange={(next) => setMyData(next)}
/>Props
| Prop | Type | Default | Notes |
|---|---|---|---|
| value | Record<string, unknown> | string | undefined | undefined | Object, JSON string, or YAML string. Initial-only — see below. |
| format | "json" | "yaml" | "json" if value is a string | Required when value is a string. |
| onChange | (value: Record<string, unknown>) => void | undefined | Fires on every user edit. Receives a parsed object regardless of input format. |
| className | string | undefined | Appended to the root <div className="ae-root">. |
Initial-only value semantics
value is applied once on mount. Subsequent changes to the prop do not re-seed the form. This avoids cursor-jumping echo loops where onChange updates value which updates the form which triggers onChange again. If you need to load value asynchronously, render the editor only after the value resolves:
const [draft, setDraft] = useState<Automation | null>(null);
useEffect(() => {
fetch("/api/draft").then((r) => r.json()).then(setDraft);
}, []);
return draft && <AutomationInteractive value={draft} onChange={setDraft} />;AutomationCode
JSON/YAML Monaco editor with split-pane and copy/download buttons.
<AutomationCode
value={`alias: My Automation\ntriggers:\n - trigger: alwaysTrue\n`}
format="yaml"
onChange={(obj) => save(obj)}
/>Same props as AutomationInteractive, plus:
| Prop | Type | Default | Notes |
|---|---|---|---|
| initialFormat | "json" | "yaml" | "yaml" | Which view the code editor opens in (the user can still toggle inside). |
onChange fires after each Monaco edit and always emits a parsed object — your handler doesn't have to choose between JSON and YAML.
AutomationWorkflow
Visual node-and-edge canvas for the automation, built on @xyflow/react.
<AutomationWorkflow
value={automation}
onChange={setAutomation}
className="h-screen"
/>Same props as AutomationInteractive. Best given a tall container; the component fills its parent.
ModeSelector
A pre-styled tabbed control for switching between interactive, code, and workflow. Optional — use this if you want the same visual style as the reference app, or roll your own.
import { ModeSelector, type UIMode } from "@davidrandoll/automation-engine-ui";
const [mode, setMode] = useState<UIMode>("interactive");
<ModeSelector mode={mode} onModeChange={setMode} />| Prop | Type | Notes |
|---|---|---|
| mode | "interactive" | "code" | "workflow" | Currently selected tab. |
| onModeChange | (mode: UIMode) => void | Fires when the user clicks a tab. |
UserDefinedPage
List + manage user-defined Actions / Conditions / Triggers / Variables. Each tab loads its definitions from the backend; users can register, edit, or unregister.
import { UserDefinedPage, type BlockType } from "@davidrandoll/automation-engine-ui";
<UserDefinedPage
defaultTab="actions"
onRegisterBlock={(type) => router.push(`/user-defined/${type}/new`)}
onEditBlock={(type, name) => router.push(`/user-defined/${type}/edit?name=${name}`)}
onTabChange={(type) => router.push(`/user-defined/${type}`)}
/>| Prop | Type | Default | Notes |
|---|---|---|---|
| activeTab | BlockType | undefined | Controlled tab. If set, the component does not manage its own tab state. |
| defaultTab | BlockType | "actions" | Initial tab when uncontrolled. |
| onTabChange | (blockType: BlockType) => void | undefined | Fires on tab click. Wire to your router. |
| onRegisterBlock | (blockType: BlockType) => void | undefined | Fires on the "Register {Type}" button. |
| onEditBlock | (blockType: BlockType, name: string) => void | undefined | Fires on a row's Edit button. |
BlockType = "actions" | "conditions" | "triggers" | "variables".
UserDefinedRegisterPage
Form for creating a new user-defined block.
<UserDefinedRegisterPage
blockType="actions"
onSuccess={() => router.push("/user-defined/actions")}
onCancel={() => router.back()}
/>| Prop | Type | Notes |
|---|---|---|
| blockType | BlockType | Which kind of block to register. Required. |
| onSuccess | () => void | Fires after a successful POST. Navigate the user away here. |
| onCancel | () => void | Fires on the back arrow. |
A success toast ("Action 'foo' registered successfully") is emitted via sonner — mount a <Toaster /> somewhere in your app or those messages will be silent.
UserDefinedEditPage
Form for editing an existing user-defined block. Loads the existing definition + schema in parallel on mount.
<UserDefinedEditPage
blockType="actions"
name="my-action"
onSuccess={() => router.push("/user-defined/actions")}
onCancel={() => router.back()}
/>Same props as UserDefinedRegisterPage, plus:
| Prop | Type | Notes |
|---|---|---|
| name | string | The identifier of the block being edited. Required. |
PlaygroundPage
Run an automation against the backend and view its trace, or paste a trace JSON and visualize it without running anything. Includes Monaco editors for the automation, inputs, and trace; a node graph for the trace; and a logs viewer.
<PlaygroundPage className="h-screen" />| Prop | Type | Notes |
|---|---|---|
| className | string | Appended to the root flex container. Give it height. |
This page is self-contained — it manages its own automation/inputs/trace state internally. There is no value / onChange for the playground; it's a tool, not a controlled editor.
Building a builder page (composition example)
The package intentionally does not ship an "all-in-one" builder. The reference standalone app builds one by composing the three primitives + ModeSelector like this:
import { useState } from "react";
import {
AutomationInteractive,
AutomationCode,
AutomationWorkflow,
ModeSelector,
type AutomationValue,
type UIMode,
} from "@davidrandoll/automation-engine-ui";
export function BuilderPage() {
const [mode, setMode] = useState<UIMode>("interactive");
const [data, setData] = useState<AutomationValue | undefined>(undefined);
return (
<div className="p-6 bg-gray-50 min-h-screen">
<header className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">My Automation Builder</h1>
<p className="text-gray-500">Pick a mode and start building.</p>
</div>
<ModeSelector mode={mode} onModeChange={setMode} />
</header>
{mode === "interactive" && <AutomationInteractive value={data} onChange={setData} />}
{mode === "code" && <AutomationCode value={data} onChange={setData} />}
{mode === "workflow" && <AutomationWorkflow value={data} onChange={setData} />}
</div>
);
}The data is held at the page level (data state), so switching modes preserves what the user has typed. Each editor seeds itself from data on mount and reports back via onChange.
If you only want one mode, mount one component — no need for the wrapper at all:
<AutomationWorkflow value={automation} onChange={save} className="h-screen" />Custom widgets
Schemas can request a custom field widget via @PresentationHint(customComponent="myWidget"). Register your component:
import { registerCustomWidget, type CustomWidgetProps } from "@davidrandoll/automation-engine-ui";
function ColorPicker({ name, value, onChange, label }: CustomWidgetProps) {
return (
<label>
{label}
<input
type="color"
name={name}
value={(value as string) ?? "#000000"}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
registerCustomWidget("colorPicker", ColorPicker);CustomWidgetProps:
interface CustomWidgetProps {
name: string; // form field name
value: unknown; // current value
onChange: (value: unknown) => void; // setter
control: Control<FieldValues>; // react-hook-form Control
schema: Record<string, unknown>; // raw field schema
customProps?: Record<string, unknown>; // anything from @PresentationHint
label: string;
}A bundled colorPicker widget is auto-registered the first time any field renders, so existing customComponent="colorPicker" references work without configuration.
Theming and styles
The package ships a single CSS file:
import "@davidrandoll/automation-engine-ui/styles.css";Differences from a typical Tailwind app:
- No global preflight reset. The CSS does not touch
body,html, or*— it won't fight your existing reset. - Tokens are scoped. Design tokens (
--background,--primary,--border, etc.) live under.ae-root, not:root. Every component renders this class on its top-level element. Define your own:roottokens freely without collision. - Dark mode. Apply
.darkto either.ae-rootitself or to any ancestor — the components react to both.ae-root.darkand.dark .ae-root.
To override a token, target .ae-root in your own stylesheet:
.ae-root {
--primary: oklch(0.55 0.2 264);
--radius: 0.25rem;
}The components use Tailwind v4 utility classes internally; the bundled CSS already contains every utility they need.
Toasts
Several actions emit toast notifications (Action 'foo' registered, Failed to load triggers, etc.) via sonner. Mount a <Toaster /> once somewhere above the components:
import { Toaster } from "sonner";
<>
<Toaster richColors position="top-right" />
{/* … your pages … */}
</>sonner is a peer-friendly transitive dependency; you don't need to install it separately, but you do need to render the <Toaster />.
Monaco editor
AutomationCode and PlaygroundPage use @monaco-editor/react. By default it loads the Monaco runtime from a CDN (https://cdn.jsdelivr.net/npm/monaco-editor) at first render.
If your host app already configures Monaco (or is offline-only and needs a local copy), do this once before rendering any component that uses it:
import { loader } from "@monaco-editor/react";
loader.config({ paths: { vs: "/vendor/monaco/min/vs" } });Type reference
// Re-exported from the package
export type AutomationValue = Record<string, unknown>;
export type UIMode = "interactive" | "code" | "workflow";
export type BlockType = "actions" | "conditions" | "triggers" | "variables";
export interface AutomationModeProps {
value?: AutomationValue | string;
format?: "json" | "yaml";
onChange?: (value: AutomationValue) => void;
className?: string;
}
export interface AutomationEngineConfig {
apiBaseUrl?: string;
apiPath?: string;
contextPath?: string;
uiPath?: string;
}
export interface CustomWidgetProps {
name: string;
value: unknown;
onChange: (value: unknown) => void;
control: Control<FieldValues>;
schema: Record<string, unknown>;
customProps?: Record<string, unknown>;
label: string;
}
// User-defined block definition shapes
export interface BaseDefinition {
alias?: string;
description?: string;
name: string;
parameters?: Record<string, unknown>;
}
export interface UserDefinedActionDefinition extends BaseDefinition { /* … */ }
export interface UserDefinedConditionDefinition extends BaseDefinition { /* … */ }
export interface UserDefinedTriggerDefinition extends BaseDefinition { /* … */ }
export interface UserDefinedVariableDefinition extends BaseDefinition { /* … */ }
export type UserDefinedDefinition =
| UserDefinedActionDefinition
| UserDefinedConditionDefinition
| UserDefinedTriggerDefinition
| UserDefinedVariableDefinition;Limitations
- No router included. Wire callback props (
onModeChange,onTabChange,onRegisterBlock,onEditBlock,onSuccess,onCancel) into your host's router. valueis initial-only, not controlled. See Initial-only value semantics.- One backend per page.
configureAgentwrites to a module-level singleton, so mounting two component trees against different backends in the same browser tab is not supported. - Auth. The HTTP client sends no auth headers. Your backend needs to handle session cookies / CORS, or fork the package and extend the agent to attach tokens.
- Server-side rendering. Components render
null-safe on first paint but do not pre-render their data; expect a "Loading…" flash on first mount.
Troubleshooting
The page renders but buttons / inputs are unstyled.
You forgot to import "@davidrandoll/automation-engine-ui/styles.css". The bundled CSS is what supplies all utility classes.
useAutomationEngineConfig must be used within an AutomationEngineConfigProvider
You called the hook outside the provider. Wrap your subtree in <AutomationEngineConfigProvider>. (Note: components themselves don't require the provider — the hook does, and only if you choose to consume the config in your own code.)
API requests 404 against the wrong URL.
Pass config={{ apiBaseUrl, apiPath }} to <AutomationEngineConfigProvider>. Without it the package builds URLs from window.location.origin + window.__APP_CONFIG__.contextPath + apiPath — which only works when your host runs behind the Spring backend.
Toast messages never appear.
Mount a <Toaster /> from sonner in your app tree.
Custom widget renders as a plain input.
The widget name in your schema (@PresentationHint(customComponent="x")) must match the name passed to registerCustomWidget("x", …) exactly. Register the widget before the page mounts; once at app boot is fine.
Monaco editor never loads.
You're either offline (default loader uses a CDN) or you have a CSP blocking cdn.jsdelivr.net. Either allow it or call loader.config({ paths: { vs: "/your/local/path" } }) before rendering the playground or code editor.
