{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workflow-1",
  "title": "Workflow 1: automation builder",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An automation builder as a vertical flow: a trigger, then conditions, waits and actions you add, reorder and delete, each opening to its settings, and a test run that shows every step as done or where a condition stopped it.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/workflow-1/workflow-1.tsx",
      "content": "// Ballmac UI: Workflow 1. https://ui.ballmac.com/blocks/workflow-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, CircleCheck, CircleX, Clock, GitBranch, Loader2, Mail, MessageSquare, Play, Plus, SquareCheckBig, Tag, Trash2, Zap } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { cn } from \"@/lib/utils\"\n\ntype Workflow1Step =\n  | { id: string; type: \"condition\"; field: string; operator: \"is\" | \"is not\" | \"is more than\" | \"contains\"; value: string }\n  | { id: string; type: \"delay\"; amount: number; unit: \"minutes\" | \"hours\" | \"days\" }\n  | { id: string; type: \"action\"; action: \"email\" | \"slack\" | \"task\" | \"tag\"; text: string }\n\ntype Workflow1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onChange\"> &\n  StateProps & {\n    title?: string\n    trigger?: string\n    triggers?: string[]\n    steps?: Workflow1Step[]\n    /** The facts a test run uses, for conditions. */\n    sample?: Record<string, string>\n    /** The step that is open at first. */\n    defaultSelectedId?: string\n    /** Milliseconds a step takes in a test run. 0 makes it instant. */\n    stepMs?: number\n    onChange?: (state: { trigger: string; steps: Workflow1Step[]; enabled: boolean }) => void\n  }\n\nconst sampleSteps: Workflow1Step[] = [\n  { id: \"s1\", type: \"condition\", field: \"Order total\", operator: \"is more than\", value: \"500\" },\n  { id: \"s2\", type: \"action\", action: \"slack\", text: \"Big order from {customer}: {total}\" },\n  { id: \"s3\", type: \"delay\", amount: 1, unit: \"days\" },\n  { id: \"s4\", type: \"action\", action: \"email\", text: \"Thanks for your order, {customer}. A person will be in touch.\" },\n]\n\nconst triggerList = [\"A new order is paid\", \"A customer signs up\", \"A ticket is marked urgent\", \"An invoice becomes overdue\"]\nconst fields = [\"Order total\", \"Customer country\", \"Plan\", \"Source\"]\nconst operators = [\"is\", \"is not\", \"is more than\", \"contains\"] as const\nconst actionMeta = {\n  email: { label: \"Send an email\", icon: Mail },\n  slack: { label: \"Post to Slack\", icon: MessageSquare },\n  task: { label: \"Create a task\", icon: SquareCheckBig },\n  tag: { label: \"Add a tag\", icon: Tag },\n}\nconst typeMeta = { condition: { label: \"Only continue if\", icon: GitBranch }, delay: { label: \"Wait\", icon: Clock }, action: { label: \"Do this\", icon: Zap } }\n\nconst defaultSample = { \"Order total\": \"640\", \"Customer country\": \"Portugal\", Plan: \"Team\", Source: \"Website\" }\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"mx-auto grid max-w-xl gap-4\">\n      {[0, 1, 2, 3].map((i) => (\n        <Skeleton key={i} className=\"h-24 rounded-2xl\" />\n      ))}\n    </div>\n  )\n}\n\nfunction passes(s: Extract<Workflow1Step, { type: \"condition\" }>, facts: Record<string, string>) {\n  const v = (facts[s.field] ?? \"\").toLowerCase()\n  const w = s.value.toLowerCase()\n  if (s.operator === \"is\") return v === w\n  if (s.operator === \"is not\") return v !== w\n  if (s.operator === \"contains\") return v.includes(w)\n  return Number(v) > Number(w)\n}\nconst sentence = (s: Workflow1Step) =>\n  s.type === \"condition\" ? `${s.field} ${s.operator} ${s.value || \"…\"}` : s.type === \"delay\" ? `Wait ${s.amount} ${s.amount === 1 ? s.unit.replace(/s$/, \"\") : s.unit}` : s.text || \"Add a message\"\n\n/**\n * An automation builder as a vertical flow: a trigger at the top, then conditions, waits and actions you add, reorder and delete, each opening to\n * its own settings. A test run walks the flow with sample facts and shows each step as done or, when a condition is not met, where and why it\n * stopped. The whole automation can be switched on or off, and every change is reported with the full list of steps.\n */\nfunction Workflow1({ title = \"New order alerts\", trigger: triggerProp = \"A new order is paid\", triggers = triggerList, steps: stepsProp = sampleSteps, sample = defaultSample, stepMs = 450, defaultSelectedId, onChange, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Workflow1Props) {\n  const id = React.useId()\n  const [trigger, setTrigger] = React.useState(triggerProp)\n  const [steps, setSteps] = React.useState<Workflow1Step[]>(stepsProp)\n  const [enabled, setEnabled] = React.useState(true)\n  const [selected, setSelected] = React.useState<string | null>(defaultSelectedId ?? null)\n  const [adding, setAdding] = React.useState<number | null>(null)\n  const [run, setRun] = React.useState<{ at: number; results: (\"done\" | \"stopped\")[]; finished: boolean } | null>(null)\n  const [announce, setAnnounce] = React.useState(\"\")\n  const running = !!run && !run.finished\n  const empty = state === \"empty\"\n\n  const update = (next: Workflow1Step[], t = trigger, e = enabled) => {\n    setSteps(next)\n    onChange?.({ trigger: t, steps: next, enabled: e })\n  }\n  const patch = (sid: string, p: Partial<Workflow1Step>) => update(steps.map((s) => (s.id === sid ? ({ ...s, ...p } as Workflow1Step) : s)))\n  const add = (at: number, kind: \"condition\" | \"delay\" | \"action\") => {\n    const sid = `n${Date.now()}`\n    const step: Workflow1Step = kind === \"condition\" ? { id: sid, type: \"condition\", field: fields[0]!, operator: \"is\", value: \"\" } : kind === \"delay\" ? { id: sid, type: \"delay\", amount: 1, unit: \"hours\" } : { id: sid, type: \"action\", action: \"task\", text: \"\" }\n    update([...steps.slice(0, at), step, ...steps.slice(at)])\n    setSelected(sid)\n    setAdding(null)\n    setAnnounce(`${typeMeta[kind].label} step added`)\n  }\n  const move = (i: number, d: -1 | 1) => {\n    const j = i + d\n    if (j < 0 || j >= steps.length) return\n    const next = [...steps]\n    ;[next[i], next[j]] = [next[j]!, next[i]!]\n    update(next)\n    setAnnounce(`Step moved to position ${j + 1}`)\n  }\n\n  async function test() {\n    setSelected(null)\n    const results: (\"done\" | \"stopped\")[] = []\n    setRun({ at: 0, results, finished: false })\n    for (let i = 0; i < steps.length; i++) {\n      setRun({ at: i, results: [...results], finished: false })\n      if (stepMs) await new Promise((r) => setTimeout(r, stepMs))\n      const s = steps[i]!\n      if (s.type === \"condition\" && !passes(s, sample)) {\n        results.push(\"stopped\")\n        setRun({ at: i, results: [...results], finished: true })\n        setAnnounce(`Test run stopped at step ${i + 1}: ${sentence(s)} was not met`)\n        return\n      }\n      results.push(\"done\")\n    }\n    setRun({ at: steps.length, results: [...results], finished: true })\n    setAnnounce(\"Test run finished. Every step ran.\")\n  }\n\n  const connector = (at: number) => (\n    <li className=\"flex flex-col items-center\">\n      <span aria-hidden=\"true\" className=\"bg-border h-4 w-px\" />\n      {adding === at ? (\n        <div role=\"group\" aria-label=\"Add a step\" className=\"bg-card flex flex-wrap justify-center gap-2 rounded-2xl border p-2 shadow-md\">\n          {([\"condition\", \"delay\", \"action\"] as const).map((k) => (\n            <Button key={k} size=\"sm\" variant=\"outline\" onClick={() => add(at, k)}>\n              {React.createElement(typeMeta[k].icon, { \"aria-hidden\": true, className: \"size-4\" })}\n              {typeMeta[k].label}\n            </Button>\n          ))}\n          <Button size=\"sm\" variant=\"ghost\" onClick={() => setAdding(null)}>\n            Cancel\n          </Button>\n        </div>\n      ) : (\n        <button type=\"button\" disabled={running} aria-label={`Add a step ${at === 0 ? \"after the trigger\" : `after step ${at}`}`} onClick={() => setAdding(at)} className=\"bg-background hover:bg-accent focus-visible:ring-ring/50 flex size-7 items-center justify-center rounded-full border outline-none focus-visible:ring-[3px] disabled:opacity-40\">\n          <Plus aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n      )}\n      <span aria-hidden=\"true\" className=\"bg-border h-4 w-px\" />\n    </li>\n  )\n\n  const content = empty ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Zap className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"Nothing automated yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Pick what starts it, then add what should happen. It runs on its own from then on.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"mx-auto max-w-xl\">\n      <div className=\"bg-card mb-6 flex flex-wrap items-center justify-between gap-4 rounded-2xl border p-4 sm:p-5\">\n        <label className=\"flex items-center gap-3 text-sm font-medium\">\n          <Switch checked={enabled} onCheckedChange={(v) => { setEnabled(v); onChange?.({ trigger, steps, enabled: v }) }} aria-label=\"Run this automation\" />\n          {enabled ? \"On: runs by itself\" : \"Off: paused\"}\n        </label>\n        <Button variant=\"outline\" size=\"sm\" onClick={() => void test()} disabled={running || !steps.length}>\n          {running ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Play aria-hidden=\"true\" />}\n          {running ? \"Running\" : \"Test run\"}\n        </Button>\n      </div>\n\n      <ol aria-label=\"Steps\" className=\"flex flex-col items-stretch\">\n        <li className=\"bg-foreground text-background rounded-2xl p-5\">\n          <p className=\"flex items-center gap-2 text-sm font-medium opacity-80\">\n            <Zap aria-hidden=\"true\" className=\"size-4\" />\n            When\n          </p>\n          <div className=\"mt-3\">\n            <Select value={trigger} onValueChange={(v) => { setTrigger(v); onChange?.({ trigger: v, steps, enabled }) }}>\n              <SelectTrigger aria-label=\"What starts this automation\" className=\"bg-background text-foreground w-full\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {triggers.map((t) => (\n                  <SelectItem key={t} value={t}>\n                    {t}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n        </li>\n        {connector(0)}\n        {steps.map((s, i) => {\n          const M = s.type === \"action\" ? actionMeta[s.action] : typeMeta[s.type]\n          const isSel = selected === s.id\n          const r = run && (i < run.results.length ? run.results[i] : run.at === i && !run.finished ? \"running\" : null)\n          return (\n            <React.Fragment key={s.id}>\n              <li className={cn(\"bg-card rounded-2xl border transition-[border-color,box-shadow] motion-reduce:transition-none\", isSel && \"border-foreground ring-foreground ring-1\", r === \"running\" && \"border-chart-1 ring-chart-1 ring-2\", r === \"stopped\" && \"border-destructive\")}>\n                <div className=\"flex items-center gap-3 p-4\">\n                  <span aria-hidden=\"true\" className={cn(\"flex size-10 shrink-0 items-center justify-center rounded-xl border\", r === \"done\" && \"bg-chart-2/15\", r === \"stopped\" && \"bg-destructive/10\")}>\n                    {r === \"done\" ? <CircleCheck className=\"size-5\" /> : r === \"stopped\" ? <CircleX className=\"size-5\" /> : r === \"running\" ? <Loader2 className=\"size-5 animate-spin motion-reduce:animate-none\" /> : <M.icon className=\"size-5\" />}\n                  </span>\n                  <button type=\"button\" aria-expanded={isSel} aria-controls={`${id}-${s.id}`} onClick={() => setSelected(isSel ? null : s.id)} className=\"focus-visible:ring-ring/50 min-w-0 flex-1 rounded-md text-start outline-none focus-visible:ring-[3px]\">\n                    <span className=\"text-muted-foreground block text-xs font-medium\">\n                      {i + 1}. {s.type === \"action\" ? actionMeta[s.action].label : typeMeta[s.type].label}\n                    </span>\n                    <span className=\"block truncate text-sm font-medium\">{sentence(s)}</span>\n                    {r ? (\n                      <span className=\"text-muted-foreground block text-xs\">\n                        {r === \"done\" ? \"Ran fine\" : r === \"stopped\" ? `Stopped here: ${sample[(s as Extract<Workflow1Step, { type: \"condition\" }>).field] ?? \"no value\"} does not match` : \"Running…\"}\n                      </span>\n                    ) : null}\n                  </button>\n                  <div className=\"flex shrink-0 items-center\">\n                    <Button variant=\"ghost\" size=\"icon\" aria-label={`Move step ${i + 1} up`} disabled={i === 0 || running} onClick={() => move(i, -1)}>\n                      <ArrowUp aria-hidden=\"true\" />\n                    </Button>\n                    <Button variant=\"ghost\" size=\"icon\" aria-label={`Move step ${i + 1} down`} disabled={i === steps.length - 1 || running} onClick={() => move(i, 1)}>\n                      <ArrowDown aria-hidden=\"true\" />\n                    </Button>\n                    <Button variant=\"ghost\" size=\"icon\" aria-label={`Delete step ${i + 1}`} disabled={running} onClick={() => { update(steps.filter((x) => x.id !== s.id)); setAnnounce(`Step ${i + 1} deleted`) }}>\n                      <Trash2 aria-hidden=\"true\" />\n                    </Button>\n                  </div>\n                </div>\n                {isSel ? (\n                  <div id={`${id}-${s.id}`} className=\"bg-muted/30 grid gap-4 border-t p-4 sm:grid-cols-2\">\n                    {s.type === \"condition\" ? (\n                      <>\n                        <Field label=\"Field\">\n                          <Select value={s.field} onValueChange={(v) => patch(s.id, { field: v })}>\n                            <SelectTrigger className=\"w-full\" aria-label=\"Field\"><SelectValue /></SelectTrigger>\n                            <SelectContent>{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>\n                          </Select>\n                        </Field>\n                        <Field label=\"Is\">\n                          <Select value={s.operator} onValueChange={(v) => patch(s.id, { operator: v as typeof s.operator })}>\n                            <SelectTrigger className=\"w-full\" aria-label=\"Comparison\"><SelectValue /></SelectTrigger>\n                            <SelectContent>{operators.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>\n                          </Select>\n                        </Field>\n                        <div className=\"sm:col-span-2\">\n                          <Field label=\"Value\">\n                            <Input value={s.value} onChange={(e) => patch(s.id, { value: e.target.value })} aria-label=\"Value\" />\n                          </Field>\n                        </div>\n                      </>\n                    ) : null}\n                    {s.type === \"delay\" ? (\n                      <>\n                        <Field label=\"For how long\">\n                          <Input type=\"number\" min={1} value={s.amount} onChange={(e) => patch(s.id, { amount: Math.max(1, Number(e.target.value) || 1) })} aria-label=\"Amount\" />\n                        </Field>\n                        <Field label=\"Unit\">\n                          <Select value={s.unit} onValueChange={(v) => patch(s.id, { unit: v as typeof s.unit })}>\n                            <SelectTrigger className=\"w-full\" aria-label=\"Unit\"><SelectValue /></SelectTrigger>\n                            <SelectContent>{([\"minutes\", \"hours\", \"days\"] as const).map((u) => <SelectItem key={u} value={u}>{u}</SelectItem>)}</SelectContent>\n                          </Select>\n                        </Field>\n                      </>\n                    ) : null}\n                    {s.type === \"action\" ? (\n                      <>\n                        <Field label=\"What to do\">\n                          <Select value={s.action} onValueChange={(v) => patch(s.id, { action: v as typeof s.action })}>\n                            <SelectTrigger className=\"w-full\" aria-label=\"Action\"><SelectValue /></SelectTrigger>\n                            <SelectContent>{(Object.keys(actionMeta) as (keyof typeof actionMeta)[]).map((a) => <SelectItem key={a} value={a}>{actionMeta[a].label}</SelectItem>)}</SelectContent>\n                          </Select>\n                        </Field>\n                        <div className=\"sm:col-span-2\">\n                          <Field label=\"Message or name\">\n                            <Input value={s.text} onChange={(e) => patch(s.id, { text: e.target.value })} aria-label=\"Message or name\" placeholder=\"Use {customer} and {total} to fill in details\" />\n                          </Field>\n                        </div>\n                      </>\n                    ) : null}\n                  </div>\n                ) : null}\n              </li>\n              {connector(i + 1)}\n            </React.Fragment>\n          )\n        })}\n        <li className=\"text-muted-foreground flex items-center justify-center gap-2 text-sm\">\n          <CircleCheck aria-hidden=\"true\" className=\"size-4\" />\n          {steps.length ? \"End of the automation\" : \"Add a first step to get started\"}\n        </li>\n      </ol>\n\n      {run?.finished ? (\n        <p role=\"status\" className={cn(\"mt-6 rounded-2xl border p-4 text-sm\", run.results.includes(\"stopped\") ? \"border-destructive/40 bg-destructive/5\" : \"border-chart-2/40 bg-chart-2/10\")}>\n          {run.results.includes(\"stopped\") ? `Test run stopped at step ${run.results.length}: the condition was not met, so nothing after it ran.` : `Test run finished. All ${steps.length} ${steps.length === 1 ? \"step\" : \"steps\"} ran.`}\n        </p>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"workflow-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8 text-center\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mx-auto mt-2 max-w-md text-pretty\">Do things automatically when something happens.</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the automation\" loading={<Skel />} error={{ title: errorState?.title ?? \"The automation could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nfunction Field({ label, children }: { label: string; children: React.ReactNode }) {\n  return (\n    <div>\n      <p className=\"text-muted-foreground mb-1.5 text-xs font-medium\">{label}</p>\n      {children}\n    </div>\n  )\n}\n\nexport { Workflow1, type Workflow1Props, type Workflow1Step }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/workflow-1/workflow-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "workflow",
      "automation",
      "trigger",
      "conditions",
      "actions",
      "zapier",
      "rules",
      "no-code",
      "builder",
      "flow"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "An automation builder. <Workflow1 title trigger triggers steps={[{ id, type: 'condition', field, operator: 'is' | 'is not' | 'is more than' | 'contains', value } | { id, type: 'delay', amount, unit: 'minutes' | 'hours' | 'days' } | { id, type: 'action', action: 'email' | 'slack' | 'task' | 'tag', text }]} sample={{ 'Order total': '640' }} stepMs={450} onChange={({ trigger, steps, enabled }) => ...} />. Test run walks the steps with the sample facts; a condition that fails stops it there. Every edit calls onChange with the whole flow.",
      "whenToUse": [
        "Rules and automations in a SaaS product",
        "A no-code 'when this happens, do that' editor"
      ],
      "whenNotToUse": [
        "A marketing diagram of a workflow (use a features block)",
        "A graph with branches and loops"
      ],
      "composesWith": [
        "flags-1",
        "webhooks-1",
        "app-shell-1",
        "tickets-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Each step's title is a button that says whether it is open; reorder and delete buttons say which step they act on"
        },
        {
          "keys": "Test run",
          "action": "Each step says Ran fine, Running or Stopped here with why, with an icon, and the end result is a status message"
        },
        {
          "keys": "Changes",
          "action": "Adding, moving and deleting a step are announced politely, with the new position"
        }
      ],
      "customization": [
        "Steps are a plain array, so saving and loading flows is a JSON round trip",
        "stepMs sets how long each step takes in a test run; 0 is instant",
        "The trigger card is dark so the start of the flow is always clear"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "workflow-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton step cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "Nothing automated yet",
        "example": "workflow-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says to pick what starts it and add what should happen; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "workflow-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry)."
      }
    ],
    "examples": [
      "workflow-1-demo",
      "workflow-1-open",
      "workflow-1-loading",
      "workflow-1-empty",
      "workflow-1-error"
    ],
    "url": "https://ui.ballmac.com/components/workflow-1"
  },
  "categories": [
    "blocks",
    "workflow"
  ],
  "type": "registry:block"
}