npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 value and emits an onChange; 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-root class, not :root, so they don't collide with your design system.

Table of contents


Install

npm install @davidrandoll/automation-engine-ui

Peer 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 form
  • GET /block/{area} — list available blocks for an area
  • GET /block/{name}/schema — schema for a specific block
  • GET|POST|PUT|DELETE /user-defined/{type} — user-defined block CRUD
  • POST /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 :root tokens freely without collision.
  • Dark mode. Apply .dark to either .ae-root itself or to any ancestor — the components react to both .ae-root.dark and .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.
  • value is initial-only, not controlled. See Initial-only value semantics.
  • One backend per page. configureAgent writes 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.