# Workflow editor

A workflow an agent proposes and a person edits before it runs: a trigger (an event in your app, a schedule, or a person starting it) and a list of steps and If/Else conditions. The proposal is labelled as the agent's, by the name your app gives, and every change the person makes is marked against it: added, changed or moved, with what they removed counted and named. Back to the proposal and Undo take changes back until the person saves; nothing runs or is saved until then.

Steps are actions from your app's list; conditions are built from your app's fields, as FilterBar's rules are, and Then and Else each hold their own steps. After a condition, the next node in its list runs whichever branch ran. The schedule is `schedule-editor`'s `ScheduleRecurrence` and time zone, with its next runs worked out here and saying so.

Problems are tied to what they're about. The editor finds the unfinished ones (no event, no action, a term with no value). Your app adds its own through `validate`, which reads the draft as it changes, or by returning them from `onSave`; each shows on its node in the list and the graph, and in a list by Save that moves to the node. Errors lock Save, saying why; warnings don't. Save calls back with the definition and its change hash, so your server can store exactly what the person saw and refuse anything that drifted.

The list is the screen-reader truth: nested lists in reading order, one row in the tab order at a time. Arrow keys move between rows, Alt with Up or Down moves the node a place (into and out of branches), Enter opens its editor, Delete deletes it, and S and C add a step or a condition after it (the letters are strings, so a translation can match its words). The graph view lays the same nodes out with `graph-layout`, Then and Else on the arrows, and a node chosen there opens under it. When the agent sends a new proposal while the person is editing, the draft stays as it is and says so; with no edits made, the new proposal is taken.

In the graph view a step or condition moves to the same places Alt with an arrow walks in the list (between two nodes, into a branch), never to a free position: the layout stays automatic. With a mouse or a pen it is dragged, a line marking the place it will go. From the keyboard or on a touch screen, the Move button on its card under the graph picks it up; then the arrow keys walk its places in reading order and Enter drops it, or a tap on a place (each a button named by its place) drops it there, and Escape or the Cancel button beside Move puts it back, each step said. A move also ends, put back and said, when focus goes to the page or nowhere. Escape goes no further, so a Dialog or Sheet the editor is in stays open. The trigger always comes first, and its Move button says so. A move is marked "Moved by you" and Undo takes it back, as in the list.

- Registry name: `workflow-editor` (`@candor/workflow-editor`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/workflow-editor

## Install

```bash
npx candor-ui add workflow-editor
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/workflow-editor`.
It brings in: button, change-hash, checkbox, combobox, date-range-picker, field, filter-bar, graph-layout, graph-view, heading-level, heading-scope, input, label, locale, locked, motion, number-input, radio-group, schedule-editor, select, textarea, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import type { FilterField } from "@/components/candor/filter-bar"
import { WorkflowEditor, type WorkflowAction, type WorkflowDefinition, type WorkflowEvent, type WorkflowIssue, type WorkflowNode } from "@/components/candor/workflow-editor"

const events: WorkflowEvent[] = [
  { id: "invoice.received", label: "An invoice arrives", description: "When the intake inbox receives an invoice and reads its fields." },
  { id: "invoice.disputed", label: "A supplier disputes a payment" },
]

const actions: WorkflowAction[] = [
  { id: "request-approval", label: "Ask finance to approve", description: "Opens an approval for the finance team; the workflow waits for it." },
  { id: "mark-ready", label: "Mark as ready to pay" },
  { id: "pay", label: "Schedule the payment", description: "Adds the invoice to the next payment run." },
  { id: "notify", label: "Post to the team channel" },
  { id: "hold", label: "Put the invoice on hold" },
]

const fields: FilterField[] = [
  { id: "amount", label: "Amount", kind: "number" },
  { id: "supplier", label: "Supplier", kind: "option", options: [{ value: "new", label: "New" }, { value: "known", label: "Known" }] },
  { id: "reference", label: "Reference", kind: "text" },
]

const proposal: WorkflowDefinition = {
  trigger: { kind: "event", event: "invoice.received" },
  nodes: [
    {
      id: "big-or-new",
      kind: "condition",
      match: "any",
      rules: [
        { id: "r1", field: "amount", operator: "greaterThan", value: 5000 },
        { id: "r2", field: "supplier", operator: "is", value: "new" },
      ],
      whenTrue: [{ id: "approve", kind: "step", action: "request-approval", note: "Large amounts and new suppliers need a second look." }],
      whenFalse: [{ id: "ready", kind: "step", action: "mark-ready" }],
    },
    { id: "tell-team", kind: "step", action: "notify", note: "Say which way the invoice went." },
  ],
}

/** Your app's own rule: a payment needs an approval before it on the same path. */
function validate(definition: WorkflowDefinition): WorkflowIssue[] {
  const issues: WorkflowIssue[] = []
  const walk = (nodes: WorkflowNode[], approved: boolean): boolean => {
    let ok = approved
    for (const node of nodes) {
      if (node.kind === "step") {
        if (node.action === "request-approval") ok = true
        if (node.action === "pay" && !ok) issues.push({ node: node.id, message: "A payment needs an approval step before it on the same path." })
        if (node.action === "notify" && !node.note) issues.push({ node: node.id, message: "Say what the post is about, or the channel gets the invoice number alone.", severity: "warning" })
      } else {
        const a = walk(node.whenTrue, ok)
        const b = walk(node.whenFalse, ok)
        ok = a && b
      }
    }
    return ok
  }
  walk(definition.nodes, false)
  return issues
}

export default function Example() {
  const [saved, setSaved] = React.useState<string>()
  return (
    <div className="flex max-w-3xl flex-col gap-3">
      <WorkflowEditor
        title="Invoice intake"
        agent="Finance agent"
        proposal={proposal}
        events={events}
        actions={actions}
        fields={fields}
        validate={validate}
        timeZones={["Europe/London", "America/New_York", "Asia/Tokyo"]}
        onSave={(definition, hash) =>
          // Your server recomputes the hash from the definition it receives, stores both, and only then turns it on.
          new Promise<void>((done) =>
            window.setTimeout(() => {
              setSaved(`${definition.nodes.length} top-level steps · sha256:${hash.slice(0, 12)}…`)
              done()
            }, 900)
          )
        }
      />
      {saved && <p className="text-xs text-muted-foreground">Sent to the server: {saved}</p>}
    </div>
  )
}
```

## <WorkflowEditor>

Props:

- `proposal: WorkflowDefinition`: The workflow the agent proposed. The person edits a copy; this one is what their changes are marked against.
- `agent: string`: The agent that proposed it, named by your app (never from model output).
- `fields: FilterField[]`: The fields a condition's terms can test, as FilterBar's.
- `actions: WorkflowAction[]`: What a step can do.
- `events?: WorkflowEvent[] | undefined` (default `[]`): The events that can start the workflow. Without any, an event trigger isn't offered unless the proposal has one.
- `triggers?: WorkflowTrigger["kind"][] | undefined`: The kinds of trigger to offer, in order. Defaults to event (when there are events), schedule and manual.
- `onSave: (definition: WorkflowDefinition, hash: string) => void | WorkflowIssue[] | Promise<void | WorkflowIssue[]>`: Called by Save with the definition and its hash from `hashOf`. Return a promise to show Save as saving until it settles. Resolve with issues to say why it wasn't saved: they show on their nodes, marked as from the last save, and don't lock Save, since your server checks again. A rejection shows "Saving the workflow failed." unless you pass `error`.
- `validate?: ((definition: WorkflowDefinition) => WorkflowIssue[]) | undefined`: Your app's checks on the draft, run as it changes. Each issue shows on the trigger or node it names.
- `busy?: boolean | undefined` (default `false`): Your app is saving: every edit and Save are locked, saying so.
- `cannotSave?: string | undefined`: Why this person can't save the workflow, when they can't. Locks Save with it; editing still works.
- `error?: string | undefined`: Why the last save failed, in your app's words, shown above Save.
- `hashOf?: ((definition: WorkflowDefinition) => string | Promise<string>) | undefined` (default `changeHash`): Works out the definition's hash. Defaults to changeHash, shown as "sha256:". Your own is shown without an algorithm's name.
- `timeZones?: string[] | undefined`: The time zones a schedule offers, as IANA names. Defaults to every zone the browser knows.
- `weekdays?: number[] | undefined` (default `defaultWeekdays`): The days "Every weekday" runs on (0 is Sunday), as `schedule-editor` takes them. Defaults to Monday to Friday.
- `now?: Date | string | number | undefined`: The moment a schedule's next runs count from. Defaults to the present moment.
- `view?: "list" | "graph" | undefined`: "list" or "graph", when your app controls the view.
- `defaultView?: "list" | "graph" | undefined` (default `"list"`): The view at first, when your app doesn't control it. Defaults to "list".
- `onViewChange?: ((view: "list" | "graph") => void) | undefined`: Called with the view the person picks.
- `title?: string | undefined`: A heading over the editor, also its accessible name. Defaults to "Workflow" for the name, with no heading shown.
- `headingLevel?: HeadingLevel | undefined`: The heading's level when `title` is set. Defaults to one below the nearest `HeadingScope`, else 2.
- `className?: string | undefined`

## Strings (`workflowEditor`)

The fixed words, English by default. Override any of them under `workflowEditor` in `<LocaleProvider strings={{ workflowEditor: { … } }}>`; the table is exported as `workflowEditorStrings`.

- `label`: Workflow
- `proposedBy`: Proposed by {agent}. Nothing runs or is saved until you save.
- `fromAgent`: From {agent}
- `added`: Added by you
- `changed`: Changed by you
- `moved`: Moved by you
- `noChanges`: No changes to {agent}’s proposal yet.
- `yourChanges`: Your changes: {changes}
- `addedCount`: {count} added
- `changedCount`: {count} changed
- `movedCount`: {count} moved
- `removedCount`: {count} removed
- `triggerChanged`: the trigger changed
- `removedList`: Removed by you: {names}
- `newProposal`: {agent} changed its proposal after you started editing. Your draft is as you left it.
- `useNewProposal`: Start from the new proposal
- `undo`: Undo
- `nothingToUndo`: Nothing to undo.
- `reset`: Back to the proposal
- `resetLocked`: This is {agent}’s proposal as it was.
- `graphLabel`: Workflow as a graph
- `graphHint`: The same trigger and steps as the list, in the same order. Choose one to edit it under the graph.
- `graphPick`: Choose the trigger or a step in the graph to edit it here.
- `trigger`: Trigger
- `starts`: Starts
- `event`: When something happens
- `schedule`: On a schedule
- `manual`: When a person starts it
- `eventField`: Event
- `chooseEvent`: Choose an event
- `eventTitle`: When: {event}
- `eventNone`: When something happens (no event chosen)
- `scheduleTitle`: On a schedule: {rule}
- `manualTitle`: When a person starts it
- `manualNote`: It runs only when someone starts it.
- `nextRuns`: Next runs
- `step`: Step
- `condition`: Condition
- `numbered`: {kind} {number}
- `action`: Action
- `chooseAction`: Choose an action
- `noAction`: No action chosen
- `note`: Note
- `noteHint`: What this step is for, in words. Shown under it in the list.
- `conditionTitle`: If {terms}
- `conditionEmpty`: If … (no terms yet)
- `term`: Term {number}
- `termText`: {field}: {value}
- `termUnfinished`: an unfinished term
- `match`: Then runs when
- `matchAll`: Every term is true
- `matchAny`: Any term is true
- `then`: Then
- `else`: Else
- `thenOf`: Then, {condition}
- `elseOf`: Else, not {condition}
- `emptyBranch`: Nothing runs on this branch.
- `emptyWorkflow`: No steps yet.
- `addTerm`: Add term
- `removeTerm`: Remove term {number}
- `edit`: Edit
- `editNode`: Edit {title}
- `done`: Done
- `moveUp`: Move {title} up
- `moveDown`: Move {title} down
- `atTop`: It’s already at the top.
- `atBottom`: It’s already at the bottom.
- `move`: Move
- `moveNode`: Move {title}
- `moveHint`: Then the arrow keys choose a place in the graph and Enter drops it there, or tap a place; Escape or Cancel puts it back.
- `cancelMove`: Cancel
- `cancelMoveNode`: Cancel moving {title}
- `triggerFixed`: The trigger always comes first.
- `placeAt`: {index} of {count} in {where}
- `placeHere`: {place}, where it is now
- `pickedUp`: {title} picked up, at {place}. Arrow keys choose a place, Enter drops it there, Escape puts it back.
- `putBack`: {title} put back where it was.
- `delete`: Delete {title}
- `deleteNote`: Until you save, Undo brings it back.
- `addStep`: Step
- `addCondition`: Condition
- `addStepTo`: Add a step to {where}
- `addConditionTo`: Add a condition to {where}
- `whereTop`: the workflow
- `whereThen`: Then of {condition}
- `whereElse`: Else of {condition}
- `addStepKey`: S
- `addConditionKey`: C
- `keyAlt`: Alt
- `keyEnter`: Enter
- `keyDelete`: Delete
- `hintMove`: move
- `hintReorder`: reorder
- `hintEdit`: edit
- `hintDelete`: delete
- `hintAddStep`: add step
- `hintAddCondition`: add condition
- `hintEither`: {a}/{b}
- `hintCombo`: {modifier}+{key}
- `hintItem`: {keys} {action}
- `addedTo`: Added {title} to {where}.
- `movedTo`: Moved {title}: {index} of {count} in {where}.
- `deleted`: Deleted {title}. Undo brings it back.
- `deletedWith`: { one: "Deleted {title} and the {count} step in its branches. Undo brings them back.", other: "Deleted {title} and the {count} steps in its branches. Undo brings them back." }
- `undone`: Undone.
- `wasReset`: Back to {agent}’s proposal. Undo brings your changes back.
- `after`: After {nodes}.
- `afterBranch`: {node}, {branch}
- `problem`: Problem:
- `warning`: Warning:
- `problems`: { one: "{count} problem", other: "{count} problems" }
- `issueWhere`: {where}:
- `ruleUnfinished`: not finished
- `problemsHeading`: To look at before saving
- `fromSave`: From the last save:
- `wholeWorkflow`: The workflow
- `needEvent`: Choose the event that starts it.
- `needAction`: Choose what this step does.
- `needTerms`: Add at least one term.
- `unfinishedTerm`: Finish term {number}: a field, a condition and a value.
- `needNodes`: Add at least one step.
- `hash`: Hash of this workflow
- `save`: Save workflow
- `saving`: Saving…
- `saved`: Saved.
- `lockedSaving`: The workflow is being saved.
- `lockedProblems`: { one: "Fix the {count} problem marked first.", other: "Fix the {count} problems marked first." }
- `lockedHashing`: Working out the hash of the workflow…
- `hashFailed`: The workflow’s hash couldn’t be worked out, so it can’t be saved.
- `alreadySaved`: Saved as it is. Change something to save again.
- `saveFailed`: Saving the workflow failed.

## Types

### WorkflowEvent

Something in your app that can start a workflow: "A ticket is opened".

```ts
{
  id: string;
  /** The event in your app's words. */
  label: string;
  /** A line saying when it fires, shown under the choice. */
  description?: string | undefined;
}
```

### WorkflowAction

Something a step can do, from your app's list: "Send an email", "Assign to a queue".

```ts
{
  id: string;
  /** The action in your app's words. */
  label: string;
  /** A line saying what it does, shown under the choice. */
  description?: string | undefined;
}
```

### WorkflowTrigger

What starts the workflow: an event in your app, a schedule (in `schedule-editor`'s shape), or a person.

```ts
/** When the event fires; `null` while none is chosen. */
{
  kind: "event";
  event: string | null;
}
/** On a schedule, its times wall-clock times in `timeZone` (an IANA name). */
 | {
  kind: "schedule";
  recurrence: ScheduleRecurrence;
  timeZone: string;
}
/** Only when a person starts it. */
 | {
  kind: "manual";
}
```

### WorkflowStep

A step: one action from your app's list, with a note in words.

```ts
{
  /** Stable for this step. Your issues name it, and it is part of what's hashed. */
  id: string;
  kind: "step";
  /** The `id` of a `WorkflowAction`; `null` while none is chosen. */
  action: string | null;
  /** What the step is for, in words. Shown as text, never as HTML or a link. */
  note?: string | undefined;
}
```

### WorkflowCondition

An If/Else: terms built from your app's fields (as `filter-bar`'s rules), and what runs when they hold or don't.

```ts
{
  /** Stable for this condition. Your issues name it, and it is part of what's hashed. */
  id: string;
  kind: "condition";
  /** "all": every term must hold. "any": one is enough. */
  match: "all" | "any";
  /** The terms, as FilterBar's rules: a field, an operator and a value. */
  rules: FilterRule[];
  /** What runs when the terms hold (Then). */
  whenTrue: WorkflowNode[];
  /** What runs when they don't (Else). */
  whenFalse: WorkflowNode[];
}
```

### WorkflowNode

A step or a condition. After a condition, the next node in its list runs whichever branch ran.

```ts
WorkflowStep | WorkflowCondition
```

### WorkflowDefinition

A whole workflow: what starts it, and the nodes that run in order. This is what is saved and hashed.

```ts
{ trigger: WorkflowTrigger; nodes: WorkflowNode[] }
```

### WorkflowIssue

A problem your app found, tied to the trigger or a node. One that names neither is about the whole workflow. Errors lock Save until they're gone; warnings are shown and don't.

```ts
{
  /** The node it is about, by id. */
  node?: string | undefined;
  /** It is about the trigger. */
  trigger?: boolean | undefined;
  /** What is wrong, in words that say how to fix it. */
  message: string;
  /** "error" (the default) locks Save; "warning" doesn't. */
  severity?: "error" | "warning" | undefined;
}
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### HeadingLevel

Import: `import type { HeadingLevel } from "@/lib/candor/heading-level"` (item `heading-level`, https://candor.themethodic.com/docs/heading-level.md)

A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.

```ts
1 | 2 | 3 | 4 | 5 | 6
```

## Motion

- **node** (arrive, fade): A step or condition the person adds fades in where it lands. The agent's nodes, and everything on screen when the editor first drew, are still. A node moved in the list is simply in its new place; one dropped in the graph slides there with GraphView's settle.

Set with `--cd-motion-workflow-editor-<moment>-*` in your brand file; the families are in the design reference.

## Terms used here

- **Change hash**: The SHA-256 of a change in one canonical form (object keys sorted at every depth), the same in the browser and on the server. An approval is made against a hash, so the server can check that what runs is the change the person saw. If the person edits the change before allowing it, the hash is worked out again for the edited version. (https://candor.themethodic.com/glossary.md#change-hash)

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
